Skip to content

Diffs and test reports

rich_ext::diff compares text and shows the result in the terminal:

  • TextDiff: a line diff whose unified output matches diff -u;
  • DiffView: a renderable diff of plain text, ANSI captures or render snapshots, unified or side by side;
  • SourceDiff: a syntax-highlighted diff of two versions of a file;
  • git::PatchView: a review-style view of git diff output, with annotations and links;
  • TestReport (feature test-report): JUnit XML and libtest JSON results, failures first;
  • assert_rich_eq! and friends (feature testing): assertions that fail with a rendered diff.

Every view marks changed lines with -, + or ~ (a style-only change), so diffs stay readable without colour.

The examples come from guide_diff.rs:

cargo run -p rs-rich-ext --example guide_diff --features testing,test-report

The diff engine, DiffView, SourceDiff and PatchView need no feature.

The engine

TextDiff::new(old, new) runs a linear-space Myers diff over lines. context(n) sets how many unchanged lines surround each change (default 3). stats() returns (added, removed), hunks() returns the grouped hunks, and unified(old_name, new_name) returns the patch text. The result is empty when the inputs are equal, as with diff.

fn text_diff() {
    let diff = TextDiff::new(OLD, NEW).context(1);
    let (added, removed) = diff.stats();
    assert_eq!((added, removed), (3, 2));
    // The same hunks `diff -u` prints, minus timestamps.
    print!("{}", diff.unified("a/server.toml", "b/server.toml"));
}
--- a/server.toml
+++ b/server.toml
@@ -2,4 +2,5 @@
 host = "0.0.0.0"
-port = 8080
+port = 8443
 workers = 4
-log = "info"
+log = "debug"
+tls = true

Lower-level functions work on any data: diff_slices diffs any Hash + Eq items, diff_lines diffs &str lines, diff_words and diff_chars diff within a line, and group_hunks groups the resulting Ops into Hunks.

DiffView

DiffView::new(old, new) renders a diff with line numbers, hunk headers and word-level emphasis on the parts of a line that changed:

fn show_unified(console: &Console) {
    let view = DiffView::new(OLD, NEW).titles("server.toml (old)", "server.toml (new)");
    console.print(&view);
}

A unified diff

Method Effect
layout(Layout::SideBySide) Old on the left, new on the right (default Layout::Unified)
titles(old, new) ---/+++ headers, or column headings side by side
context(n) Unchanged lines around each change (default 3)
line_numbers(false) Hide the line-number gutters
wrap(false) Truncate long lines with … instead of wrapping
emphasis(false) Turn off word-level emphasis
fn show_side_by_side(console: &Console) {
    let view = DiffView::new(OLD, NEW)
        .layout(Layout::SideBySide)
        .titles("before", "after")
        .context(1);
    console.print(&view);
}

A side-by-side diff

is_equal(), stats() (added, removed, restyled) and style_changed_lines() let a caller decide what to do before rendering. DiffView::from_diff(&text_diff) views an existing TextDiff.

ANSI captures

DiffView::ansi(old, new) compares captured terminal output by its visible text. A line whose text is the same but whose styling changed is marked ~, and each side keeps its own colours:

fn show_ansi(console: &Console) {
    // Captured terminal output: the text is the same but "FAILED" lost its
    // colour. `DiffView::ansi` reports that as a style-only change (`~`).
    let before = "test parse ... \x1b[32mok\x1b[0m\ntest render ... \x1b[31mFAILED\x1b[0m\n";
    let after = "test parse ... \x1b[32mok\x1b[0m\ntest render ... FAILED\n";
    let view = DiffView::ansi(before, after);
    assert_eq!(view.style_changed_lines(), [2]);
    console.print(&view);
}

A style-only change marked with a tilde

Render snapshots

With the testing feature, DiffView::snapshots(&old, &new) compares two RenderSnapshots through their ANSI output. A styling regression that leaves the plain text alone still shows:

fn show_snapshots(console: &Console) {
    let target = RenderTarget::new(
        TargetKind::Capture,
        TargetCapabilities {
            width: 30,
            height: 5,
            color_system: Some(ColorSystem::Truecolor),
            interactive: false,
            unicode: true,
            hyperlinks: false,
            sixel: Support::Unsupported,
        },
        Theme::default_theme(),
    );
    let old = RenderSnapshot::capture(&target, &Text::styled("Build passed", "bold green"));
    let new = RenderSnapshot::capture(&target, &Text::styled("Build passed", "green"));
    // Same plain text, different styling: only a snapshot diff sees it.
    assert_eq!(old.plain, new.plain);
    console.print(&DiffView::snapshots(&old, &new));
}

A snapshot diff

RenderSnapshot::diff(&other) returns the same comparison as unified text.

