tytanic_utils/
fs.rs

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