diff --git a/CLAUDE.md b/CLAUDE.md index 5a163e8..8b96e2f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,9 @@ Vajra is a deterministic semantic reduction and anomaly analysis engine for JSON ## The Rules -1. **Every algorithm must work at any scale.** 1KB, 1GB, 100GB. If it doesn't stream, it doesn't ship. +1. **Every algorithm must work at any scale.** 1KB, 1GB, 100GB. Design accumulators to bounded memory: sketches over exact collections, one pass where one pass suffices, a documented accuracy bound where it does not. + + This is the target, and the codebase does not meet it yet — there is no incremental parser, so every analysis materialises the corpus first (#102). Stating the rule as "if it doesn't stream, it doesn't ship" made it unfalsifiable: two commands were flagged for violating it while nothing in the tool actually streamed. A new analysis should say what its memory scales with, and `separation`'s docs are the example to follow. 2. **Determinism is sacred.** Same input + same config = same output. Always. Use `BTreeMap` for any externally-visible ordering. Seed all randomized algorithms. Fixed traversal order everywhere. 3. **Strong primitives only.** No speculative ML, no O(n^2) algorithms, no techniques requiring tuning. Published, peer-reviewed, deployed at scale — or it doesn't belong. 4. **Honest inference.** Every heuristic is labeled. Every score is decomposable. Never silently assert a guess as truth. @@ -216,7 +218,7 @@ Agent(isolation: worktree) → vajra-anomaly - No `unsafe` blocks. - No `HashMap` where output order matters. Use `BTreeMap`. - No `f64` equality. Use relative tolerance. -- No `println!` for output. All user-facing output through the rendering system. +- No `println!` for building output. Text and Markdown are built as a `render::Report` and written once; `println!` appears at most once per format branch, to emit the finished string. Building output line by line duplicates format handling per command, which is how `--format markdown` silently fell through to text in several of them. - No `#[allow(clippy::...)]` without a comment explaining why the lint is wrong here. - No dependencies without checking: maintained? reasonable dep tree? `no_std` compatible where needed? - No `unwrap()`, `expect()`, or `panic!()`. Ever. The clippy deny lints enforce this. diff --git a/docs/src/streaming.md b/docs/src/streaming.md index 05d7cb6..0769310 100644 --- a/docs/src/streaming.md +++ b/docs/src/streaming.md @@ -1,6 +1,25 @@ # Streaming -Vajra handles JSON of any size. A 50 KB medical claim and a 10 GB event log enter the same pipeline. The streaming engine is what makes this possible. +> **Status: this page describes the intended design, not the current implementation.** +> +> There is no SAX parser yet. `load_adaptive`'s large-input branch reads the +> whole file, parses a full DOM, and then materialises a `Vec` +> alongside it — so `--streaming` currently uses *more* memory than the default +> path, not less. Measured on a 15 MB, 120,000-record input: +> +> | mode | peak RSS | +> |---|---| +> | default (DOM) | 233 MB | +> | `--streaming` | 402 MB | +> +> The flag selects the sketch-based accumulators, which are real and tested; +> what is missing is an incremental parser to feed them. Passing `--streaming` +> prints a warning saying so. Tracked in +> [#102](https://github.com/copyleftdev/vajra/issues/102). The memory budget +> below is the target the accumulators were designed against, and holds once a +> record iterator exists that never materialises the corpus. + +Vajra aims to handle JSON of any size: a 50 KB medical claim and a 10 GB event log entering the same pipeline. The streaming engine is what would make this possible. --- @@ -16,7 +35,7 @@ For documents that fit in memory. The parser builds a full in-memory tree with r ### Streaming Mode -For documents that exceed available memory. SAX-style event parsing with bounded memory. The parser emits events (start-object, key, value, end-object, start-array, end-array) and the analyzers update their accumulators incrementally. +*Intended:* for documents that exceed available memory, SAX-style event parsing with bounded memory. *Today:* the events are produced by walking a fully parsed DOM, so this mode costs more memory than the DOM mode it is meant to replace (#102). The parser emits events (start-object, key, value, end-object, start-array, end-array) and the analyzers update their accumulators incrementally. **Memory:** O(p + s) where p = distinct paths and s = sum of sketch sizes. For typical JSON with < 1,000 distinct paths: < 10 MB regardless of document size. **Activates:** Automatically when document size exceeds the streaming threshold. Force with `--streaming`. @@ -119,7 +138,7 @@ Fingerprint: ~10 KB Total: ~5.2 MB ``` -This budget holds regardless of whether the document is 100 MB or 100 GB. The streaming guarantee: **bounded memory independent of input size**. +This budget is what the accumulators were designed to hold to, regardless of whether the document is 100 MB or 100 GB. It is not yet achieved: the accumulators are fed from a fully materialised event vector, so today's memory scales with input size. See the status note at the top of this page and [#102](https://github.com/copyleftdev/vajra/issues/102). --- diff --git a/vajra-cli/src/main.rs b/vajra-cli/src/main.rs index 33ca829..94da425 100644 --- a/vajra-cli/src/main.rs +++ b/vajra-cli/src/main.rs @@ -75,7 +75,7 @@ struct Cli { #[arg(long, global = true)] budget: Option, - /// Use streaming analysis pipeline (bounded memory, sketch-based stats) + /// Use the sketch-based accumulators (NOT yet bounded memory — see #102) #[arg(long, global = true)] streaming: bool, @@ -398,6 +398,7 @@ const RENDERS_MARKDOWN: &[&str] = &[ "governance", "inspect", "invariants", + "profiles", "score", "separation", "stats", @@ -550,6 +551,23 @@ fn resolve_field_labelled( resolved.selector } +/// Say that `--streaming` does not yet bound memory. +/// +/// The flag selects the sketch-based accumulators, but reaching them still +/// parses the whole document and then materialises a `Vec` beside +/// it: measured at 402 MB against the DOM path's 233 MB on a 15 MB input. A +/// user passing it to survive a large file gets the opposite of what the name +/// promises, so it says so rather than accepting the flag quietly. See #102. +fn warn_streaming_not_bounded(cli: &Cli) { + if cli.quiet || !cli.streaming { + return; + } + eprintln!( + "vajra: --streaming selects the sketch-based accumulators but does not yet bound \ + memory — it currently uses more than the default path (see issue #102)" + ); +} + fn warn_unimplemented_format(cli: &Cli) { if cli.quiet { return; @@ -599,6 +617,7 @@ fn main() { let cli = Cli::parse(); warn_unimplemented_format(&cli); + warn_streaming_not_bounded(&cli); let result = match &cli.command { Command::Inspect { input } => cmd_inspect(input, &cli), @@ -1217,18 +1236,28 @@ fn cmd_profiles(cli: &Cli) -> Result<()> { match cli.format { Format::Text | Format::Markdown | Format::CompactAi => { - println!("=== Built-in Profiles ==="); + let mut out = render::Report::new(); + out.heading("Built-in Profiles"); + let mut builtin = render::Table::new(&["PROFILE", "DESCRIPTION"], "(none)"); for (name, desc) in &builtin_profiles { - println!(" {:<12} {}", name, desc); + builtin.push(vec![(*name).to_owned(), (*desc).to_owned()]); } + out.table(builtin); if !custom_profiles.is_empty() { - println!(); - println!("=== Custom Profiles ==="); + out.heading("Custom Profiles"); + let mut custom = render::Table::new(&["PROFILE", "DESCRIPTION"], "(none)"); for p in &custom_profiles { - println!(" {:<12} {}", p.name(), p.description()); + custom.push(vec![p.name().to_owned(), p.description().to_owned()]); } + out.table(custom); } + + let text = match cli.format { + Format::Markdown => out.to_markdown(), + _ => out.to_text(), + }; + print!("{}", maybe_redact(&text, cli)); } Format::Json => { let mut profiles_json: Vec = builtin_profiles @@ -2110,6 +2139,96 @@ fn cmd_anomalies(input: &str, cli: &Cli) -> Result<()> { Ok(()) } +/// Render a corpus shape index. +fn corpus_index_report(index: &corpus::CorpusIndex) -> render::Report { + let mut report = render::Report::new(); + report.heading("Corpus Shape Index"); + report.fields(vec![ + ("Files scanned".to_owned(), index.files_scanned.to_string()), + ( + "Documents indexed".to_owned(), + index.documents_indexed.to_string(), + ), + ("Skipped (format)".to_owned(), index.skipped.to_string()), + ("Suppressed (size)".to_owned(), index.suppressed.to_string()), + ( + "Groups indexed".to_owned(), + index.groups_indexed.to_string(), + ), + ( + "Distinct shapes".to_owned(), + index.distinct_shapes.to_string(), + ), + ( + "Reused across documents".to_owned(), + index.shapes_in_multiple_documents.to_string(), + ), + ( + "Reused across groups".to_owned(), + index.shapes_in_multiple_groups.to_string(), + ), + ]); + + report.heading("Reuse Groups"); + if index.reuse_groups.is_empty() { + report.note("no shape occurs in more than one document"); + } else { + report.nested( + index + .reuse_groups + .iter() + .map(|g| { + ( + format!( + "{} x{} nodes={}", + &g.shape[..16.min(g.shape.len())], + g.count, + g.node_count + ), + g.members.clone(), + ) + }) + .collect(), + ); + } + + report.heading("Clusters (linked transitively)"); + if index.clusters.is_empty() { + report.note("none"); + } else { + report.nested( + index + .clusters + .iter() + .map(|c| { + ( + format!( + "{} group(s), {} shared shape(s), min nodes {}", + c.size, c.shared_shapes, c.min_node_count + ), + c.members.clone(), + ) + }) + .collect(), + ); + report.note( + "A cluster resting on one small shape is weak evidence — check min nodes, \ + and see --min-nodes.", + ); + } + + if !index.errors.is_empty() { + report.heading(format!("Errors ({})", index.errors.len())); + let mut table = render::Table::new(&["FILE", "ERROR"], "(none)"); + for e in &index.errors { + table.push(vec![e.file.clone(), e.error.clone()]); + } + report.table(table); + } + + report +} + // --------------------------------------------------------------------------- // fingerprint // --------------------------------------------------------------------------- @@ -2235,61 +2354,12 @@ fn cmd_fingerprint_corpus( println!("{json}"); } Format::Text | Format::Markdown | Format::CompactAi => { - println!("=== Corpus Shape Index ==="); - println!(" Files scanned: {}", index.files_scanned); - println!(" Documents indexed: {}", index.documents_indexed); - println!(" Skipped (format): {}", index.skipped); - println!(" Suppressed (size): {}", index.suppressed); - println!(" Distinct shapes: {}", index.distinct_shapes); - println!( - " Reused shapes: {}", - index.shapes_in_multiple_documents - ); - println!(); - - println!("=== Reuse Groups ==="); - if index.reuse_groups.is_empty() { - println!(" (no shape occurs in more than one document)"); - } else { - for g in &index.reuse_groups { - println!( - " {} x{} nodes={}", - &g.shape[..16.min(g.shape.len())], - g.count, - g.node_count - ); - for m in &g.members { - println!(" {m}"); - } - } - } - println!(); - - println!("=== Clusters (linked transitively) ==="); - if index.clusters.is_empty() { - println!(" (none)"); - } else { - for c in &index.clusters { - println!( - " {} group(s), {} shared shape(s), min nodes {}", - c.size, c.shared_shapes, c.min_node_count - ); - for m in &c.members { - println!(" {m}"); - } - } - println!(); - println!(" A cluster resting on one small shape is weak evidence — check"); - println!(" min nodes, and see --min-nodes."); - } - - if !index.errors.is_empty() { - println!(); - println!("=== Errors ({}) ===", index.errors.len()); - for e in &index.errors { - println!(" {}: {}", e.file, e.error); - } - } + let report = corpus_index_report(&index); + let text = match cli.format { + Format::Markdown => report.to_markdown(), + _ => report.to_text(), + }; + print!("{}", maybe_redact(&text, cli)); } } @@ -2687,46 +2757,59 @@ fn cmd_tree_diff(baseline: &str, candidate: &str, cli: &Cli) -> Result<()> { println!("{json}"); } Format::Text | Format::Markdown | Format::CompactAi => { - println!("=== Structural Tree Diff ==="); - println!(" Baseline files: {}", diff.baseline_files); - println!(" Candidate files: {}", diff.candidate_files); + let mut out = render::Report::new(); + out.heading("Structural Tree Diff"); + let mut summary = vec![ + ("Baseline files".to_owned(), diff.baseline_files.to_string()), + ( + "Candidate files".to_owned(), + diff.candidate_files.to_string(), + ), + ]; for kind in ["added", "removed", "changed", "unchanged"] { - println!( - " {:<10} {}", - format!("{kind}:"), - diff.summary.get(kind).copied().unwrap_or(0) - ); + summary.push(( + kind.to_owned(), + diff.summary.get(kind).copied().unwrap_or(0).to_string(), + )); } - println!(" Net node delta: {:+}", diff.total_node_delta); - println!(); - - if diff.files.is_empty() { - println!(" (no structural differences)"); - } else { - println!(" {:<10} {:>10} PATH", "CHANGE", "NODES"); - for f in &diff.files { - let delta = f - .node_delta - .map_or_else(|| " --".to_owned(), |d| format!("{d:>+10}")); - println!( - " {:<10} {} {}", - format!("{:?}", f.change).to_lowercase(), - delta, - f.path - ); - } - println!(); - println!(" Comparison is by structural shape, so reformatting and renaming do"); - println!(" not register. A file whose shape changed grew or lost structure."); + summary.push(( + "Net node delta".to_owned(), + format!("{:+}", diff.total_node_delta), + )); + out.fields(summary); + + let mut table = + render::Table::new(&["CHANGE", "NODES", "PATH"], "(no structural differences)"); + for f in &diff.files { + table.push(vec![ + format!("{:?}", f.change).to_lowercase(), + f.node_delta + .map_or_else(|| "--".to_owned(), |d| format!("{d:+}")), + f.path.clone(), + ]); + } + out.table(table); + if !diff.files.is_empty() { + out.note( + "Comparison is by structural shape, so reformatting and renaming do not \ + register. A file whose shape changed grew or lost structure.", + ); } if !diff.errors.is_empty() { - println!(); - println!("=== Errors ({}) ===", diff.errors.len()); + out.heading(format!("Errors ({})", diff.errors.len())); + let mut errors = render::Table::new(&["PATH", "ERROR"], "(none)"); for e in &diff.errors { - println!(" {}: {}", e.path, e.error); + errors.push(vec![e.path.clone(), e.error.clone()]); } + out.table(errors); } + + let text = match cli.format { + Format::Markdown => out.to_markdown(), + _ => out.to_text(), + }; + print!("{}", maybe_redact(&text, cli)); } } @@ -2822,54 +2905,93 @@ fn cmd_population_drift(input: &str, group_by: &str, cli: &Cli) -> Result<()> { println!("{json_str}"); } Format::Text | Format::Markdown | Format::CompactAi => { - println!("Population Drift Report"); - println!("Group-by: {group_by}"); - println!("Groups: {group_count} ({pair_count} pairwise comparisons)"); - println!(); - println!("Group sizes:"); + let mut out = render::Report::new(); + out.heading("Population Drift Report"); + out.fields(vec![ + ("Group-by".to_owned(), group_by.to_owned()), + ( + "Groups".to_owned(), + format!("{group_count} ({pair_count} pairwise comparisons)"), + ), + ]); + let mut sizes = render::Table::new(&["GROUP", "RECORDS"], "(none)"); for (name, size) in &group_sizes { - println!(" {name}: {size} records"); + sizes.push(vec![name.clone(), size.to_string()]); } - println!(); + out.table(sizes); + for (name_a, name_b, report) in &pairwise_drift { - println!("--- {name_a} vs {name_b} ---"); - println!( - " Structural similarity: {:.4} (Jaccard)", - report.structural_similarity - ); - println!(" Severity: {:?}", report.severity); + out.heading(format!("{name_a} vs {name_b}")); + out.fields(vec![ + ( + "Structural similarity".to_owned(), + format!("{:.4} (Jaccard)", report.structural_similarity), + ), + ("Severity".to_owned(), format!("{:?}", report.severity)), + ]); + let mut changes = Vec::new(); if !report.path_diff.added.is_empty() { - println!(" Added paths ({}):", report.path_diff.added.len()); - for p in &report.path_diff.added { - println!(" + {p}"); - } + changes.push(( + format!("Added paths ({})", report.path_diff.added.len()), + report + .path_diff + .added + .iter() + .map(|p| format!("+ {p}")) + .collect(), + )); } if !report.path_diff.removed.is_empty() { - println!(" Removed paths ({}):", report.path_diff.removed.len()); - for p in &report.path_diff.removed { - println!(" - {p}"); - } + changes.push(( + format!("Removed paths ({})", report.path_diff.removed.len()), + report + .path_diff + .removed + .iter() + .map(|p| format!("- {p}")) + .collect(), + )); } if !report.type_changes.is_empty() { - println!(" Type changes ({}):", report.type_changes.len()); - for tc in &report.type_changes { - println!(" {} : {} -> {}", tc.path, tc.from, tc.to); - } + changes.push(( + format!("Type changes ({})", report.type_changes.len()), + report + .type_changes + .iter() + .map(|tc| format!("{} : {} -> {}", tc.path, tc.from, tc.to)) + .collect(), + )); } if !report.distributional_drifts.is_empty() { - println!( - " Distribution shifts ({}):", - report.distributional_drifts.len() - ); - for dd in &report.distributional_drifts { - println!( - " {} : {:?} = {:.4} (effect {:.4})", - dd.path, dd.metric, dd.value, dd.effect_size - ); - } + changes.push(( + format!( + "Distribution shifts ({})", + report.distributional_drifts.len() + ), + report + .distributional_drifts + .iter() + .map(|dd| { + format!( + "{} : {:?} = {:.4} (effect {:.4})", + dd.path, dd.metric, dd.value, dd.effect_size + ) + }) + .collect(), + )); + } + if changes.is_empty() { + out.note("no structural or distributional differences"); + } else { + out.nested(changes); } - println!(); } + + let text = match cli.format { + Format::Markdown => out.to_markdown(), + _ => out.to_text(), + }; + print!("{}", maybe_redact(&text, cli)); } } Ok(()) @@ -3869,13 +3991,24 @@ fn cmd_ingest_github( println!("{out}"); } Format::Text | Format::Markdown => { - println!("=== GitHub Ingestion Summary ==="); - println!(" Repository: {repo}"); - println!(" Output dir: {}", result.output_dir.display()); - println!(" Commits: {}", result.commits); - println!(" Pull requests: {}", result.pull_requests); - println!(" Issues: {}", result.issues); - println!(" Releases: {}", result.releases); + let mut out = render::Report::new(); + out.heading("GitHub Ingestion Summary"); + out.fields(vec![ + ("Repository".to_owned(), repo.to_owned()), + ( + "Output dir".to_owned(), + result.output_dir.display().to_string(), + ), + ("Commits".to_owned(), result.commits.to_string()), + ("Pull requests".to_owned(), result.pull_requests.to_string()), + ("Issues".to_owned(), result.issues.to_string()), + ("Releases".to_owned(), result.releases.to_string()), + ]); + let text = match cli.format { + Format::Markdown => out.to_markdown(), + _ => out.to_text(), + }; + print!("{}", maybe_redact(&text, cli)); } } diff --git a/vajra-cli/tests/format_honesty.rs b/vajra-cli/tests/format_honesty.rs index 7d13197..9c040f8 100644 --- a/vajra-cli/tests/format_honesty.rs +++ b/vajra-cli/tests/format_honesty.rs @@ -390,3 +390,127 @@ fn redact_applies_to_text_output() -> Result<()> { ); Ok(()) } + +/// Sub-modes are a separate rendering path and drifted unnoticed. +/// +/// `all_migrated_commands_emit_real_markdown` runs each command against a +/// single JSON file, so it never reached `fingerprint --corpus`, +/// `drift --group-by`, `drift --tree` or `profiles`. All four fell through to +/// the text branch while `fingerprint` and `drift` were listed as rendering +/// Markdown — the claim held for the path under test and not for the others. +#[test] +fn command_sub_modes_emit_real_markdown() -> Result<()> { + let dir = tempfile::tempdir()?; + + // A corpus: two structurally distinct JSON documents in a tree. + let corpus = dir.path().join("corpus"); + std::fs::create_dir_all(corpus.join("a"))?; + std::fs::create_dir_all(corpus.join("b"))?; + std::fs::write(corpus.join("a/one.json"), r#"{"x":1,"y":[1,2,3]}"#)?; + std::fs::write(corpus.join("b/two.json"), r#"{"p":{"q":"z"},"r":false}"#)?; + + // A second tree, for --tree. + let other = dir.path().join("other"); + std::fs::create_dir_all(other.join("a"))?; + std::fs::write(other.join("a/one.json"), r#"{"x":1,"y":[1,2,3],"z":9}"#)?; + + // Grouped records, for --group-by. + let grouped = dir.path().join("grouped.json"); + std::fs::write( + &grouped, + r#"[{"team":"red","score":1},{"team":"red","score":2}, + {"team":"blue","score":80},{"team":"blue","extra":true}]"#, + )?; + + let corpus_s = corpus.to_str().ok_or_else(|| anyhow!("bad path"))?; + let other_s = other.to_str().ok_or_else(|| anyhow!("bad path"))?; + let grouped_s = grouped.to_str().ok_or_else(|| anyhow!("bad path"))?; + + let modes: Vec<(&str, Vec<&str>)> = vec![ + ( + "fingerprint --corpus", + vec!["fingerprint", corpus_s, "--corpus"], + ), + ("drift --tree", vec!["drift", corpus_s, other_s, "--tree"]), + ( + "drift --group-by", + vec!["drift", grouped_s, "--group-by", "$.team"], + ), + ("profiles", vec!["profiles"]), + ]; + + for (label, base) in modes { + let mut md_args = base.clone(); + md_args.extend_from_slice(&["--format", "markdown", "--quiet"]); + let mut text_args = base.clone(); + text_args.extend_from_slice(&["--format", "text", "--quiet"]); + + let md = Command::new(vajra_bin()).args(&md_args).output()?; + let text = Command::new(vajra_bin()).args(&text_args).output()?; + assert!( + md.status.success(), + "`{label}` markdown failed: {}", + String::from_utf8_lossy(&md.stderr) + ); + assert!(text.status.success(), "`{label}` text failed"); + + let md_out = String::from_utf8_lossy(&md.stdout).into_owned(); + let text_out = String::from_utf8_lossy(&text.stdout).into_owned(); + + // Checked without --quiet, which would suppress the very warning this + // is asserting is absent. `profiles` emitted `## Built-in Profiles` + // while warning it had no markdown renderer — the inverse false claim, + // and invisible while this ran with --quiet. + let loud = Command::new(vajra_bin()) + .args(&base) + .args(["--format", "markdown"]) + .output()?; + let loud_err = String::from_utf8_lossy(&loud.stderr).into_owned(); + assert!( + !loud_err.contains("no markdown renderer"), + "`{label}` renders markdown but claims it does not: {loud_err}" + ); + assert_ne!( + md_out, text_out, + "`{label}` markdown is byte-identical to text — it fell through" + ); + assert!( + md_out.contains("## "), + "`{label}` should emit Markdown headings:\n{md_out}" + ); + assert!( + !text_out.contains("## "), + "`{label}` text must not contain Markdown headings:\n{text_out}" + ); + } + Ok(()) +} + +/// `--streaming` advertised "bounded memory". It selects the sketch-based +/// accumulators, but reaching them parses the whole document and then +/// materialises a `Vec` beside it — measured at 402 MB against the +/// DOM path's 233 MB on a 15 MB input. A flag that is accepted and does the +/// opposite of what it says is the same defect as `--redact` doing nothing. +/// See #102. +#[test] +fn streaming_says_it_is_not_yet_bounded() -> Result<()> { + let dir = tempfile::tempdir()?; + let f = dir.path().join("d.json"); + std::fs::write(&f, FIXTURE)?; + + let (_, stderr) = run(&f, &["stats", "--streaming", "--format", "json"])?; + assert!( + stderr.contains("does not yet bound memory"), + "--streaming must not claim bounded memory silently: {stderr:?}" + ); + + let (_, quiet) = run(&f, &["stats", "--streaming", "--format", "json", "--quiet"])?; + assert!(quiet.is_empty(), "--quiet must silence it: {quiet:?}"); + + let (_, without) = run(&f, &["stats", "--format", "json"])?; + assert!( + !without.contains("bound memory"), + "the warning must not fire without the flag: {without:?}" + ); + Ok(()) +} diff --git a/vajra-cli/tests/population_drift.rs b/vajra-cli/tests/population_drift.rs index 5b8f5ba..8955ccc 100644 --- a/vajra-cli/tests/population_drift.rs +++ b/vajra-cli/tests/population_drift.rs @@ -171,9 +171,15 @@ fn text_output_works() -> Result<(), Box> { String::from_utf8_lossy(&output.stderr) ); let text = String::from_utf8_lossy(&output.stdout); + // Field labels are padded to a common width by the renderer, so assert on + // the content rather than on a particular run of spaces. + let squeezed = text.split_whitespace().collect::>().join(" "); assert!(text.contains("Population Drift Report"), "missing header"); assert!(text.contains("fastapi vs gin"), "missing pair"); - assert!(text.contains("Group-by: repo"), "missing group-by"); + assert!( + squeezed.contains("Group-by: repo"), + "missing group-by:\n{text}" + ); Ok(()) }