Skip to main content

dev_fixtures/
lib.rs

1//! # dev-fixtures
2//!
3//! Repeatable test environments, sample data, and controlled inputs for
4//! Rust. Part of the `dev-*` verification suite.
5//!
6//! ## Why
7//!
8//! Tests are only useful if they are repeatable. AI agents in particular
9//! need fixtures that:
10//!
11//! - Build the same way every time
12//! - Clean themselves up
13//! - Provide both happy-path and adversarial inputs
14//!
15//! `dev-fixtures` provides primitives for building those environments.
16//!
17//! ## Quick example
18//!
19//! ```no_run
20//! use dev_fixtures::TempProject;
21//!
22//! let project = TempProject::new()
23//!     .with_file("Cargo.toml", "[package]\nname = \"sample\"\n")
24//!     .with_file("src/lib.rs", "pub fn answer() -> u32 { 42 }")
25//!     .build()
26//!     .unwrap();
27//!
28//! // project.path() points at a temp directory.
29//! // It is deleted automatically when `project` is dropped.
30//! ```
31//!
32//! ## Modules
33//!
34//! - [`tree`] — `FileTree` builder with workspace and symlink helpers.
35//! - [`adversarial`] — generators for oversized, malformed, and unusual inputs.
36//! - [`golden`] — snapshot-based verification with `dev-report` integration.
37//! - [`mock`] — deterministic mock data (CSV, JSON, bytes).
38
39#![cfg_attr(docsrs, feature(doc_cfg))]
40#![warn(missing_docs)]
41#![warn(rust_2018_idioms)]
42
43use std::fs;
44use std::io;
45use std::path::{Path, PathBuf};
46
47use dev_report::{CheckResult, Evidence, Producer, Report, Severity};
48
49pub mod adversarial;
50pub mod golden;
51pub mod mock;
52pub mod tree;
53
54/// A temporary project directory that auto-cleans on drop.
55///
56/// Holds an internal `tempfile::TempDir`. The temp directory is deleted
57/// when this value is dropped.
58///
59/// # Example
60///
61/// ```
62/// use dev_fixtures::TempProject;
63///
64/// let p = TempProject::new()
65///     .with_file("README.md", "hello")
66///     .build()
67///     .unwrap();
68/// assert!(p.path().join("README.md").exists());
69/// ```
70pub struct TempProject {
71    _dir: tempfile::TempDir,
72    files: Vec<(PathBuf, Vec<u8>)>,
73}
74
75impl TempProject {
76    /// Begin building a temp project.
77    ///
78    /// Returns a [`TempProjectBuilder`] (not `Self`); call `.build()`
79    /// on it to materialize the directory.
80    #[allow(clippy::new_ret_no_self)]
81    pub fn new() -> TempProjectBuilder {
82        TempProjectBuilder::default()
83    }
84
85    /// Path to the root of the temp project.
86    pub fn path(&self) -> &Path {
87        self._dir.path()
88    }
89
90    /// Files declared at build time. Useful for diagnostics.
91    pub fn declared_files(&self) -> impl Iterator<Item = (&Path, &[u8])> {
92        self.files.iter().map(|(p, b)| (p.as_path(), b.as_slice()))
93    }
94}
95
96/// Builder for [`TempProject`].
97///
98/// # Example
99///
100/// ```
101/// use dev_fixtures::TempProject;
102///
103/// let _ = TempProject::new()
104///     .with_file("a.txt", "hello")
105///     .with_bytes("b.bin", vec![1, 2, 3])
106///     .build()
107///     .unwrap();
108/// ```
109#[derive(Default)]
110pub struct TempProjectBuilder {
111    files: Vec<(PathBuf, Vec<u8>)>,
112}
113
114impl TempProjectBuilder {
115    /// Stage a UTF-8 text file at `relative_path` inside the temp project.
116    pub fn with_file(
117        mut self,
118        relative_path: impl Into<PathBuf>,
119        contents: impl Into<String>,
120    ) -> Self {
121        self.files
122            .push((relative_path.into(), contents.into().into_bytes()));
123        self
124    }
125
126    /// Stage a binary file at `relative_path` inside the temp project.
127    pub fn with_bytes(
128        mut self,
129        relative_path: impl Into<PathBuf>,
130        contents: impl Into<Vec<u8>>,
131    ) -> Self {
132        self.files.push((relative_path.into(), contents.into()));
133        self
134    }
135
136    /// Build the temp project on disk.
137    pub fn build(self) -> io::Result<TempProject> {
138        let dir = tempfile::tempdir()?;
139        for (rel, bytes) in &self.files {
140            let target = dir.path().join(rel);
141            if let Some(parent) = target.parent() {
142                fs::create_dir_all(parent)?;
143            }
144            fs::write(&target, bytes)?;
145        }
146        Ok(TempProject {
147            _dir: dir,
148            files: self.files,
149        })
150    }
151}
152
153/// A trait for any fixture that can be set up and torn down.
154///
155/// Implementors should ensure that `tear_down` is idempotent and that
156/// `set_up` followed by `tear_down` always returns the system to a clean
157/// state.
158///
159/// # Example
160///
161/// ```
162/// use dev_fixtures::Fixture;
163/// use std::io;
164///
165/// struct MyFixture;
166/// impl Fixture for MyFixture {
167///     type Output = u32;
168///     fn set_up(&mut self) -> io::Result<u32> { Ok(42) }
169///     fn tear_down(&mut self) -> io::Result<()> { Ok(()) }
170/// }
171/// ```
172pub trait Fixture {
173    /// Output produced when the fixture is set up.
174    type Output;
175
176    /// Set the fixture up. Returns the output (e.g. a path, a handle).
177    fn set_up(&mut self) -> io::Result<Self::Output>;
178
179    /// Tear the fixture down. MUST be idempotent.
180    fn tear_down(&mut self) -> io::Result<()>;
181
182    /// Run set_up and emit a [`CheckResult`] tagged `fixtures`.
183    ///
184    /// On `Ok(_)`, verdict is `Pass` with `setup_ok=1` evidence.
185    /// On `Err(e)`, verdict is `Fail (Critical)` with `setup_failed`
186    /// + `regression` tags and `setup_ok=0` evidence.
187    ///
188    /// Default impl wraps `set_up` and discards the output. Override
189    /// if you need to inspect the produced value before returning.
190    ///
191    /// # Example
192    ///
193    /// ```
194    /// use dev_fixtures::Fixture;
195    /// use std::io;
196    ///
197    /// struct OkFixture;
198    /// impl Fixture for OkFixture {
199    ///     type Output = ();
200    ///     fn set_up(&mut self) -> io::Result<()> { Ok(()) }
201    ///     fn tear_down(&mut self) -> io::Result<()> { Ok(()) }
202    /// }
203    /// let check = OkFixture.set_up_checked("ok");
204    /// assert!(check.has_tag("fixtures"));
205    /// ```
206    fn set_up_checked(&mut self, name: impl Into<String>) -> CheckResult {
207        let name = format!("fixtures::{}", name.into());
208        match self.set_up() {
209            Ok(_) => {
210                let mut c = CheckResult::pass(name).with_detail("set_up succeeded");
211                c.tags = vec!["fixtures".to_string()];
212                c.evidence = vec![Evidence::numeric("setup_ok", 1.0)];
213                c
214            }
215            Err(e) => {
216                let mut c = CheckResult::fail(name, Severity::Critical)
217                    .with_detail(format!("set_up failed: {}", e));
218                c.tags = vec![
219                    "fixtures".to_string(),
220                    "setup_failed".to_string(),
221                    "regression".to_string(),
222                ];
223                c.evidence = vec![Evidence::numeric("setup_ok", 0.0)];
224                c
225            }
226        }
227    }
228}
229
230/// Producer wrapper that runs a self-test of fixture lifecycle and
231/// emits a [`Report`].
232///
233/// The producer takes a closure that builds and tears down a fixture.
234/// It emits one `CheckResult` for set_up and one for tear_down.
235///
236/// # Example
237///
238/// ```no_run
239/// use dev_fixtures::{FixtureProducer, TempProject};
240/// use dev_report::Producer;
241///
242/// let producer = FixtureProducer::new(
243///     "temp_project_lifecycle",
244///     "0.1.0",
245///     || {
246///         let p = TempProject::new()
247///             .with_file("x.txt", "hi")
248///             .build()?;
249///         // path() exists, files exist; dropping `p` cleans up.
250///         drop(p);
251///         Ok(())
252///     },
253/// );
254/// let report = producer.produce();
255/// assert_eq!(report.checks.len(), 1);
256/// ```
257pub struct FixtureProducer<F>
258where
259    F: Fn() -> io::Result<()>,
260{
261    name: String,
262    subject_version: String,
263    run: F,
264}
265
266impl<F> FixtureProducer<F>
267where
268    F: Fn() -> io::Result<()>,
269{
270    /// Build a new producer.
271    pub fn new(name: impl Into<String>, subject_version: impl Into<String>, run: F) -> Self {
272        Self {
273            name: name.into(),
274            subject_version: subject_version.into(),
275            run,
276        }
277    }
278}
279
280impl<F> Producer for FixtureProducer<F>
281where
282    F: Fn() -> io::Result<()>,
283{
284    fn produce(&self) -> Report {
285        let check_name = format!("fixtures::{}", self.name);
286        let started = std::time::Instant::now();
287        let check = match (self.run)() {
288            Ok(()) => {
289                let elapsed = started.elapsed();
290                let mut c = CheckResult::pass(check_name)
291                    .with_duration_ms(elapsed.as_millis() as u64)
292                    .with_detail("fixture lifecycle completed cleanly");
293                c.tags = vec!["fixtures".to_string()];
294                c.evidence = vec![
295                    Evidence::numeric("setup_ok", 1.0),
296                    Evidence::numeric("elapsed_ms", elapsed.as_millis() as f64),
297                ];
298                c
299            }
300            Err(e) => {
301                let mut c = CheckResult::fail(check_name, Severity::Critical)
302                    .with_detail(format!("fixture lifecycle failed: {}", e));
303                c.tags = vec![
304                    "fixtures".to_string(),
305                    "setup_failed".to_string(),
306                    "regression".to_string(),
307                ];
308                c.evidence = vec![Evidence::numeric("setup_ok", 0.0)];
309                c
310            }
311        };
312        let mut r = Report::new(self.name.clone(), self.subject_version.clone())
313            .with_producer("dev-fixtures");
314        r.push(check);
315        r.finish();
316        r
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    #[test]
325    fn temp_project_builds_and_writes_files() {
326        let project = TempProject::new()
327            .with_file("a.txt", "hello")
328            .with_file("nested/b.txt", "world")
329            .build()
330            .unwrap();
331
332        let a = project.path().join("a.txt");
333        let b = project.path().join("nested").join("b.txt");
334        assert!(a.exists());
335        assert!(b.exists());
336        assert_eq!(std::fs::read_to_string(&a).unwrap(), "hello");
337        assert_eq!(std::fs::read_to_string(&b).unwrap(), "world");
338    }
339
340    #[test]
341    fn temp_project_cleans_up_on_drop() {
342        let path = {
343            let project = TempProject::new()
344                .with_file("x.txt", "ephemeral")
345                .build()
346                .unwrap();
347            project.path().to_path_buf()
348        };
349        assert!(!path.exists());
350    }
351
352    #[test]
353    fn temp_project_cleans_up_on_panic() {
354        let path = {
355            let project = TempProject::new()
356                .with_file("x.txt", "panicky")
357                .build()
358                .unwrap();
359            let path = project.path().to_path_buf();
360            // Simulate panic-then-drop by capturing the panic.
361            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
362                let _proj = project;
363                panic!("test panic");
364            }));
365            assert!(result.is_err());
366            path
367        };
368        assert!(!path.exists());
369    }
370
371    struct OkFixture;
372    impl Fixture for OkFixture {
373        type Output = ();
374        fn set_up(&mut self) -> io::Result<()> {
375            Ok(())
376        }
377        fn tear_down(&mut self) -> io::Result<()> {
378            Ok(())
379        }
380    }
381
382    struct FailingFixture;
383    impl Fixture for FailingFixture {
384        type Output = ();
385        fn set_up(&mut self) -> io::Result<()> {
386            Err(io::Error::other("boom"))
387        }
388        fn tear_down(&mut self) -> io::Result<()> {
389            Ok(())
390        }
391    }
392
393    #[test]
394    fn set_up_checked_pass_path() {
395        let c = OkFixture.set_up_checked("ok");
396        assert_eq!(c.verdict, dev_report::Verdict::Pass);
397        assert!(c.has_tag("fixtures"));
398    }
399
400    #[test]
401    fn set_up_checked_fail_path() {
402        let c = FailingFixture.set_up_checked("bad");
403        assert_eq!(c.verdict, dev_report::Verdict::Fail);
404        assert!(c.has_tag("setup_failed"));
405        assert!(c.has_tag("regression"));
406    }
407
408    #[test]
409    fn fixture_producer_emits_report() {
410        let producer = FixtureProducer::new("smoke", "0.1.0", || {
411            let _p = TempProject::new().with_file("a.txt", "x").build()?;
412            Ok(())
413        });
414        let report = producer.produce();
415        assert_eq!(report.checks.len(), 1);
416        assert_eq!(report.producer.as_deref(), Some("dev-fixtures"));
417        assert_eq!(report.overall_verdict(), dev_report::Verdict::Pass);
418    }
419
420    #[test]
421    fn fixture_producer_failed_setup_yields_fail() {
422        let producer = FixtureProducer::new("broken", "0.1.0", || {
423            Err(io::Error::new(io::ErrorKind::PermissionDenied, "nope"))
424        });
425        let report = producer.produce();
426        assert_eq!(report.overall_verdict(), dev_report::Verdict::Fail);
427    }
428}