mod baseline_common;
use std::panic::AssertUnwindSafe;
use std::path::{Path, PathBuf};
use baseline_common::{
CORPUS_ROOT, FileFingerprint, IN_TREE_FIXTURE_PREFIX, MIN_FULL_CORPUS_SIZE, compute_manifest,
discover_corpus_files, is_in_tree_fixture, panic_payload_hash, read_committed_manifest,
repo_root, write_manifest,
};
const MANIFEST_PATH: &str = "tests/baselines/parser-corpus.manifest";
const MANIFEST_HEADER: &[&str] = &[
"# Parser-output baseline. See crates/rustledger-parser/tests/corpus_baseline.rs.",
"# Format: path<TAB>source_hash<TAB>parser_output_hash",
"# Regenerate: BASELINE_UPDATE=1 cargo test -p rustledger-parser --test corpus_baseline",
];
fn fingerprint(absolute_path: &Path) -> FileFingerprint {
let source = match std::fs::read_to_string(absolute_path) {
Ok(s) => s,
Err(e) => {
let tag = format!("read-error:{:?}", e.kind());
return FileFingerprint {
source: tag.clone(),
parser: tag,
};
}
};
let source_hash = blake3::hash(source.as_bytes()).to_hex().to_string();
let parse_outcome =
std::panic::catch_unwind(AssertUnwindSafe(|| rustledger_parser::parse(&source)));
let result = match parse_outcome {
Ok(r) => r,
Err(payload) => {
return FileFingerprint {
source: source_hash,
parser: format!("panic:{}", panic_payload_hash(&*payload)),
};
}
};
FileFingerprint {
source: source_hash,
parser: parser_hash_of(&result),
}
}
fn parser_hash_of(result: &rustledger_parser::ParseResult) -> String {
let payload = rustledger_parser::__baseline_canonical_payload(result);
blake3::hash(&payload).to_hex().to_string()
}
#[test]
fn parser_output_matches_baseline() {
let manifest_abs = repo_root().join(MANIFEST_PATH);
let fp = |p: &Path| Some(fingerprint(p));
let update = std::env::var_os("BASELINE_UPDATE").is_some();
let strict = std::env::var_os("STRICT_BASELINE").is_some();
let current = compute_manifest(fp);
if current.len() < MIN_FULL_CORPUS_SIZE {
assert!(
!strict,
"STRICT_BASELINE: current corpus has {} files (need at \
least {MIN_FULL_CORPUS_SIZE}). Did \
`fetch-compat-test-files.sh` run?",
current.len(),
);
assert!(
!update,
"BASELINE_UPDATE=1 refusing to write a manifest from only \
{} files (need at least {MIN_FULL_CORPUS_SIZE}). Run \
`./scripts/fetch-compat-test-files.sh` first; an unguarded \
regen would silently truncate the committed manifest.",
current.len(),
);
eprintln!(
"corpus at `{CORPUS_ROOT}` has only {} files (need at least \
{MIN_FULL_CORPUS_SIZE}). Run \
`./scripts/fetch-compat-test-files.sh`; skipping baseline \
check. CI uses STRICT_BASELINE=1 to make this a hard \
failure.",
current.len(),
);
return;
}
if update {
write_manifest(&manifest_abs, ¤t, MANIFEST_HEADER);
return;
}
let committed = read_committed_manifest(&manifest_abs);
let mut parser_drift: Vec<(&PathBuf, &str, &str)> = Vec::new();
let mut source_drift: Vec<&PathBuf> = Vec::new();
let mut missing_from_corpus: Vec<&PathBuf> = Vec::new();
let mut missing_from_manifest: Vec<&PathBuf> = Vec::new();
let mut previously_broken_resolved: Vec<&PathBuf> = Vec::new();
for (path, expected) in &committed {
match current.get(path) {
None => missing_from_corpus.push(path),
Some(current_fp) if current_fp.source != expected.source => {
source_drift.push(path);
}
Some(current_fp) if current_fp.parser != expected.parser => {
let was_broken = expected.parser.starts_with("panic:")
|| expected.parser.starts_with("read-error:");
let is_now_real = !current_fp.parser.starts_with("panic:")
&& !current_fp.parser.starts_with("read-error:");
if was_broken && is_now_real {
previously_broken_resolved.push(path);
} else {
parser_drift.push((path, expected.parser.as_str(), current_fp.parser.as_str()));
}
}
Some(_) => {}
}
}
for path in current.keys() {
if !committed.contains_key(path) {
missing_from_manifest.push(path);
}
}
let unmanifested_in_tree: Vec<&PathBuf> = missing_from_manifest
.iter()
.filter(|p| is_in_tree_fixture(p))
.copied()
.collect();
let source_drift_in_tree: Vec<&PathBuf> = source_drift
.iter()
.filter(|p| is_in_tree_fixture(p))
.copied()
.collect();
let unprotected_in_strict =
strict && (!unmanifested_in_tree.is_empty() || !source_drift_in_tree.is_empty());
if parser_drift.is_empty() && !unprotected_in_strict {
if !source_drift.is_empty() {
eprintln!(
"info: {} corpus file(s) have new upstream content \
(source hash changed). Parser output on those files \
was NOT checked. Regenerate when convenient:\n \
BASELINE_UPDATE=1 cargo test -p rustledger-parser \
--test corpus_baseline",
source_drift.len(),
);
}
if !missing_from_manifest.is_empty() {
eprintln!(
"warning: {} corpus file(s) have no manifest entry.",
missing_from_manifest.len(),
);
}
if !missing_from_corpus.is_empty() {
eprintln!(
"warning: {} manifest entry/entries refer to files no \
longer present in the corpus.",
missing_from_corpus.len(),
);
}
if !previously_broken_resolved.is_empty() {
eprintln!(
"info: {} file(s) previously recorded as a sentinel \
(`panic:*` or `read-error:*`) now parse cleanly. \
Improvement, not regression; regenerate when convenient.",
previously_broken_resolved.len(),
);
}
return;
}
let mut report = String::new();
if !parser_drift.is_empty() {
report.push_str(&format!(
"Parser-output drift on {} file(s) with unchanged source \
(first 10 shown):\n",
parser_drift.len(),
));
for (path, expected, current) in parser_drift.iter().take(10) {
report.push_str(&format!(
" {path}\n expected: {e}\n current: {c}\n",
path = path.display(),
e = &expected[..16.min(expected.len())],
c = ¤t[..16.min(current.len())],
));
}
}
if strict && !unmanifested_in_tree.is_empty() {
report.push_str(&format!(
"\n{} in-tree fixture(s) have no manifest entry (first 10):\n",
unmanifested_in_tree.len(),
));
for path in unmanifested_in_tree.iter().take(10) {
report.push_str(&format!(" {}\n", path.display()));
}
}
if strict && !source_drift_in_tree.is_empty() {
report.push_str(&format!(
"\n{} in-tree fixture(s) have edited source without a \
manifest regen (first 10):\n",
source_drift_in_tree.len(),
));
for path in source_drift_in_tree.iter().take(10) {
report.push_str(&format!(" {}\n", path.display()));
}
}
panic!(
"Parser baseline drift:\n\n{report}\nIf this drift is \
intentional, regenerate:\n \
BASELINE_UPDATE=1 cargo test -p rustledger-parser --test \
corpus_baseline\n\nReview the diff against `{MANIFEST_PATH}` \
and commit.",
);
}
#[test]
fn discovery_finds_in_tree_plugin_fixtures() {
let files = discover_corpus_files();
let has_plugin_fixture = files.iter().any(|p| p.starts_with(IN_TREE_FIXTURE_PREFIX));
assert!(
has_plugin_fixture,
"expected to find at least one in-tree fixture under \
`{IN_TREE_FIXTURE_PREFIX}`; got {} corpus files total. \
Check CORPUS_ROOT resolution and the `.gitignore` exception.",
files.len()
);
}
#[test]
fn fingerprint_is_deterministic_within_one_binary() {
let fixture = r#"
; Exercises directives with metadata to catch any HashMap-of-strings
; leaking iteration order into the fingerprint.
option "title" "T"
plugin "p"
include "i.beancount"
2024-01-01 open Assets:Bank USD
meta-key-a: "a"
meta-key-b: "b"
meta-key-c: "c"
2024-01-02 * "Coffee"
meta-on-txn: 1
Assets:Bank -3.50 USD
meta-on-posting-1: "x"
meta-on-posting-2: "y"
Expenses:Food
2024-01-03 balance Assets:Bank -3.50 USD
2024-01-04 close Assets:Bank
"#;
let tmp = std::env::temp_dir().join(format!(
"corpus-baseline-determinism-{}.beancount",
std::process::id()
));
std::fs::write(&tmp, fixture).expect("write temp fixture");
let h1 = fingerprint(&tmp);
let h2 = fingerprint(&tmp);
std::fs::remove_file(&tmp).ok();
assert_eq!(
h1, h2,
"fingerprint() produced different hashes on identical input \
within one binary. This usually means a HashMap-shaped field \
in ParseResult (or one of its nested types) is leaking its \
iteration order into Debug formatting. Update the fingerprint \
to canonicalize the new field; see the module rustdoc."
);
}
fn assert_field_in_hash(
baseline_hash: &str,
baseline_src: &str,
field_name: &str,
mutate: impl FnOnce(&mut rustledger_parser::ParseResult),
) {
let mut variant = rustledger_parser::parse(baseline_src);
mutate(&mut variant);
let variant_hash = parser_hash_of(&variant);
assert_ne!(
variant_hash, baseline_hash,
"field `{field_name}` is not covered by parser_hash_of(): mutating \
it produced an identical fingerprint. Add it to parser_hash_of()."
);
}
#[test]
fn fingerprint_covers_every_parse_result_field() {
let baseline_src = "; comment line\n\
option \"title\" \"T\"\n\
plugin \"p\"\n\
include \"i.beancount\"\n\
2024-01-01 open Assets:Bank USD\n\
2024-01-02 * \"x\"\n Assets:Bank 1 USD\n Income:Other\n";
let baseline = rustledger_parser::parse(baseline_src);
let baseline_hash = parser_hash_of(&baseline);
assert_field_in_hash(&baseline_hash, baseline_src, "directives", |v| {
v.directives.clear();
});
assert_field_in_hash(&baseline_hash, baseline_src, "options", |v| {
v.options.clear();
});
assert_field_in_hash(&baseline_hash, baseline_src, "includes", |v| {
v.includes.clear();
});
assert_field_in_hash(&baseline_hash, baseline_src, "plugins", |v| {
v.plugins.clear();
});
assert_field_in_hash(&baseline_hash, baseline_src, "comments", |v| {
v.comments.clear();
});
assert_field_in_hash(&baseline_hash, baseline_src, "errors", |v| {
use rustledger_parser::{ParseError, ParseErrorKind, Span};
v.errors.push(ParseError::new(
ParseErrorKind::UnexpectedEof,
Span::new(0, 0),
));
});
assert_field_in_hash(&baseline_hash, baseline_src, "warnings", |v| {
use rustledger_parser::{ParseWarning, Span};
v.warnings
.push(ParseWarning::new("synthetic", Span::new(0, 0)));
});
assert_field_in_hash(&baseline_hash, baseline_src, "currency_occurrences", |v| {
v.currency_occurrences.clear();
});
assert_field_in_hash(&baseline_hash, baseline_src, "account_occurrences", |v| {
v.account_occurrences.clear();
});
assert_field_in_hash(&baseline_hash, baseline_src, "has_leading_bom", |v| {
v.has_leading_bom = !v.has_leading_bom;
});
}