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}