RenderSnapshot::capture writes schema 1, which stores the segments as rendered, so output that was only split differently shows as a change. RenderSnapshot::capture_frame writes schema 2: rows of runs, with neighbouring segments that look the same merged and control segments dropped, and ansi in the merged encoding. When either side of diff is schema 2, both are compared by size, plain text and rows, so a schema 1 fixture you already have still compares against a new schema 2 capture. upgrade() converts a schema 1 snapshot. Schema 1 JSON is unchanged: it has no rows key.

Source diffs

SourceDiff highlights each side as a whole file with core's Syntax, then splits it into lines, so multi-line strings and comments keep their colours. Changed tokens are emphasised on top of the highlighting. The language comes from language("rust") or the extension of path(…).

fn show_source(console: &Console) {
    let old = "fn area(w: u32, h: u32) -> u32 {\n    w * h\n}\n";
    let new = "fn area(w: u64, h: u64) -> u64 {\n    w.saturating_mul(h)\n}\n";
    let diff = SourceDiff::new(old, new)
        .path("src/geometry.rs") // picks the Rust highlighter
        .link_template("vscode://file/{path}:{line}");
    console.print(&diff);
}

A highlighted Rust diff

link_template("vscode://file/{path}:{line}") links each line number to an editor ({column} is always 1). hyperlinker(Hyperlinker) uses file:// links instead. paths(old, new) names a renamed file, and titles(false) hides the headers. layout, line_numbers, wrap and context work as on DiffView, and view() returns the underlying DiffView.

Git patches

git::parse_unified reads git diff output and plain diff -u output. It handles new, deleted, renamed and copied files, mode changes, binary files and \ No newline at end of file. The result is a Patch of FilePatches, each with its status, paths, modes, similarity, hunks and addition/deletion counts.

PatchView renders a patch the way a code review shows it:

  • a file tree with per-file counts;
  • each file's header and syntax-highlighted hunks;
  • Annotations (a level and a message) under their lines;
  • a summary line at the end.
fn show_patch(console: &Console) {
    let patch = parse_unified(PATCH).expect("valid patch");
    assert_eq!(patch.stats(), (4, 1)); // (additions, deletions) across files
    let view = PatchView::new(patch)
        .annotate(Annotation::new(
            "src/lib.rs",
            3,
            Level::Warning,
            "unused variable: `unused`",
        ))
        .links(
            TemplateLinks::new("https://github.com/{owner}/{repo}/blob/{rev}/{path}#L{line}")
                .file_template("https://github.com/{owner}/{repo}/blob/{rev}/{path}")
                .var("owner", "octo")
                .var("repo", "totals")
                .var("rev", "a2c4f0d"),
        );
    console.print(&view);
}

A patch with a file tree and an inline warning

