/// Check if a single line matches a pattern that may contain inline `...` wildcards. pub fn match_line(pattern: &str, actual: &str) -> bool { if !pattern.contains("...") { return pattern == actual; } let parts: Vec<&str> = pattern.split("...").collect(); let mut pos = 0; for (i, part) in parts.iter().enumerate() { if i == 0 { if !actual.starts_with(part) { return false; } pos = part.len(); } else if i == parts.len() - 1 { if !actual[pos..].ends_with(part) { return false; } } else { match actual[pos..].find(part) { Some(idx) => pos += idx + part.len(), None => return false, } } } true } /// Match expected output against actual output, supporting multi-line `...` wildcards. pub fn match_output(expected: &[String], actual: &[String]) -> bool { do_match(expected, 0, actual, 0) } fn do_match(expected: &[String], ei: usize, actual: &[String], ai: usize) -> bool { // Both exhausted — match if ei == expected.len() && ai == actual.len() { return true; } // Expected exhausted but actual remains — no match if ei == expected.len() { return false; } let exp = &expected[ei]; // Multi-line wildcard if exp == "..." { // Try matching zero or more actual lines for skip in ai..=actual.len() { if do_match(expected, ei + 1, actual, skip) { return true; } } return false; } // Actual exhausted but expected remains — no match if ai == actual.len() { return false; } // Line-level match (with possible inline wildcards) if match_line(exp, &actual[ai]) { return do_match(expected, ei + 1, actual, ai + 1); } false } #[derive(Debug, Clone, PartialEq)] pub enum DiffKind { Equal, Expected, Actual, Context, } #[derive(Debug, Clone)] pub struct DiffLine { pub kind: DiffKind, pub text: String, } pub fn diff(expected: &[String], actual: &[String]) -> Vec { let mut result = Vec::new(); let mut ei = 0; let mut ai = 0; while ei < expected.len() || ai < actual.len() { if ei < expected.len() && expected[ei] == "..." { // Find where the wildcard ends by looking at next expected line let next_exp = if ei + 1 < expected.len() { Some(&expected[ei + 1]) } else { None }; if next_exp.is_none() { // ... at end matches everything remaining result.push(DiffLine { kind: DiffKind::Context, text: "...".to_string() }); break; } // Skip actual lines until we find the next expected match result.push(DiffLine { kind: DiffKind::Context, text: "...".to_string() }); ei += 1; let next = next_exp.unwrap(); while ai < actual.len() && !match_line(next, &actual[ai]) { ai += 1; } continue; } if ei < expected.len() && ai < actual.len() { if match_line(&expected[ei], &actual[ai]) { result.push(DiffLine { kind: DiffKind::Equal, text: actual[ai].clone() }); ei += 1; ai += 1; } else { result.push(DiffLine { kind: DiffKind::Expected, text: expected[ei].clone() }); result.push(DiffLine { kind: DiffKind::Actual, text: actual[ai].clone() }); ei += 1; ai += 1; } } else if ei < expected.len() { result.push(DiffLine { kind: DiffKind::Expected, text: expected[ei].clone() }); ei += 1; } else { result.push(DiffLine { kind: DiffKind::Actual, text: actual[ai].clone() }); ai += 1; } } result }