Skip to main content

nvrim/plugins/
attempt.rs

1//! Exposes a dictionary with a `create_scratch_file` function for selecting and copying scratch files from the attempts
2//! directory.
3
4use std::fs::DirEntry;
5use std::fs::ReadDir;
6use std::path::Path;
7use std::path::PathBuf;
8
9use chrono::DateTime;
10use chrono::Local;
11use nvim_oxi::Dictionary;
12use rootcause::report;
13
14const SCRATCHES_PATH_PARTS: &[&str] = &["yog", "nvrim", "src", "plugins", "attempt"];
15
16/// [`Dictionary`] of scratch file utilities.
17pub fn dict() -> Dictionary {
18    dict! {
19        "create_scratch_file": fn_from!(create_scratch_file),
20    }
21}
22
23/// An available scratch file.
24#[derive(Clone, Debug)]
25#[cfg_attr(test, derive(Eq, PartialEq))]
26struct Scratch {
27    /// The name shown when selecting the scratch file.
28    display_name: String,
29    /// The base name of the scratch file without extension.
30    base_name: String,
31    /// The file extension of the scratch file.
32    extension: String,
33    /// The full path to the scratch file.
34    path: PathBuf,
35}
36
37impl Scratch {
38    /// Attempts to build a [`Scratch`] file from a [`DirEntry`] result.
39    pub fn from(read_dir_res: std::io::Result<DirEntry>) -> Option<rootcause::Result<Self>> {
40        let path = match read_dir_res.map(|entry| entry.path()) {
41            Ok(path) => path,
42            Err(err) => return Some(Err(err.into())),
43        };
44        if !path.is_file() {
45            return None;
46        }
47        let display_name = match path.file_name().map(|s| s.to_string_lossy()) {
48            Some(s) => s.to_string(),
49            None => {
50                return Some(Err(
51                    report!("error missing file name in path").attach(format!("path={}", path.display()))
52                ));
53            }
54        };
55        let base_name = match path.file_stem().map(|s| s.to_string_lossy()) {
56            Some(s) => s.to_string(),
57            None => {
58                return Some(Err(
59                    report!("error missing file stem in path").attach(format!("path={}", path.display()))
60                ));
61            }
62        };
63        let extension = match path.extension().map(|s| s.to_string_lossy()) {
64            Some(s) => s.to_string(),
65            None => {
66                return Some(Err(
67                    report!("error missing extension in path").attach(format!("path={}", path.display()))
68                ));
69            }
70        };
71
72        Some(Ok(Self {
73            display_name,
74            base_name,
75            extension,
76            path,
77        }))
78    }
79
80    /// Generates the destination file path for the scratch.
81    ///
82    /// The path is constructed as `{dest_dir}/{base_name}-{timestamp}.{extension}` where timestamp is a provided
83    /// [`Local`] [`DateTime`].
84    pub fn dest_file_path(&self, dest_dir: &Path, date_time: DateTime<Local>) -> PathBuf {
85        dest_dir.join(format!(
86            "{}-{}.{}",
87            self.base_name,
88            date_time.format("%Y%m%d-%H%M%S"),
89            self.extension
90        ))
91    }
92}
93
94/// Creates a scratch file by selecting and copying a template file.
95///
96/// This function retrieves available scratch files, presents a selection UI to the user,
97/// and creates a new scratch file based on the selection inside a tmp folder.
98fn create_scratch_file(_: ()) {
99    let Ok(scratches_dir_content) = get_scratches_dir_content() else {
100        return;
101    };
102
103    let scratches = scratches_dir_content
104        .into_iter()
105        .filter_map(|entry| {
106            Scratch::from(entry)?
107                .inspect_err(|err| {
108                    ytil_noxi::notify::error(format!("error building Scratch struct | error={err:#?}"));
109                })
110                .ok()
111        })
112        .collect::<Vec<_>>();
113
114    let dest_dir = Path::new("/tmp").join("attempt.rs");
115
116    if let Err(err) = std::fs::create_dir_all(&dest_dir) {
117        ytil_noxi::notify::error(format!(
118            "cannot create dest dir | dest_dir={} error={err:#?}",
119            dest_dir.display()
120        ));
121        return;
122    }
123
124    let callback = {
125        let scratches = scratches.clone();
126        move |choice_idx| {
127            let Some(scratch): Option<&Scratch> = scratches.get(choice_idx) else {
128                return;
129            };
130            let dest = scratch.dest_file_path(&dest_dir, Local::now());
131            if let Err(err) = std::fs::copy(&scratch.path, &dest) {
132                ytil_noxi::notify::error(format!(
133                    "cannot copy file | from={} to={} error={err:#?}",
134                    scratch.path.display(),
135                    dest.display()
136                ));
137                return;
138            }
139            drop(ytil_noxi::buffer::open(&dest, None, None));
140        }
141    };
142
143    if let Err(err) = ytil_noxi::vim_ui_select::open(
144        scratches.iter().map(|scratch| scratch.display_name.as_str()),
145        &[("prompt", "Create scratch file ")],
146        callback,
147        None,
148    ) {
149        ytil_noxi::notify::error(format!("error creating scratch file | error={err:#?}"));
150    }
151}
152
153/// Retrieves the entries of the scratches directory.
154///
155/// # Errors
156/// Returns an error if the workspace root cannot be determined or the directory cannot be read.
157fn get_scratches_dir_content() -> rootcause::Result<ReadDir> {
158    ytil_sys::dir::get_workspace_root()
159        .map(|workspace_root| ytil_sys::dir::build_path(workspace_root, SCRATCHES_PATH_PARTS))
160        .inspect_err(|err| {
161            ytil_noxi::notify::error(format!("error getting workspace root | error={err:#?}"));
162        })
163        .and_then(|dir| std::fs::read_dir(dir).map_err(From::from))
164        .inspect_err(|err| {
165            ytil_noxi::notify::error(format!("error reading attempt files dir | error={err:#?}"));
166        })
167}
168
169#[cfg(test)]
170mod tests {
171    use chrono::TimeZone;
172    use rstest::rstest;
173    use tempfile::TempDir;
174
175    use super::*;
176
177    #[rstest]
178    #[case("test.txt", "test.txt", "test", "txt")]
179    #[case(".hidden.txt", ".hidden.txt", ".hidden", "txt")]
180    fn test_scratch_from_when_valid_file_returns_some_ok(
181        #[case] file_name: &str,
182        #[case] expected_display: &str,
183        #[case] expected_base: &str,
184        #[case] expected_ext: &str,
185    ) {
186        let (_tmp_dir, entry) = dummy_dir_entry(file_name);
187        let expected_path = entry.path();
188
189        let result = Scratch::from(Ok(entry));
190
191        assert2::assert!(let Some(Ok(actual)) = result);
192        pretty_assertions::assert_eq!(
193            actual,
194            Scratch {
195                display_name: expected_display.to_string(),
196                base_name: expected_base.to_string(),
197                extension: expected_ext.to_string(),
198                path: expected_path,
199            },
200        );
201    }
202
203    #[test]
204    fn test_scratch_from_when_directory_returns_none() {
205        let temp_dir = TempDir::new().unwrap();
206        let sub_dir = temp_dir.path().join("subdir");
207        std::fs::create_dir(&sub_dir).unwrap();
208        let mut read_dir = std::fs::read_dir(temp_dir.path()).unwrap();
209        let entry = read_dir.next().unwrap().unwrap();
210
211        let result = Scratch::from(Ok(entry));
212
213        assert!(result.is_none());
214    }
215
216    #[rstest]
217    #[case("test", "missing extension")]
218    #[case(".hidden", "missing extension")]
219    fn test_scratch_from_when_invalid_file_returns_some_expected_error(
220        #[case] file_name: &str,
221        #[case] expected_error: &str,
222    ) {
223        let (_tmp_dir, entry) = dummy_dir_entry(file_name);
224
225        let result = Scratch::from(Ok(entry));
226
227        assert2::assert!(let Some(Err(err)) = result);
228        assert!(err.to_string().contains(expected_error));
229    }
230
231    #[test]
232    fn test_scratch_from_when_io_error_returns_some_expected_err() {
233        let err = std::io::Error::new(std::io::ErrorKind::NotFound, "test error");
234
235        let result = Scratch::from(Err(err));
236
237        assert2::assert!(let Some(Err(e)) = result);
238        assert!(e.to_string().contains("test error"));
239    }
240
241    #[test]
242    fn test_scratch_dest_file_path_returns_expected_path() {
243        let scratch = Scratch {
244            display_name: "test.txt".to_string(),
245            base_name: "test".to_string(),
246            extension: "txt".to_string(),
247            path: PathBuf::from("/some/path/test.txt"),
248        };
249
250        let date_time = Local.with_ymd_and_hms(2023, 1, 1, 12, 0, 0).unwrap();
251        let result = scratch.dest_file_path(Path::new("/tmp"), date_time);
252
253        pretty_assertions::assert_eq!(result, PathBuf::from("/tmp/test-20230101-120000.txt"));
254    }
255
256    fn dummy_dir_entry(file_name: &str) -> (TempDir, DirEntry) {
257        let tmp_dir = TempDir::new().unwrap();
258        let file_path = tmp_dir.path().join(file_name);
259        std::fs::write(&file_path, "content").unwrap();
260        let mut read_dir = std::fs::read_dir(tmp_dir.path()).unwrap();
261        (tmp_dir, read_dir.next().unwrap().unwrap())
262    }
263}