tytanic_utils/
fs.rs

1//! Helper functions and types for filesystem interactions, including unit test
2//! helpers.
3
4use std::collections::{BTreeMap, BTreeSet};
5use std::fmt::Write;
6use std::path::{Path, PathBuf};
7use std::{fs, io};
8
9use tempdir::TempDir;
10
11use crate::result::{io_not_found, ResultEx};
12
13/// The prefix used for temporary directories in [`TempTestEnv`].
14pub const TEMP_DIR_PREFIX: &str = "tytanic-utils";
15
16/// Creates a new directory and its parent directories if `all` is specified,
17/// but doesn't fail if it already exists.
18///
19/// # Example
20/// ```no_run
21/// # use tytanic_utils::fs::create_dir;
22/// create_dir("foo", true)?;
23/// create_dir("foo", true)?; // second time doesn't fail
24/// # Ok::<_, Box<dyn std::error::Error>>(())
25/// ```
26pub fn create_dir<P>(path: P, all: bool) -> io::Result<()>
27where
28    P: AsRef<Path>,
29{
30    fn inner(path: &Path, all: bool) -> io::Result<()> {
31        let res = if all {
32            fs::create_dir_all(path)
33        } else {
34            fs::create_dir(path)
35        };
36        res.ignore_default(|e| e.kind() == io::ErrorKind::AlreadyExists)
37    }
38
39    inner(path.as_ref(), all)
40}
41
42/// Removes a file, but doesn't fail if it doesn't exist.
43///
44/// # Example
45/// ```no_run
46/// # use tytanic_utils::fs::remove_file;
47/// remove_file("foo.txt")?;
48/// remove_file("foo.txt")?; // second time doesn't fail
49/// # Ok::<_, Box<dyn std::error::Error>>(())
50/// ```
51pub fn remove_file<P>(path: P) -> io::Result<()>
52where
53    P: AsRef<Path>,
54{
55    fn inner(path: &Path) -> io::Result<()> {
56        std::fs::remove_file(path).ignore_default(io_not_found)
57    }
58
59    inner(path.as_ref())
60}
61
62/// Removes a directory, but doesn't fail if it doesn't exist.
63///
64/// # Example
65/// ```no_run
66/// # use tytanic_utils::fs::remove_dir;
67/// remove_dir("foo", true)?;
68/// remove_dir("foo", true)?; // second time doesn't fail
69/// # Ok::<_, Box<dyn std::error::Error>>(())
70/// ```
71pub fn remove_dir<P>(path: P, all: bool) -> io::Result<()>
72where
73    P: AsRef<Path>,
74{
75    fn inner(path: &Path, all: bool) -> io::Result<()> {
76        let res = if all {
77            fs::remove_dir_all(path)
78        } else {
79            fs::remove_dir(path)
80        };
81
82        res.ignore_default(|e| {
83            if io_not_found(e) {
84                let parent_exists = path
85                    .parent()
86                    .and_then(|p| p.try_exists().ok())
87                    .is_some_and(|b| b);
88
89                if !parent_exists {
90                    tracing::error!(?path, "tried removing dir, but parent did not exist");
91                }
92
93                parent_exists
94            } else {
95                false
96            }
97        })
98    }
99
100    inner(path.as_ref(), all)
101}
102
103/// Creates an empty directory, removing any content if it already existed. The
104/// `all` argument is passed through to [`std::fs::create_dir`].
105///
106/// # Example
107/// ```no_run
108/// # use tytanic_utils::fs::ensure_empty_dir;
109/// ensure_empty_dir("foo", true)?;
110/// # Ok::<_, Box<dyn std::error::Error>>(())
111/// ```
112pub fn ensure_empty_dir<P>(path: P, all: bool) -> io::Result<()>
113where
114    P: AsRef<Path>,
115{
116    fn inner(path: &Path, all: bool) -> io::Result<()> {
117        let res = remove_dir(path, true);
118        if all {
119            // if there was nothing to clear, then we simply go on to creation
120            res.ignore_default(io_not_found)?;
121        } else {
122            res?;
123        }
124
125        create_dir(path, all)
126    }
127
128    inner(path.as_ref(), all)
129}
130
131/// Creates a temporary test environment in which files and directories can be
132/// prepared and checked against after the test ran.
133#[derive(Debug)]
134pub struct TempTestEnv {
135    root: TempDir,
136    found: BTreeMap<PathBuf, Option<Vec<u8>>>,
137    expected: BTreeMap<PathBuf, Option<Option<Vec<u8>>>>,
138}
139
140/// Set up the project structure.
141///
142/// See [`TempTestEnv::run`] and [`TempTestEnv::run_no_check`].
143pub struct Setup(TempTestEnv);
144
145impl Setup {
146    /// Create a directory and all its parents within the test root.
147    ///
148    /// May panic if io errros are encountered.
149    pub fn setup_dir<P: AsRef<Path>>(&mut self, path: P) -> &mut Self {
150        let abs_path = self.0.root.path().join(path.as_ref());
151        create_dir(abs_path, true).unwrap();
152        self
153    }
154
155    /// Create a file and all its parent directories within the test root.
156    ///
157    /// May panic if io errros are encountered.
158    pub fn setup_file<P: AsRef<Path>>(&mut self, path: P, content: impl AsRef<[u8]>) -> &mut Self {
159        let abs_path = self.0.root.path().join(path.as_ref());
160        let parent = abs_path.parent().unwrap();
161        if parent != self.0.root.path() {
162            create_dir(parent, true).unwrap();
163        }
164
165        let content = content.as_ref();
166        std::fs::write(&abs_path, content).unwrap();
167        self
168    }
169
170    /// Create a directory and all its parents within the test root.
171    ///
172    /// May panic if io errros are encountered.
173    pub fn setup_file_empty<P: AsRef<Path>>(&mut self, path: P) -> &mut Self {
174        let abs_path = self.0.root.path().join(path.as_ref());
175        let parent = abs_path.parent().unwrap();
176        if parent != self.0.root.path() {
177            create_dir(parent, true).unwrap();
178        }
179
180        std::fs::write(&abs_path, "").unwrap();
181        self
182    }
183}
184
185/// Specify what you expect to see after the test concluded.
186///
187/// See [`TempTestEnv::run`].
188pub struct Expect(TempTestEnv);
189
190impl Expect {
191    /// Ensure a directory exists after a test ran.
192    pub fn expect_dir<P: AsRef<Path>>(&mut self, path: P) -> &mut Self {
193        self.0.add_expected(path.as_ref().to_path_buf(), None);
194        self
195    }
196
197    /// Ensure a file exists after a test ran.
198    pub fn expect_file<P: AsRef<Path>>(&mut self, path: P) -> &mut Self {
199        self.0.add_expected(path.as_ref().to_path_buf(), Some(None));
200        self
201    }
202
203    /// Ensure a file with the given content exists after a test ran.
204    pub fn expect_file_content<P: AsRef<Path>>(
205        &mut self,
206        path: P,
207        content: impl AsRef<[u8]>,
208    ) -> &mut Self {
209        let content = content.as_ref();
210        self.0
211            .add_expected(path.as_ref().to_path_buf(), Some(Some(content.to_owned())));
212        self
213    }
214
215    /// Ensure an empty file exists after a test ran.
216    pub fn expect_file_empty<P: AsRef<Path>>(&mut self, path: P) -> &mut Self {
217        self.0.add_expected(path.as_ref().to_path_buf(), None);
218        self
219    }
220}
221
222impl TempTestEnv {
223    /// Create a test enviroment and run the given test in it.
224    ///
225    /// The given closures for `setup` and `expect` set up the test environment
226    /// and configure the expected end state respectively.
227    pub fn run(
228        setup: impl FnOnce(&mut Setup) -> &mut Setup,
229        test: impl FnOnce(&Path),
230        expect: impl FnOnce(&mut Expect) -> &mut Expect,
231    ) {
232        let dir = Self {
233            root: TempDir::new(TEMP_DIR_PREFIX).unwrap(),
234            found: BTreeMap::new(),
235            expected: BTreeMap::new(),
236        };
237
238        let mut s = Setup(dir);
239        setup(&mut s);
240        let Setup(dir) = s;
241
242        test(dir.root.path());
243
244        let mut e = Expect(dir);
245        expect(&mut e);
246        let Expect(mut dir) = e;
247
248        dir.collect();
249        dir.assert();
250    }
251
252    /// Create a test enviroment and run the given test in it.
253    ///
254    /// This is the same as [`TempTestEnv::run`], but does not check the
255    /// resulting directory structure.
256    pub fn run_no_check(setup: impl FnOnce(&mut Setup) -> &mut Setup, test: impl FnOnce(&Path)) {
257        let dir = Self {
258            root: TempDir::new(TEMP_DIR_PREFIX).unwrap(),
259            found: BTreeMap::new(),
260            expected: BTreeMap::new(),
261        };
262
263        let mut s = Setup(dir);
264        setup(&mut s);
265        let Setup(dir) = s;
266
267        test(dir.root.path());
268    }
269}
270
271impl TempTestEnv {
272    fn add_expected(&mut self, expected: PathBuf, content: Option<Option<Vec<u8>>>) {
273        for ancestor in expected.ancestors() {
274            self.expected.insert(ancestor.to_path_buf(), None);
275        }
276        self.expected.insert(expected, content);
277    }
278
279    fn add_found(&mut self, found: PathBuf, content: Option<Vec<u8>>) {
280        for ancestor in found.ancestors() {
281            self.found.insert(ancestor.to_path_buf(), None);
282        }
283        self.found.insert(found, content);
284    }
285
286    fn read(&mut self, path: PathBuf) {
287        let rel = path.strip_prefix(self.root.path()).unwrap().to_path_buf();
288        if path.metadata().unwrap().is_file() {
289            let content = std::fs::read(&path).unwrap();
290            self.add_found(rel, Some(content));
291        } else {
292            let mut empty = true;
293            for entry in path.read_dir().unwrap() {
294                let entry = entry.unwrap();
295                self.read(entry.path());
296                empty = false;
297            }
298
299            if empty && self.root.path() != path {
300                self.add_found(rel, None);
301            }
302        }
303    }
304
305    fn collect(&mut self) {
306        self.read(self.root.path().to_path_buf())
307    }
308
309    fn assert(mut self) {
310        let mut not_found = BTreeSet::new();
311        let mut not_matched = BTreeMap::new();
312        for (expected_path, expected_value) in self.expected {
313            if let Some(found) = self.found.remove(&expected_path) {
314                let expected = expected_value.unwrap_or_default();
315                let found = found.unwrap_or_default();
316                if let Some(expected) = expected {
317                    if expected != found {
318                        not_matched.insert(expected_path, (found, expected));
319                    }
320                }
321            } else {
322                not_found.insert(expected_path);
323            }
324        }
325
326        let not_expected: BTreeSet<_> = self.found.into_keys().collect();
327
328        let mut mismatch = false;
329        let mut msg = String::new();
330        if !not_found.is_empty() {
331            mismatch = true;
332            writeln!(&mut msg, "\n=== Not found ===").unwrap();
333            for not_found in not_found {
334                writeln!(&mut msg, "/{}", not_found.display()).unwrap();
335            }
336        }
337
338        if !not_expected.is_empty() {
339            mismatch = true;
340            writeln!(&mut msg, "\n=== Not expected ===").unwrap();
341            for not_expected in not_expected {
342                writeln!(&mut msg, "/{}", not_expected.display()).unwrap();
343            }
344        }
345
346        if !not_matched.is_empty() {
347            mismatch = true;
348            writeln!(&mut msg, "\n=== Content matched ===").unwrap();
349            for (path, (found, expected)) in not_matched {
350                writeln!(&mut msg, "/{}", path.display()).unwrap();
351                match (std::str::from_utf8(&found), std::str::from_utf8(&expected)) {
352                    (Ok(found), Ok(expected)) => {
353                        writeln!(&mut msg, "=== Expected ===\n>>>\n{}\n<<<\n", expected).unwrap();
354                        writeln!(&mut msg, "=== Found ===\n>>>\n{}\n<<<\n", found).unwrap();
355                    }
356                    _ => {
357                        writeln!(&mut msg, "Binary data differed").unwrap();
358                    }
359                }
360            }
361        }
362
363        if mismatch {
364            panic!("{msg}")
365        }
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    #[test]
374    fn test_temp_env_run() {
375        TempTestEnv::run(
376            |test| {
377                test.setup_file_empty("foo/bar/empty.txt")
378                    .setup_file_empty("foo/baz/other.txt")
379            },
380            |root| {
381                std::fs::remove_file(root.join("foo/bar/empty.txt")).unwrap();
382            },
383            |test| {
384                test.expect_dir("foo/bar/")
385                    .expect_file_empty("foo/baz/other.txt")
386            },
387        );
388    }
389
390    #[test]
391    #[should_panic]
392    fn test_temp_env_run_panic() {
393        TempTestEnv::run(
394            |test| {
395                test.setup_file_empty("foo/bar/empty.txt")
396                    .setup_file_empty("foo/baz/other.txt")
397            },
398            |root| {
399                std::fs::remove_file(root.join("foo/bar/empty.txt")).unwrap();
400            },
401            |test| test.expect_dir("foo/bar/"),
402        );
403    }
404
405    #[test]
406    fn test_temp_env_run_no_check() {
407        TempTestEnv::run_no_check(
408            |test| {
409                test.setup_file_empty("foo/bar/empty.txt")
410                    .setup_file_empty("foo/baz/other.txt")
411            },
412            |root| {
413                std::fs::remove_file(root.join("foo/bar/empty.txt")).unwrap();
414            },
415        );
416    }
417}