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