testmark: add the checking layer (Checker, read_file, write_back)
commit 20c0b098eacac69f739562a7f8b52de262e16e6b · parent 9049dd4f1c
· browse this tree
Ximianth, 2026-08-10 18:31:21 UTC
The testing layer, sitting on Document and PatchAccumulator: check() compares an actual value against a hunk body, and in regen mode (TESTMARK_REGEN in the env, captured at construction) records a patch instead of failing. Downstream harnesses get one call with no env-var branching, and the expected value drops out of their signatures entirely: the document knows it, so a caller can no longer pass a copy that predates an earlier patch. Deliberate semantics: - An absent hunk is an error in both modes: regen rewrites hunks, it does not invent them. - Checking one hunk twice with different values is a Conflict error in both modes: two tests claiming one hunk is a harness bug, and last-write-wins would hide it. Claims are tracked separately from patches, so this holds even when the first check matched the document. - No thread-awareness: a parallel harness wraps the Checker in a Mutex at the point where testmark meets the parallel runner, and single-threaded users pay nothing. - No file locking: one Checker per document per process is the contract; write_back is the caller's explicit final step (and a no-op when no patches accumulated, so harnesses call it unconditionally). read_file/write_back are free functions: Document stays ignorant of where its text lives.
diff --git a/crates/testmark/src/check.rs b/crates/testmark/src/check.rs new file mode 100644 index 0000000..812bb35 --- /dev/null +++ b/crates/testmark/src/check.rs @@ -0,0 +1,201 @@ +//! 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"); diff --git a/crates/testmark/src/check_test.rs b/crates/testmark/src/check_test.rs new file mode 100644 index 0000000..f8f54f5 --- /dev/null +++ b/crates/testmark/src/check_test.rs @@ -0,0 +1,138 @@ +#[cfg(test)] +mod check_tests { + use super::*; + + const DOC: &str = "\ +[testmark]:# (a/expect)\n\ +```\n\ +alpha\n\ +```\n\ +\n\ +[testmark]:# (b/expect)\n\ +```\n\ +beta\n\ +```\n\ +\n\ +[testmark]:# (tbl)\n\ +| h |\n\ +| --- |\n\ +| 1 |\n"; + + fn checker(regen: bool) -> Checker { + Checker::new(Document::parse(DOC).expect("parses"), regen) + } + + #[test] + fn normal_mode_match_is_ok() { + let mut chk = checker(false); + assert_eq!(chk.check("a/expect", "alpha\n"), Ok(())); + assert!(chk.patches.is_empty()); + } + + #[test] + fn normal_mode_mismatch_is_error_with_regen_hint() { + let mut chk = checker(false); + let err = chk.check("a/expect", "omega\n").unwrap_err(); + assert!(matches!(err, CheckError::Mismatch { .. })); + let msg = err.to_string(); + assert!(msg.contains("mismatch in a/expect")); + assert!(msg.contains("alpha")); + assert!(msg.contains("omega")); + assert!(msg.contains("TESTMARK_REGEN=1")); + assert!(chk.patches.is_empty()); + } + + #[test] + fn regen_mode_mismatch_records_a_patch() { + let mut chk = checker(true); + assert_eq!(chk.check("a/expect", "omega\n"), Ok(())); + assert_eq!(chk.patches.get("a/expect"), Some("omega\n")); + let text = chk.doc.patched_all(&chk.patches).unwrap(); + let redoc = Document::parse(&text).expect("reparses"); + assert_eq!(redoc.hunk("a/expect").unwrap().body().unwrap(), "omega\n"); + assert_eq!(redoc.hunk("b/expect").unwrap().body().unwrap(), "beta\n"); + } + + #[test] + fn regen_mode_match_records_nothing() { + let mut chk = checker(true); + assert_eq!(chk.check("a/expect", "alpha\n"), Ok(())); + assert!(chk.patches.is_empty()); + } + + #[test] + fn absent_hunk_is_error_in_both_modes() { + for regen in [false, true] { + let mut chk = checker(regen); + assert_eq!( + chk.check("no/such", "x\n"), + Err(CheckError::NoSuchHunk { + name: "no/such".into(), + }) + ); + } + } + + #[test] + fn table_hunk_is_error() { + let mut chk = checker(false); + assert_eq!( + chk.check("tbl", "x\n"), + Err(CheckError::NotACodeHunk { + name: "tbl".into(), + }) + ); + } + + #[test] + fn recheck_with_same_value_is_ok() { + for regen in [false, true] { + let mut chk = checker(regen); + assert_eq!(chk.check("a/expect", "alpha\n"), Ok(())); + assert_eq!(chk.check("a/expect", "alpha\n"), Ok(())); + } + } + + #[test] + fn recheck_with_different_value_is_conflict() { + // Conflicts must surface in both modes, and regardless of whether the + // first check matched the document (i.e. whether a patch was recorded). + for regen in [false, true] { + for first in ["alpha\n", "omega\n"] { + let mut chk = checker(regen); + let _ = chk.check("a/expect", first); + let err = chk.check("a/expect", "something else\n").unwrap_err(); + assert!( + matches!(err, CheckError::Conflict { .. }), + "regen={regen} first={first:?}: got {err:?}" + ); + } + } + } + + #[test] + fn write_back_roundtrip() { + let dir = std::env::temp_dir().join(format!("testmark-check-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("tmp dir"); + let path = dir.join("fixture.md"); + std::fs::write(&path, DOC).expect("seed fixture"); + + let mut chk = Checker::new(crate::read_file(&path).expect("reads"), true); + chk.check("b/expect", "gamma\n").expect("regen records"); + write_back(&path, &chk).expect("writes"); + + let redoc = crate::read_file(&path).expect("re-reads"); + assert_eq!(redoc.hunk("b/expect").unwrap().body().unwrap(), "gamma\n"); + assert_eq!(redoc.hunk("a/expect").unwrap().body().unwrap(), "alpha\n"); + + std::fs::remove_dir_all(&dir).expect("cleanup"); + } + + #[test] + fn write_back_without_patches_is_a_no_op() { + // Point at a path that does not exist: a no-op must not try to write it. + let chk = checker(false); + let path = std::path::Path::new("/nonexistent/testmark-no-op"); + assert!(write_back(path, &chk).is_ok()); + } +} diff --git a/crates/testmark/src/files.rs b/crates/testmark/src/files.rs new file mode 100644 index 0000000..89bcfbd --- /dev/null +++ b/crates/testmark/src/files.rs @@ -0,0 +1,53 @@ +//! File helpers: reading a testmark document from disk. +//! +//! Free functions, not methods: `Document` itself has no notion of where its +//! text came from, and keeping I/O out of the document model keeps that model +//! pure. (The write-side helper, `write_back`, lives in the `check` module: +//! writing is only ever the return leg of a check-with-regen cycle.) + +use std::fmt; +use std::path::{Path, PathBuf}; + +use crate::document::{Document, ParseError}; + +/// Read and parse a testmark file. +pub fn read_file(path: &Path) -> Result<Document, ReadFileError> { + let text = std::fs::read_to_string(path).map_err(|source| ReadFileError::Io { + path: path.to_path_buf(), + source, + })?; + Document::parse(&text).map_err(|source| ReadFileError::Parse { + path: path.to_path_buf(), + source, + }) +} + +/// Problems from [`read_file`]. +#[derive(Debug)] +pub enum ReadFileError { + Io { + path: PathBuf, + source: std::io::Error, + }, + Parse { + path: PathBuf, + source: ParseError, + }, +} + +impl fmt::Display for ReadFileError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + ReadFileError::Io { + path, + source, + } => write!(f, "reading {}: {source}", path.display()), + ReadFileError::Parse { + path, + source, + } => write!(f, "{}: {source}", path.display()), + } + } +} + +impl std::error::Error for ReadFileError {} diff --git a/crates/testmark/src/lib.rs b/crates/testmark/src/lib.rs index 64b0a22..79e927e 100644 --- a/crates/testmark/src/lib.rs +++ b/crates/testmark/src/lib.rs @@ -23,13 +23,20 @@ //! - [`PatchAccumulator`]: pending hunk rewrites, applied in one pure pass //! by [`Document::patched_all`]. Still pure format manipulation: //! any tool that runs hunks and writes results back can use these two layers. +//! - [`Checker`]: the testing layer. Wraps a document and an accumulator, +//! and owns the assert-or-regen semantics (the `TESTMARK_REGEN` env var). +//! //! This implementation fails closed where the input looks like testmark but isn't quite: //! malformed annotations, annotations without a following fence, unclosed fences, //! and duplicate hunk names are all errors rather than silently skipped hunks. //! (A silently skipped hunk would mean a silently unexercised test.) +mod check; mod document; +mod files; mod patch; +pub use check::{CheckError, Checker, WriteBackError, write_back}; pub use document::{Document, Hunk, HunkContent, NewContent, ParseError, ParsedTable}; +pub use files::{ReadFileError, read_file}; pub use patch::{PatchAccumulator, PatchError};