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