1#![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
54pub struct TempProject {
71 _dir: tempfile::TempDir,
72 files: Vec<(PathBuf, Vec<u8>)>,
73}
74
75impl TempProject {
76 #[allow(clippy::new_ret_no_self)]
81 pub fn new() -> TempProjectBuilder {
82 TempProjectBuilder::default()
83 }
84
85 pub fn path(&self) -> &Path {
87 self._dir.path()
88 }
89
90 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#[derive(Default)]
110pub struct TempProjectBuilder {
111 files: Vec<(PathBuf, Vec<u8>)>,
112}
113
114impl TempProjectBuilder {
115 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 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 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
153pub trait Fixture {
173 type Output;
175
176 fn set_up(&mut self) -> io::Result<Self::Output>;
178
179 fn tear_down(&mut self) -> io::Result<()>;
181
182 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
230pub 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 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 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}