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