use std::fs;
use anyhow::{Context as _, Result};
use super::{ExecutionMode, PhysicalCase, RunResult, STAGED_FILE, fixture_path};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct ReportCounts {
pub(crate) insertions: usize,
pub(crate) deletions: usize,
}
impl ReportCounts {
const NONE: Self = Self {
insertions: 0,
deletions: 0,
};
pub(crate) fn is_empty(self) -> bool { self == Self::NONE }
}
pub(crate) fn assert_reporting_invariants(
case: &PhysicalCase,
printed: &str,
result: &RunResult,
) -> Result<bool> {
let fixture = fs::read(fixture_path(case.logical.fixture))
.with_context(|| format!("read matrix fixture '{}'", case.logical.fixture))?;
assert_eq!(
result.file_content,
fixture,
"{}: a reporting mode must not write",
case.snapshot_name(),
);
let source = std::str::from_utf8(&fixture).expect("matrix fixtures are UTF-8");
let drifted = printed != source;
let expected = i32::from(drifted);
assert_eq!(
result.output.status.code(),
Some(expected),
"{}: a reporting mode must exit {expected} for this file, stderr: {}",
case.snapshot_name(),
String::from_utf8_lossy(&result.output.stderr),
);
let counts = counts_for(case, result);
assert_eq!(
!counts.is_empty(),
drifted,
"{}: the report and the exit status must describe the same run",
case.snapshot_name(),
);
if drifted && case.mode == ExecutionMode::Diff {
assert_diff_describes_the_document(case, printed, source, result);
}
Ok(drifted)
}
fn counts_for(case: &PhysicalCase, result: &RunResult) -> ReportCounts {
match case.mode {
ExecutionMode::Check => check_counts(case, result),
ExecutionMode::Diff => diff_counts(case, result),
mode => unreachable!("{mode:?} is not a reporting mode"),
}
}
pub(crate) fn check_counts(case: &PhysicalCase, result: &RunResult) -> ReportCounts {
let stdout = String::from_utf8_lossy(&result.output.stdout);
if stdout.is_empty() {
return ReportCounts::NONE;
}
let mut lines = stdout.lines();
let line = lines.next().expect("a non-empty report has a first line");
assert!(
lines.next().is_none(),
"{}: one file is one report line: {stdout:?}",
case.snapshot_name(),
);
let (path, counts) = parse_report_line(line)
.unwrap_or_else(|| panic!("{}: {line:?} is not a report line", case.snapshot_name()));
assert_eq!(
path,
STAGED_FILE,
"{}: the report must name the file as it was given",
case.snapshot_name(),
);
counts
}
pub(crate) fn diff_counts(case: &PhysicalCase, result: &RunResult) -> ReportCounts {
let stdout = String::from_utf8_lossy(&result.output.stdout);
if stdout.is_empty() {
return ReportCounts::NONE;
}
let body = diff_body(case, &stdout);
ReportCounts {
insertions: count_marked(body, '+'),
deletions: count_marked(body, '-'),
}
}
fn diff_body<'a>(case: &PhysicalCase, stdout: &'a str) -> &'a str {
let header = format!("--- {STAGED_FILE}\n+++ {STAGED_FILE}\n");
stdout.strip_prefix(&header).unwrap_or_else(|| {
panic!(
"{}: a diff must open with {header:?}: {stdout:?}",
case.snapshot_name(),
)
})
}
fn count_marked(body: &str, marker: char) -> usize {
body.lines().filter(|line| line.starts_with(marker)).count()
}
fn assert_diff_describes_the_document(
case: &PhysicalCase,
printed: &str,
source: &str,
result: &RunResult,
) {
let stdout = String::from_utf8_lossy(&result.output.stdout);
let body = diff_body(case, &stdout);
assert_eq!(
apply(&case.snapshot_name(), body, source),
printed,
"{}: applying the diff to the file must print the document the printing mode wrote",
case.snapshot_name(),
);
}
fn apply(name: &str, body: &str, original: &str) -> String {
let mut source = original.lines();
let mut consumed = 0;
let mut document = String::new();
for line in body.lines() {
let Some((marker, rest)) = line.split_at_checked(1) else {
continue;
};
match marker {
"@" => {
while consumed + 1 < hunk_start(name, line) {
add(&mut document, next_line(name, &mut source));
consumed += 1;
}
}
" " => {
let text = next_line(name, &mut source);
assert_eq!(text, rest, "{name}: a context line must come from the file");
add(&mut document, text);
consumed += 1;
}
"-" => {
let text = next_line(name, &mut source);
assert_eq!(text, rest, "{name}: a deletion must come from the file");
consumed += 1;
}
"+" => add(&mut document, rest),
_ => {}
}
}
for text in source {
add(&mut document, text);
}
document
}
fn hunk_start(name: &str, header: &str) -> usize {
header
.split(' ')
.nth(1)
.and_then(|range| range.strip_prefix('-'))
.and_then(|range| range.split(',').next())
.and_then(|start| start.parse().ok())
.unwrap_or_else(|| panic!("{name}: {header:?} is not a hunk header"))
}
fn next_line<'a>(name: &str, source: &mut impl Iterator<Item = &'a str>) -> &'a str {
source
.next()
.unwrap_or_else(|| panic!("{name}: the diff marks more lines than the file has"))
}
fn add(document: &mut String, line: &str) {
document.push_str(line);
document.push('\n');
}
fn parse_report_line(line: &str) -> Option<(&str, ReportCounts)> {
let mut fields = line.rsplitn(3, ' ');
let deletions: usize = fields.next()?.strip_prefix('-')?.parse().ok()?;
let insertions: usize = fields.next()?.strip_prefix('+')?.parse().ok()?;
let path = fields.next()?;
(!path.is_empty()).then_some((
path,
ReportCounts {
insertions,
deletions,
},
))
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::{ReportCounts, apply, parse_report_line};
#[rstest]
#[case("docs/a.md +12 -8", "docs/a.md", 12, 8)]
#[case("input.dat +1 -0", "input.dat", 1, 0)]
#[case("a b.md +3 -4", "a b.md", 3, 4)]
fn parse_report_line_reads_path_and_counts(
#[case] line: &str,
#[case] path: &str,
#[case] insertions: usize,
#[case] deletions: usize,
) {
let expected = ReportCounts {
insertions,
deletions,
};
assert_eq!(parse_report_line(line), Some((path, expected)));
}
#[rstest]
#[case("")]
#[case("docs/a.md")]
#[case("docs/a.md +12")]
#[case("docs/a.md 12 -8")]
#[case("docs/a.md +x -8")]
#[case(" +12 -8")]
fn parse_report_line_rejects_anything_else(#[case] line: &str) {
assert_eq!(parse_report_line(line), None);
}
#[test]
fn apply_rebuilds_the_document_from_a_hunk() {
let body = "@@ -1,3 +1,3 @@\n-|A|B|\n+| A | B |\n context\n";
assert_eq!(
apply("unit", body, "|A|B|\ncontext\n"),
"| A | B |\ncontext\n",
);
}
#[test]
fn apply_copies_the_lines_between_and_after_hunks() {
let original =
"one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven\ntwelve\n";
let body = "@@ -1 +1 @@\n-one\n+ONE\n@@ -9,2 +9,2 @@\n-nine\n-ten\n+NINE\n+TEN\n";
assert_eq!(
apply("unit", body, original),
"ONE\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nNINE\nTEN\neleven\ntwelve\n",
);
}
#[test]
#[should_panic(expected = "a deletion must come from the file")]
fn apply_rejects_a_deletion_the_file_does_not_carry() {
apply("unit", "@@ -1 +1 @@\n-one\n+ONE\n", "other\n");
}
#[test]
fn apply_ignores_lines_that_carry_no_marker() {
assert_eq!(apply("unit", "not a hunk\n", "other\n"), "other\n");
}
#[test]
#[should_panic(expected = "is not a hunk header")]
fn apply_rejects_a_malformed_hunk_header() { apply("unit", "@@ nonsense\n", "other\n"); }
#[test]
fn apply_ignores_the_no_newline_marker() {
let body = "@@ -1 +1 @@\n-old\n\\ No newline at end of file\n+new\n";
assert_eq!(apply("unit", body, "old\n"), "new\n");
}
}