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