use regex::Regex;
use crate::cases::OutputMatch;
pub const DEFAULT_EPSILON: f64 = 1e-9;
enum Strategy {
Trim,
CollapseWhitespace,
TrailingNewline,
RegexExtract(Regex),
NumericTolerance,
}
impl Strategy {
fn transform(&self, s: &str) -> String {
match self {
Self::Trim => s.trim().to_string(),
Self::CollapseWhitespace => collapse_whitespace(s),
Self::TrailingNewline => s.trim_end_matches(['\r', '\n']).to_string(),
Self::RegexExtract(re) => re
.captures(s)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_string())
.unwrap_or_else(|| s.to_string()),
Self::NumericTolerance => s.to_string(),
}
}
}
pub fn collapse_whitespace(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut in_ws = false;
for ch in s.chars() {
if ch.is_whitespace() {
if !in_ws {
out.push(' ');
in_ws = true;
}
} else {
out.push(ch);
in_ws = false;
}
}
out
}
#[derive(Debug, Clone, PartialEq)]
pub struct Comparison {
pub equal: bool,
pub warnings: Vec<String>,
}
fn build(om: &OutputMatch) -> (Vec<Strategy>, Vec<String>) {
let mut steps = Vec::new();
let mut warnings = Vec::new();
for name in &om.normalize {
match name.as_str() {
"trim" => steps.push(Strategy::Trim),
"collapse_whitespace" => steps.push(Strategy::CollapseWhitespace),
"trailing_newline" => steps.push(Strategy::TrailingNewline),
"numeric_tolerance" => steps.push(Strategy::NumericTolerance),
"regex_extract" => match om.extract_pattern.as_deref() {
Some(pat) => match Regex::new(pat) {
Ok(re) => steps.push(Strategy::RegexExtract(re)),
Err(e) => {
warnings.push(format!("regex_extract: invalid pattern {pat:?}: {e}"));
}
},
None => warnings
.push("regex_extract: no extract_pattern configured; skipped".to_string()),
},
other => warnings.push(format!("unknown normalize strategy {other:?}; skipped")),
}
}
(steps, warnings)
}
pub fn compare(actual: &str, expected: &str, om: &OutputMatch) -> Comparison {
let (steps, warnings) = build(om);
let mut a = actual.to_string();
let mut e = expected.to_string();
for step in &steps {
a = step.transform(&a);
e = step.transform(&e);
}
let numeric = steps
.iter()
.any(|s| matches!(s, Strategy::NumericTolerance));
let equal = if numeric {
match (a.trim().parse::<f64>(), e.trim().parse::<f64>()) {
(Ok(av), Ok(ev)) => (av - ev).abs() <= om.epsilon.unwrap_or(DEFAULT_EPSILON),
_ => a == e,
}
} else {
a == e
};
Comparison { equal, warnings }
}
#[cfg(test)]
mod tests {
use super::*;
fn om(normalize: &[&str]) -> OutputMatch {
OutputMatch {
normalize: normalize.iter().map(|s| s.to_string()).collect(),
..Default::default()
}
}
#[test]
fn trim_strips_leading_and_trailing_whitespace() {
assert_eq!(Strategy::Trim.transform(" hi \n"), "hi");
assert_eq!(Strategy::Trim.transform(""), "");
assert_eq!(Strategy::Trim.transform(" \t\n "), "");
}
#[test]
fn collapse_whitespace_reduces_runs_to_single_space() {
assert_eq!(
Strategy::CollapseWhitespace.transform("a \t b\nc"),
"a b c"
);
assert_eq!(Strategy::CollapseWhitespace.transform(" a "), " a ");
assert_eq!(Strategy::CollapseWhitespace.transform(" "), " ");
assert_eq!(Strategy::CollapseWhitespace.transform(""), "");
}
#[test]
fn trailing_newline_drops_only_trailing_crlf() {
assert_eq!(Strategy::TrailingNewline.transform("x\n"), "x");
assert_eq!(Strategy::TrailingNewline.transform("x\r\n"), "x");
assert_eq!(Strategy::TrailingNewline.transform("x\n\n"), "x");
assert_eq!(Strategy::TrailingNewline.transform("a\nb\n"), "a\nb");
}
#[test]
fn regex_extract_pulls_capture_group_one() {
let re = Regex::new(r"Result:\s*(\d+)").unwrap();
let s = Strategy::RegexExtract(re);
assert_eq!(s.transform("noise\nResult: 42\nmore"), "42");
assert_eq!(s.transform("nothing here"), "nothing here");
}
#[test]
fn empty_normalize_is_exact_match() {
assert!(compare("a\n", "a\n", &om(&[])).equal);
assert!(!compare("a\n", "a", &om(&[])).equal);
}
#[test]
fn trim_then_collapse_matches_reformatted_output() {
let c = compare(" a b ", "a b", &om(&["trim", "collapse_whitespace"]));
assert!(c.equal, "{c:?}");
assert!(c.warnings.is_empty());
}
#[test]
fn order_matters_collapse_before_trim_leaves_edge_space() {
assert!(compare(" a ", "a", &om(&["collapse_whitespace", "trim"])).equal);
assert!(compare(" a ", "a", &om(&["trim", "collapse_whitespace"])).equal);
}
#[test]
fn numeric_tolerance_matches_within_epsilon() {
let mut o = om(&["numeric_tolerance"]);
o.epsilon = Some(0.01);
assert!(compare("3.14159", "3.14", &o).equal, "just inside");
assert!(!compare("3.20", "3.14", &o).equal, "just outside");
}
#[test]
fn numeric_tolerance_default_epsilon_is_tight() {
let o = om(&["numeric_tolerance"]);
assert!(compare("2.0000000001", "2.0", &o).equal);
assert!(!compare("2.001", "2.0", &o).equal);
}
#[test]
fn numeric_tolerance_falls_back_to_string_when_not_numeric() {
let mut o = om(&["numeric_tolerance"]);
o.epsilon = Some(1.0);
assert!(compare("hi", "hi", &o).equal);
assert!(!compare("hi", "bye", &o).equal);
}
#[test]
fn regex_extract_then_numeric_tolerance_composes() {
let mut o = om(&["regex_extract", "numeric_tolerance"]);
o.extract_pattern = Some(r"Result:\s*([\d.]+)".to_string());
o.epsilon = Some(0.5);
assert!(compare("Result: 42.3\n", "The answer is Result: 42.0", &o).equal);
}
#[test]
fn unknown_strategy_is_skipped_with_warning_and_compare_still_runs() {
let c = compare(" x ", "x", &om(&["bogus", "trim"]));
assert!(c.equal, "trim still applied");
assert_eq!(c.warnings.len(), 1);
assert!(c.warnings[0].contains("bogus"));
}
#[test]
fn regex_extract_without_pattern_warns_and_is_skipped() {
let c = compare("Result: 5", "Result: 5", &om(&["regex_extract"]));
assert!(
c.equal,
"no pattern → strategy skipped, exact compare holds"
);
assert_eq!(c.warnings.len(), 1);
assert!(c.warnings[0].contains("no extract_pattern"));
}
#[test]
fn regex_extract_invalid_pattern_warns_and_is_skipped() {
let mut o = om(&["regex_extract"]);
o.extract_pattern = Some("(unclosed".to_string());
let c = compare("a", "a", &o);
assert!(c.equal);
assert_eq!(c.warnings.len(), 1);
assert!(c.warnings[0].contains("invalid pattern"));
}
}