check.rs
//! Assert-or-regen over one document: the testing layer.
//!
//! `Checker` wraps a [`Document`] and a [`PatchAccumulator`] (both pub fields;
//! this layer adds behavior, it doesn't hide anything).
//! In normal mode, [`Checker::check`] compares an actual value against a hunk's body.
//! In regen mode -- captured at construction, from the `TESTMARK_REGEN` env var --
//! a difference is recorded as a pending patch instead of failing.
//! Writing the patched text back out is the caller's job: see [`write_back`].
//!
//! # Use with test harnesses
//!
//! One `Checker` per document per process; the crate does no file locking,
//! so two *processes* regenerating the same file concurrently will lose writes.
//! (For `cargo test`, each integration-test binary is its own process:
//! test binaries that share fixture files must not run regen concurrently.)
//! Within one process, parallel tests can share a `Checker` by wrapping it
//! in a `Mutex` at the point where testmark meets the parallel runner;
//! the crate itself stays thread-unaware so single-threaded use pays nothing.
//!
//! Call `write_back` before anything that calls `process::exit`
//! (for example, libtest_mimic's `Conclusion::exit`), or accumulated regen
//! patches are silently dropped.
use std::fmt;
use std::path::{Path, PathBuf};
use crate::document::Document;
use crate::patch::{PatchAccumulator, PatchError};
/// Assert-or-regen over one document. See the module docs for the lifecycle.
pub struct Checker {
pub doc: Document,
pub patches: PatchAccumulator,
regen: bool,
/// The first value each hunk was checked with, for conflict detection.
/// Kept separately from `patches`, which only holds actual differences:
/// a first check that *matched* the document still claims the hunk.
claims: Vec<(String, String)>,
}
impl Checker {
/// Regen mode iff the `TESTMARK_REGEN` env var is set (to any value).
pub fn from_env(doc: Document) -> Checker {
Checker::new(doc, std::env::var_os("TESTMARK_REGEN").is_some())
}
/// Explicit mode selection (mostly for testing harness code itself).
pub fn new(doc: Document, regen: bool) -> Checker {
Checker {
doc,
patches: PatchAccumulator::new(),
regen,
claims: Vec::new(),
}
}
/// Compare `actual` against the named code hunk's body.
///
/// Normal mode: a difference is [`CheckError::Mismatch`].
/// Regen mode: a difference records a patch and returns Ok.
/// Both modes: an absent hunk is an error -- regen rewrites hunks,
/// it does not invent them -- and a second check of the same hunk with a
/// *different* value is [`CheckError::Conflict`]: two tests claiming one
/// hunk is a harness bug, and last-write-wins here would hide it.
pub fn check(&mut self, hunk: &str, actual: &str) -> Result<(), CheckError> {
let h = self.doc.hunk(hunk).ok_or_else(|| CheckError::NoSuchHunk {
name: hunk.to_string(),
})?;
let expected = h.body().ok_or_else(|| CheckError::NotACodeHunk {
name: hunk.to_string(),
})?;
if let Some((_, earlier)) = self.claims.iter().find(|(name, _)| name == hunk) {
if earlier == actual {
return Ok(());
}
return Err(CheckError::Conflict {
name: hunk.to_string(),
earlier: earlier.clone(),
later: actual.to_string(),
});
}
self.claims.push((hunk.to_string(), actual.to_string()));
if actual == expected {
return Ok(());
}
if self.regen {
self.patches.put(hunk, actual);
return Ok(());
}
Err(CheckError::Mismatch {
name: hunk.to_string(),
expected: expected.to_string(),
actual: actual.to_string(),
})
}
}
/// Write the checker's patched document text to `path`, if any patches
/// accumulated. A no-op when there are none, so it's harmless (and cheap)
/// outside regen mode: call it unconditionally at the end of a harness.
pub fn write_back(path: &Path, chk: &Checker) -> Result<(), WriteBackError> {
if chk.patches.is_empty() {
return Ok(());
}
let text = chk.doc.patched_all(&chk.patches)?;
std::fs::write(path, text).map_err(|source| WriteBackError::Io {
path: path.to_path_buf(),
source,
})
}
/// Problems from [`Checker::check`].
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum CheckError {
/// The named hunk is not in the document (in regen mode too:
/// regen rewrites hunks, it does not invent them).
NoSuchHunk {
name: String,
},
/// The named hunk is a table, not a code hunk.
NotACodeHunk {
name: String,
},
/// Normal mode: the actual value differs from the hunk body.
Mismatch {
name: String,
expected: String,
actual: String,
},
/// The same hunk was checked twice with different values
/// (e.g. two tests claiming one hunk, or one test whose subject is
/// expected to be deterministic producing two different outputs).
Conflict {
name: String,
earlier: String,
later: String,
},
}
impl fmt::Display for CheckError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
CheckError::NoSuchHunk {
name,
} => write!(f, "no hunk named {name:?} in the document"),
CheckError::NotACodeHunk {
name,
} => write!(f, "hunk {name:?} is not a code hunk"),
CheckError::Mismatch {
name,
expected,
actual,
} => write!(
f,
"mismatch in {name}:\n--- expected ---\n{expected}--- actual ---\n{actual}--- end ---\n(run with TESTMARK_REGEN=1 to accept the actual output)"
),
CheckError::Conflict {
name,
earlier,
later,
} => write!(
f,
"hunk {name} checked twice with different values (a harness bug):\n--- earlier ---\n{earlier}--- later ---\n{later}--- end ---"
),
}
}
}
impl std::error::Error for CheckError {}
/// Problems from [`write_back`].
#[derive(Debug)]
pub enum WriteBackError {
Patch(PatchError),
Io {
path: PathBuf,
source: std::io::Error,
},
}
impl From<PatchError> for WriteBackError {
fn from(e: PatchError) -> WriteBackError {
WriteBackError::Patch(e)
}
}
impl fmt::Display for WriteBackError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
WriteBackError::Patch(e) => write!(f, "{e}"),
WriteBackError::Io {
path,
source,
} => write!(f, "writing {}: {source}", path.display()),
}
}
}
impl std::error::Error for WriteBackError {}
include!("check_test.rs");
crates/testmark/src/check.rs · 6.1 KiB · 20c0b098ea