Diffs and test reports¶
rich_ext::diff compares text and shows the result in the terminal:
TextDiff: a line diff whose unified output matchesdiff -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 ofgit diffoutput, with annotations and links;TestReport(featuretest-report): JUnit XML and libtest JSON results, failures first;assert_rich_eq!and friends (featuretesting): 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:
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);
}
| 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);
}
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);
}
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));
}
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);
}
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);
}
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"));
}
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'sexpected:<a> but was:<b>and Jest'sExpected:/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));
}
| 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 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 fileon both sides, asdiffdoes. This is expected, not a change. stats()order differs.TextDiff::statsreturns(added, removed),Patch::statsreturns(additions, deletions), andDiffView::statsalso counts restyled lines.- libtest JSON needs nightly (
-Z unstable-options). On stable, use a JUnit reporter such ascargo nextest's. - A truncated JUnit file is an error. When the XML ends with elements
still open (a runner killed mid-write),
junit::parsefails 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'sand JSON's\u001bdecode to real control characters. The parsedPatchandTestRunkeep them, whilePatchViewandTestReportshow paths, names, messages and output with terminal and bidi controls made visible (␛[2J). Line content in aPatchViewis shown as given: sanitize untrusted patch text (for example withrich_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¶
rich_ext::diffon docs.rsDiffView,git::PatchView,test_report::TestReport- Quality assurance: screenshot approvals use these diffs
- Structured data: leaf-level diffs of documents
- Using the CLI:
rich diff