links(provider) links paths and new-side line numbers. TemplateLinks fills {path}, {line} and any {name} you set with var, which is enough for GitHub, GitLab or Gitea URLs. A Hyperlinker also works as a provider (local file:// or editor links). Implement LinkProvider for anything else. tree(false), highlight(false) and emphasis(false) turn parts off.

On the command line, git diff | rich diff - renders a patch this way, and rich diff OLD NEW compares any two text files.

Merge conflicts

ConflictFile::parse reads the markers a merge leaves in a file: <<<<<<<, the diff3 base after ||||||| (with merge.conflictStyle = diff3 or zdiff3), ======= and >>>>>>>, each with its label. A Conflict has the ours, optional base and theirs sides as line ranges, the marker lines and a 1-based number; file.text(&side) joins a side's lines.

const MERGED: &str = "\
fn timeout() -> u64 {
<<<<<<< HEAD
    30
||||||| merged common ancestors
    10
=======
    env_or(\"TIMEOUT\", 10)
>>>>>>> feature/env
}
";

fn show_conflicts(console: &Console) {
    let file = ConflictFile::parse(MERGED).expect("well-formed markers");
    let conflict = &file.conflicts()[0];
    assert_eq!(file.text(&conflict.ours), "    30");
    assert_eq!(conflict.theirs.label.as_deref(), Some("feature/env"));
    // Side by side when every column fits, stacked otherwise.
    console.print(&ConflictView::new(file).path("src/config.rs"));
}

A diff3 conflict, ours, base and theirs side by side

ConflictView numbers each conflict, shows a few lines of context around it (context(n), default 3) and highlights each side with core's Syntax (by language(…) or the extension of path(…); plain without the syntax feature). The file is highlighted as three whole versions, so a string or comment that spans a marker colours as it would in each (a file over 1 MiB is shown plain). layout picks ConflictLayout::SideBySide, Stacked, or Auto (the default: columns when each gets 20 cells of text). base(false) hides the base, and line_numbers and wrap work as on DiffView. The line numbers are the file's, and with colour off every side line keeps its marker: < ours, | base, > theirs. The diff.conflict.ours, diff.conflict.base, diff.conflict.theirs and diff.conflict.label theme keys style it.

Parsing follows git's rule for a marker: seven characters at the start of a line, then whitespace or the end of the line. A conflict's markers all share the opening marker's length, so a longer marker inside one is text, which is how git writes a conflict nested inside another. A path with a larger conflict-marker-size attribute gets longer markers throughout: a longer opening marker counts when a separator and a closing marker of its length follow it. Outside a conflict only <<<<<<< counts, so a Markdown heading underlined with ======= stays text. Markers out of order, a second <<<<<<< and a conflict that is never closed return a ConflictError naming the line. Input is capped at MAX_CONFLICT_SOURCE (16 MiB) and MAX_CONFLICTS (10,000).

On the command line, rich diff --conflicts FILE shows a file's conflicts this way, with a summary line.

Test reports

With the test-report feature, junit::parse reads JUnit XML (Maven Surefire, pytest, jest-junit and nested suites). libtest::parse reads the JSON stream from libtest (cargo +nightly test -- -Z unstable-options --format json). Both return the same TestRun of Suites and Cases.

TestReport renders a run:

  • failures first, each with its message and captured output;
  • a diff of expected against actual, when the message contains both. libtest's left/right, JUnit 4's expected:<a> but was:<b> and Jest's Expected:/Received: are recognised;
  • a per-suite summary table and a totals line.
fn show_test_report(console: &Console) {
    // `cargo +nightly test -- -Z unstable-options --format json > results.json 2>&1`
    let run = libtest::parse(LIBTEST).expect("libtest JSON");
    assert!(!run.is_success());
    console.print(&TestReport::new(run).show_passed(true));

    // JUnit XML from Surefire, pytest, jest-junit, …
    let run = junit::parse(JUNIT).expect("JUnit XML");
    let totals = run.totals();
    assert_eq!((totals.passed, totals.failed), (1, 1));
    console.print(&TestReport::new(run));
}

A libtest run and a JUnit run

Method Effect
show_passed(true) List passing and skipped cases after the failures
show_output(false) Hide captured output
diff_context(n) Context lines in expected/actual diffs (default 3)

TestRun::totals(), time() and is_success() summarise a run, and to_junit_xml() writes normalized JUnit for CI systems that read it. Implement TestAdapter to add another format. Include cargo's stderr in the libtest stream (2>&1) so its Running unittests src/lib.rs (…) lines name the suites; without them suites are called suite 1, suite 2, …

Assertions

With the testing feature, four macros compare values and, when they differ, panic with a rendered diff instead of two long debug strings:

Macro Compares
assert_rich_eq!(left, right) Two strings (anything AsRef<str>)
assert_rich_json_eq!(left, right) Two Serialize values as pretty JSON
assert_render_eq!(renderable, expected, width = 40) A renderable's plain output (default width 80; trailing spaces ignored)
assert_snapshot_eq!(left, right) Two RenderSnapshots, styles included

Each accepts a trailing format message, as assert_eq! does. This is a real failure, caught and printed:

#[derive(serde::Serialize)]
struct Config {
    name: &'static str,
    ports: Vec<u16>,
}

fn assertion_message() -> String {
    let failure = std::panic::catch_unwind(|| {
        // In a test you would just write the assertion.
        rich_ext::assert_rich_json_eq!(
            Config {
                name: "api",
                ports: vec![80, 443]
            },
            Config {
                name: "api",
                ports: vec![80, 8443]
            },
            "config for {}",
            "production"
        );
    })
    .unwrap_err();
    failure
        .downcast_ref::<String>()
        .cloned()
        .unwrap_or_default()
}

The panic message of a failed assert_rich_json_eq!

The diff layout and colour come from the environment:

Variable Effect
RICH_ASSERT_LAYOUT=side-by-side Side-by-side diffs (default unified)
RICH_ASSERT_COLOR=1 / 0 Force colour on or off. By default it is on when stdout is a terminal and CI is unset.

Without colour the message is plain ASCII, which keeps CI logs readable.

Gotchas

  • Snapshots and captures without a final newline show \ No newline at end of file on both sides, as diff does. This is expected, not a change.
  • stats() order differs. TextDiff::stats returns (added, removed), Patch::stats returns (additions, deletions), and DiffView::stats also counts restyled lines.
  • libtest JSON needs nightly (-Z unstable-options). On stable, use a JUnit reporter such as cargo nextest's.
  • A truncated JUnit file is an error. When the XML ends with elements still open (a runner killed mid-write), junit::parse fails instead of returning the cases it saw, so a cut-off report never reads as a success.
  • Decoded controls are shown, not obeyed. Git's quoted paths ("b/\033[2J"), JUnit's &#x1b; and JSON's \u001b decode to real control characters. The parsed Patch and TestRun keep them, while PatchView and TestReport show paths, names, messages and output with terminal and bidi controls made visible (␛[2J). Line content in a PatchView is shown as given: sanitize untrusted patch text (for example with rich_ext::sanitize_terminal_controls) before parsing it.
  • Hunk headers must be possible. @@ -0,1 … (a non-empty range at line 0) and ranges whose end overflows are parse errors.

See also