Skip to main content

ctl_core/
fixture.rs

1//! Files for integration tests, written through one call instead of
2//! `fs::create_dir_all`, `fs::write`, and an `expect` per file.
3//!
4//! A [`Suite`] owns a temporary directory named after the test file, and each
5//! [`Suite::scenario`] owns its own directory inside it. Both are removed when
6//! the value drops, so a test cleans up by going out of scope. A write that
7//! fails panics with the path it could not write: a broken fixture is a broken
8//! test, and the message says which file.
9
10use std::fs;
11use std::marker::PhantomData;
12use std::path::{Component, Path, PathBuf};
13
14use tempfile::{Builder, TempDir};
15
16/// A temporary directory for one test file.
17pub struct Suite {
18    name: String,
19    root: Root<'static>,
20}
21
22impl Suite {
23    /// A fresh directory whose name starts with `name`.
24    ///
25    /// # Panics
26    ///
27    /// When the system temporary directory cannot hold a new directory.
28    #[must_use]
29    #[track_caller]
30    pub fn new(name: &str) -> Self {
31        let dir = Builder::new()
32            .prefix(&format!("{name}-"))
33            .tempdir()
34            .unwrap_or_else(|error| panic!("create a temporary directory for {name}: {error}"));
35        Self {
36            name: name.to_owned(),
37            root: Root {
38                dir,
39                suite: PhantomData,
40            },
41        }
42    }
43
44    /// A fresh directory for one scenario, inside this suite's, removed when
45    /// the returned [`Root`] drops. The scenario borrows the suite, because
46    /// dropping the suite first would delete a directory still in use:
47    ///
48    /// ```compile_fail,E0505
49    /// let suite = ctl_core::fixture::Suite::new("early");
50    /// let root = suite.scenario("orphan");
51    /// drop(suite);
52    /// root.file("a.md", "a");
53    /// ```
54    ///
55    /// # Panics
56    ///
57    /// When the suite's directory cannot hold a new directory.
58    #[must_use]
59    #[track_caller]
60    pub fn scenario(&self, name: &str) -> Root<'_> {
61        let dir = Builder::new()
62            .prefix(&format!("{name}-"))
63            .tempdir_in(self.root.path())
64            .unwrap_or_else(|error| panic!("create {} scenario {name}: {error}", self.name));
65        Root {
66            dir,
67            suite: PhantomData,
68        }
69    }
70
71    /// The suite's own directory.
72    #[must_use]
73    pub fn root(&self) -> &Root<'static> {
74        &self.root
75    }
76}
77
78/// A temporary directory a test writes files into, alive no longer than the
79/// suite it sits in.
80pub struct Root<'suite> {
81    dir: TempDir,
82    suite: PhantomData<&'suite ()>,
83}
84
85impl Root<'_> {
86    /// Where the directory is.
87    #[must_use]
88    pub fn path(&self) -> &Path {
89        self.dir.path()
90    }
91
92    /// Write `body` to `relative`, creating its parent directories, and return
93    /// the full path.
94    ///
95    /// # Panics
96    ///
97    /// When `relative` is absolute or climbs out with `..`, or the file cannot
98    /// be written.
99    #[track_caller]
100    pub fn file(&self, relative: impl AsRef<Path>, body: impl AsRef<[u8]>) -> PathBuf {
101        let path = self.inside(relative.as_ref());
102        if let Some(parent) = path.parent() {
103            fs::create_dir_all(parent)
104                .unwrap_or_else(|error| panic!("create {}: {error}", parent.display()));
105        }
106        fs::write(&path, body).unwrap_or_else(|error| panic!("write {}: {error}", path.display()));
107        path
108    }
109
110    /// Read a file back as text.
111    ///
112    /// # Panics
113    ///
114    /// When `relative` is absolute or climbs out with `..`, or the file cannot
115    /// be read as UTF-8.
116    #[must_use]
117    #[track_caller]
118    pub fn read(&self, relative: impl AsRef<Path>) -> String {
119        let path = self.inside(relative.as_ref());
120        fs::read_to_string(&path).unwrap_or_else(|error| panic!("read {}: {error}", path.display()))
121    }
122
123    /// `relative` joined onto this directory. Joining an absolute path would
124    /// replace the root, so a fixture could write anywhere.
125    #[track_caller]
126    fn inside(&self, relative: &Path) -> PathBuf {
127        assert!(
128            relative
129                .components()
130                .all(|part| matches!(part, Component::Normal(_) | Component::CurDir)),
131            "{} must stay inside the fixture root",
132            relative.display()
133        );
134        self.path().join(relative)
135    }
136}