use std::fs;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::path::{Path, PathBuf};
use rustledger_parser::format::format_source;
use rustledger_parser::parse;
const REQUIRED_FIXTURES: &[&str] = &[
"issue_1252_destructive_repro",
"trailing_comment_on_directive_header",
"trailing_comment_eof_no_newline",
"posting_trailing_comment",
"pushtag_poptag_pair_preserved",
"pushmeta_popmeta_pair_preserved",
"balance_leading_unary_minus_preserves_sign",
"balance_leading_parenthesized_expression",
"price_leading_unary_minus_preserves_sign",
"posting_arithmetic_with_parens",
"metadata_unary_minus_value",
"cost_spec_per_unit_plus_total_marker",
"cost_spec_with_negative_amount",
"commas_stripped_per_canonical_form",
"unary_plus_stripped_per_canonical_form",
"bom_dropped",
"missing_final_newline_added",
"multiple_trailing_blank_lines_collapsed",
"crlf_outside_strings_folded",
"crlf_inside_strings_preserved",
"posting_with_interleaved_metadata",
"multiline_note_string_preserved",
"balance_assertion_with_metadata",
"transaction_tags_and_links_source_order",
"non_latin_account_names",
];
const MIRROR_PAIRS: &[(&str, &str)] = &[
(
"balance_leading_unary_minus_preserves_sign",
"balance_leading_unary_minus",
),
(
"balance_leading_parenthesized_expression",
"balance_leading_parenthesized_expression",
),
(
"price_leading_unary_minus_preserves_sign",
"price_leading_unary_minus",
),
("cost_spec_with_negative_amount", "cost_spec_with_negative"),
(
"cost_spec_with_comma_and_date",
"cost_spec_with_comma_and_date",
),
(
"cost_spec_per_unit_plus_total_marker",
"transaction_with_per_unit_plus_total_cost",
),
("metadata_unary_minus_value", "metadata_unary_minus"),
("metadata_arithmetic_value", "metadata_arithmetic"),
("non_latin_account_names", "non_latin_account_name"),
("posting_trailing_comment", "posting_with_trailing_comment"),
("multiline_note_string_preserved", "multiline_note_string"),
("comment_with_unbalanced_quote", "comment_containing_quote"),
(
"transaction_tags_and_links_source_order",
"transaction_with_tags_and_links",
),
("custom_directive_with_date_value", "custom_with_date_value"),
("options_includes_plugins_block", "options_and_includes"),
(
"balance_assertion_with_metadata",
"balance_assertion_with_meta",
),
("crlf_outside_strings_folded", "crlf_input"),
];
#[test]
fn format_compat_fixtures_match_expected_output() {
let cases_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("format_compat")
.join("cases");
assert!(
cases_dir.is_dir(),
"format_compat cases directory missing at {}",
cases_dir.display(),
);
let mut all_subdirs: Vec<PathBuf> = fs::read_dir(&cases_dir)
.unwrap_or_else(|e| panic!("read_dir({}): {e}", cases_dir.display()))
.map(|entry| {
entry.unwrap_or_else(|e| {
panic!("read_dir entry under {} failed: {e}", cases_dir.display())
})
})
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect();
all_subdirs.sort();
let present_names: std::collections::BTreeSet<String> = all_subdirs
.iter()
.filter_map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
.collect();
let missing_required: Vec<&str> = REQUIRED_FIXTURES
.iter()
.copied()
.filter(|name| !present_names.contains(*name))
.collect();
assert!(
missing_required.is_empty(),
"format_compat coverage dropped: required fixture(s) missing from cases dir: {missing_required:?}. \
Each name in REQUIRED_FIXTURES is a load-bearing bug-class pin; \
removing one is a deliberate change to that constant, not a silent deletion.",
);
let mirror_pair_missing: Vec<&str> = MIRROR_PAIRS
.iter()
.filter(|(file_pair, _)| !present_names.contains(*file_pair))
.map(|(file_pair, _)| *file_pair)
.collect();
assert!(
mirror_pair_missing.is_empty(),
"MIRROR_PAIRS lists file-pair fixture(s) absent from cases dir: {mirror_pair_missing:?}. \
Either add the fixture or update the MIRROR_PAIRS constant in tests/format_compat.rs.",
);
let fixtures: Vec<PathBuf> = all_subdirs
.iter()
.filter(|p| p.join("input.bean").is_file())
.cloned()
.collect();
let mut failures: Vec<String> = Vec::new();
for fixture in &fixtures {
let name = fixture.file_name().map_or_else(
|| fixture.display().to_string(),
|n| n.to_string_lossy().into_owned(),
);
let input_path = fixture.join("input.bean");
let expected_path = fixture.join("expected.bean");
let input = match fs::read_to_string(&input_path) {
Ok(s) => s,
Err(e) => {
failures.push(format!(
"[{name}] missing input.bean ({}): {e}",
input_path.display(),
));
continue;
}
};
let expected = match fs::read_to_string(&expected_path) {
Ok(s) => s,
Err(e) => {
failures.push(format!(
"[{name}] missing expected.bean ({}): {e}",
expected_path.display(),
));
continue;
}
};
let formatted = format_source(&input);
if formatted != expected {
failures.push(format!(
"[{name}] format_source(input) != expected\n--- input ---\n{}\n--- expected ---\n{}\n--- got ---\n{}",
escape_for_diff(&input),
escape_for_diff(&expected),
escape_for_diff(&formatted),
));
}
match catch_unwind(AssertUnwindSafe(|| format_source(&expected))) {
Ok(twice) => {
if twice != expected {
failures.push(format!(
"[{name}] idempotence broken: format_source(expected) != expected\n--- expected ---\n{}\n--- got ---\n{}",
escape_for_diff(&expected),
escape_for_diff(&twice),
));
}
}
Err(_panic) => {
failures.push(format!(
"[{name}] stage-2 panicked: format_source(expected) raised a panic. \
The formatter is contracted to be total over arbitrary input; this is a bug.",
));
}
}
match catch_unwind(AssertUnwindSafe(|| parse(&expected))) {
Ok(parsed) => {
if !parsed.errors.is_empty() {
failures.push(format!(
"[{name}] expected.bean does not parse cleanly ({} error(s)): {:?}",
parsed.errors.len(),
parsed.errors,
));
}
}
Err(_panic) => {
failures.push(format!(
"[{name}] stage-3 panicked: parse(expected) raised a panic. \
The parser is contracted to be total over arbitrary input; this is a bug.",
));
}
}
}
assert!(
failures.is_empty(),
"{} format_compat fixture(s) failed (of {}):\n\n{}",
failures.len(),
fixtures.len(),
failures.join("\n\n"),
);
}
fn escape_for_diff(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 16);
for ch in s.chars() {
match ch {
'\n' => out.push_str("\\n\n"),
c if (' '..='~').contains(&c) => out.push(c),
c => {
for esc in c.escape_debug() {
out.push(esc);
}
}
}
}
out
}