Skip to main content

big_code_analysis/output/
dump_metrics.rs

1//! Terminal per-metric dump serializer.
2//!
3//! The dump tree is driven by [`wire::CodeMetrics`] — the serialized
4//! metric shape — rather than hand-picking a per-metric subset of stats
5//! (issue #674). Projecting the compute metrics through the wire form and
6//! walking the resulting JSON object guarantees the dump's field set is
7//! *uniform by construction*: every leaf the JSON output carries appears
8//! here under the same key, and a new metric field shows up automatically
9//! with no edit to this file.
10//!
11//! Field *order* in this text view is serde_json's default sorted-key order
12//! (`Value::Object` is a `BTreeMap` because `preserve_order` is deliberately
13//! not enabled — see the root `Cargo.toml`), which is deterministic and
14//! differs from the JSON serializer's struct-field order. The uniform field
15//! *set* is what #674 requires; matching JSON's order would mean enabling
16//! `preserve_order` workspace-wide, which would perturb the frozen
17//! code-climate / SARIF fingerprint contracts (#559).
18//!
19//! The other deliberate divergence from JSON is presentation: float values
20//! render rounded to [`TEXT_FLOAT_DECIMALS`] decimals in this text view,
21//! whereas JSON keeps full precision. Non-finite floats (which serialize
22//! to JSON `null`) render as `NaN`, matching the prior dump and the
23//! human-readable `numfmt` arm.
24
25use termcolor::{Color, WriteColor};
26
27use serde_json::Value;
28
29use crate::output::color::print_to_stdout;
30use crate::output::numfmt::F64_SAFE_INT_BOUND;
31use crate::output::{ColorMode, branch_glyphs};
32use crate::spaces::{CodeMetrics, FuncSpace};
33use crate::wire;
34
35use crate::output::color::{color, intense_color};
36
37/// Decimal places used when rendering a non-integer float in the text
38/// dump. JSON output keeps full precision; the terminal view trades the
39/// trailing noise for legibility (issue #674).
40const TEXT_FLOAT_DECIMALS: usize = 2;
41
42/// Dumps the metrics of a code.
43///
44/// Returns a [`Result`] value, when an error occurs.
45///
46/// # Errors
47///
48/// Propagates any [`std::io::Error`] produced by the color-aware
49/// writer that backs `stdout` (broken pipe, write failure, …).
50///
51/// # Examples
52///
53/// ```
54/// use big_code_analysis::{analyze, dump_root, LANG, MetricsOptions, Source};
55///
56/// // Compute metrics via the non-generic `analyze` entry point.
57/// let space = analyze(
58///     Source::new(LANG::Cpp, b"int a = 42;"),
59///     MetricsOptions::default(),
60/// )
61/// .expect("snippet has a top-level FuncSpace");
62///
63/// // Dump all metrics
64/// dump_root(&space).unwrap();
65/// ```
66pub fn dump_root(space: &FuncSpace) -> std::io::Result<()> {
67    dump_root_with_color(space, ColorMode::Always)
68}
69
70/// Like [`dump_root`], but the caller selects the [`ColorMode`].
71///
72/// `bca` resolves a `--color` flag, the `NO_COLOR` convention, and
73/// stdout tty detection into a mode and passes it here so piped output
74/// is escape-free by default. The bare [`dump_root`] keeps the
75/// historical always-colored behavior for backward compatibility.
76///
77/// # Errors
78///
79/// Propagates any [`std::io::Error`] produced by the color-aware
80/// writer that backs `stdout` (broken pipe, write failure, …).
81pub fn dump_root_with_color(space: &FuncSpace, color_mode: ColorMode) -> std::io::Result<()> {
82    print_to_stdout(color_mode, |stdout| {
83        dump_space(space, stdout)?;
84        color(stdout, Color::White)
85    })
86}
87
88/// One pending space in the walk: the space, the length its indentation
89/// prefix has in the shared buffer, and whether it is its parent's last
90/// child.
91///
92/// The prefix is a *length* rather than an owned copy (#1054): prefixes
93/// only grow as the walk descends, so the first `prefix_len` bytes stay
94/// this space's prefix until it is popped. Owning one prefix per stack
95/// entry cost O(depth²) resident bytes on a deep closure nest.
96type SpaceFrame<'a> = (&'a FuncSpace, usize, bool);
97
98/// Dump the `FuncSpace` metric tree with an explicit work stack rather
99/// than recursion, so a pathologically deep space nesting (closures
100/// within closures) cannot overflow the thread stack at dump time — an
101/// uncatchable abort, forbidden by the no-panic rule (#700). Traversal
102/// order and per-node glyphs are byte-identical to the prior recursive
103/// form.
104fn dump_space(space: &FuncSpace, stdout: &mut dyn WriteColor) -> std::io::Result<()> {
105    let mut prefix = String::new();
106    let mut stack: Vec<SpaceFrame> = vec![(space, 0, true)];
107
108    while let Some((space, prefix_len, last)) = stack.pop() {
109        // Truncating on every visit — rather than on the way back up —
110        // is what lets a frame carry a bare length: whatever a sibling's
111        // subtree appended is dropped here. Recorded lengths always sit
112        // on a char boundary because only whole glyph runs are appended.
113        prefix.truncate(prefix_len);
114        let (pref_child, pref) = branch_glyphs(last);
115
116        color(stdout, Color::Blue)?;
117        write!(stdout, "{prefix}{pref}")?;
118
119        intense_color(stdout, Color::Yellow)?;
120        write!(stdout, "{}: ", space.kind)?;
121
122        intense_color(stdout, Color::Cyan)?;
123        write!(stdout, "{}", space.name.as_ref().map_or("", |name| name))?;
124
125        intense_color(stdout, Color::Red)?;
126        writeln!(stdout, " (@{})", space.start_line)?;
127
128        prefix.push_str(pref_child);
129        let child_prefix_len = prefix.len();
130        dump_metrics(&space.metrics, &mut prefix, space.spaces.is_empty(), stdout)?;
131
132        // Push children in reverse so `pop()` visits them in source
133        // order; the final child carries `last = true` for the closing
134        // `` `- `` glyph, matching the recursive `split_last` form.
135        let count = space.spaces.len();
136        for (i, child) in space.spaces.iter().enumerate().rev() {
137            stack.push((child, child_prefix_len, i + 1 == count));
138        }
139    }
140
141    Ok(())
142}
143
144/// Render a space's `metrics` subtree. `prefix` is the shared
145/// indentation buffer; it is extended in place for the metric groups and
146/// left extended — every caller either truncates back or is the space
147/// walk, which re-truncates on its next visit.
148fn dump_metrics(
149    metrics: &CodeMetrics,
150    prefix: &mut String,
151    last: bool,
152    stdout: &mut dyn WriteColor,
153) -> std::io::Result<()> {
154    let (pref_child, pref) = branch_glyphs(last);
155
156    color(stdout, Color::Blue)?;
157    write!(stdout, "{prefix}{pref}")?;
158
159    intense_color(stdout, Color::Yellow)?;
160    writeln!(stdout, "metrics")?;
161
162    // Project the compute metrics through the wire shape and walk the
163    // serialized object so the dump's field set is the JSON field set
164    // exactly (issue #674). Disabled class-only metrics (`wmc`/`npm`/`npa`
165    // on a non-class language) are already elided by the `From` impl, so
166    // they never appear in the object and need no per-metric guard here.
167    let wire_metrics = wire::CodeMetrics::from(metrics);
168    let Value::Object(groups) = serde_json::to_value(&wire_metrics).unwrap_or(Value::Null) else {
169        return Ok(());
170    };
171
172    prefix.push_str(pref_child);
173    let group_prefix_len = prefix.len();
174    let last_index = groups.len().saturating_sub(1);
175    for (index, (name, value)) in groups.iter().enumerate() {
176        prefix.truncate(group_prefix_len);
177        dump_group(name, value, prefix, index == last_index, stdout)?;
178    }
179    Ok(())
180}
181
182/// Render one metric group (`cognitive`, `loc`, …) as a green-labelled
183/// subtree, then walk its leaves. A nested object leaf (e.g.
184/// `cyclomatic.modified`) recurses as its own subtree, so the rendered
185/// shape always mirrors the JSON nesting.
186///
187/// Extends the shared `prefix` in place for the group's leaves and leaves
188/// it extended; callers truncate back before the next sibling.
189fn dump_group(
190    name: &str,
191    value: &Value,
192    prefix: &mut String,
193    last: bool,
194    stdout: &mut dyn WriteColor,
195) -> std::io::Result<()> {
196    let (pref_child, pref) = branch_glyphs(last);
197
198    color(stdout, Color::Blue)?;
199    write!(stdout, "{prefix}{pref}")?;
200
201    intense_color(stdout, Color::Green)?;
202    writeln!(stdout, "{name}")?;
203
204    prefix.push_str(pref_child);
205    dump_object(value, prefix, stdout)
206}
207
208/// Walk the leaves of a metric object, emitting one `name: value` line
209/// per scalar and recursing into any nested object (rendered as a green
210/// subtree, matching the JSON nesting). A non-object value is ignored
211/// (the wire metric groups are always objects).
212///
213/// Truncating the shared `prefix` back to this object's level before each
214/// field is what keeps a nested group from indenting its siblings.
215fn dump_object(
216    value: &Value,
217    prefix: &mut String,
218    stdout: &mut dyn WriteColor,
219) -> std::io::Result<()> {
220    let Value::Object(fields) = value else {
221        return Ok(());
222    };
223    let field_prefix_len = prefix.len();
224    let last_index = fields.len().saturating_sub(1);
225    for (index, (name, leaf)) in fields.iter().enumerate() {
226        let last = index == last_index;
227        prefix.truncate(field_prefix_len);
228        if leaf.is_object() {
229            dump_group(name, leaf, prefix, last, stdout)?;
230        } else {
231            dump_value(name, leaf, prefix, last, stdout)?;
232        }
233    }
234    Ok(())
235}
236
237/// Emit a single `name: value` leaf. Floats render rounded to
238/// [`TEXT_FLOAT_DECIMALS`] decimals (text view only — JSON keeps full
239/// precision); integers print verbatim; a JSON `null` (a non-finite
240/// metric) renders as `NaN`, matching the prior dump.
241fn dump_value(
242    name: &str,
243    value: &Value,
244    prefix: &str,
245    last: bool,
246    stdout: &mut dyn WriteColor,
247) -> std::io::Result<()> {
248    let pref = if last { "`- " } else { "|- " };
249
250    color(stdout, Color::Blue)?;
251    write!(stdout, "{prefix}{pref}")?;
252
253    intense_color(stdout, Color::Magenta)?;
254    write!(stdout, "{name}: ")?;
255
256    color(stdout, Color::White)?;
257    writeln!(stdout, "{}", format_leaf(value))
258}
259
260/// Format a scalar wire leaf for the text view. Integral numbers print
261/// without a decimal point; non-integral floats round to
262/// [`TEXT_FLOAT_DECIMALS`] places; `null` (a non-finite metric) becomes
263/// `NaN`.
264fn format_leaf(value: &Value) -> String {
265    match value {
266        Value::Null => "NaN".to_owned(),
267        Value::Number(n) => format_number(n),
268        // The wire metric leaves are only numbers or null; render anything
269        // else verbatim rather than panicking on an unexpected shape.
270        other => other.to_string(),
271    }
272}
273
274/// Render a JSON number: an integer prints without a decimal point; a
275/// float rounds to [`TEXT_FLOAT_DECIMALS`] places, after which a trailing
276/// `.00` is dropped so a whole-valued average reads like a count.
277fn format_number(n: &serde_json::Number) -> String {
278    if let Some(int) = n.as_u64() {
279        return int.to_string();
280    }
281    if let Some(int) = n.as_i64() {
282        return int.to_string();
283    }
284    let Some(float) = n.as_f64() else {
285        return n.to_string();
286    };
287    // A safe-integer-valued float (e.g. an exact `2.0` average) prints as
288    // an integer. This *diverges* from the JSON serializer, which keeps
289    // the `.0` (serde_json renders `2.0` as `"2.0"`, not `"2"`); the dump
290    // drops it deliberately for terminal legibility, the same presentation
291    // tradeoff the module header documents for rounded floats (#674).
292    if float.fract() == 0.0 && float.abs() < F64_SAFE_INT_BOUND {
293        #[allow(clippy::cast_possible_truncation)]
294        return (float as i64).to_string();
295    }
296    format!("{float:.TEXT_FLOAT_DECIMALS$}")
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use crate::metric_set::Metric;
303    use crate::{LANG, MetricsOptions, Source, analyze};
304
305    fn render(space: &FuncSpace) -> String {
306        let mut buf = termcolor::NoColor::new(Vec::new());
307        dump_space(space, &mut buf).expect("dump to in-memory buffer");
308        String::from_utf8(buf.into_inner()).expect("utf-8 dump")
309    }
310
311    #[cfg(feature = "cpp")]
312    #[test]
313    fn fields_after_a_nested_metric_object_resume_the_group_rail() {
314        // `cyclomatic.modified` is the one metric group that nests
315        // another object, and `sum` / `value` follow it. Rendering the
316        // nested group extends the shared indentation buffer (#1054), so
317        // those two trailing fields only land back on the `cyclomatic`
318        // rail if `dump_object` truncates before each field, and the
319        // group *after* cyclomatic only lands back on the metrics rail
320        // if the group loop truncates too. Nothing else in the suite
321        // exercises a nested group's siblings.
322        //
323        // The whole block is compared as a line sequence rather than
324        // with per-rail `contains` checks: `sum` and `value` are fields
325        // of several groups, and a deeper rail ends with the shallower
326        // one, so a substring search accepts both the wrong group and
327        // the exact mis-indentation this test exists to catch.
328        let space = analyze(
329            Source::new(LANG::Cpp, b"int a = 42;"),
330            MetricsOptions::default(),
331        )
332        .expect("snippet has a top-level FuncSpace");
333        let out = render(&space);
334
335        // Values are dropped: this pins indentation, not metric numbers.
336        let rails: Vec<&str> = out
337            .lines()
338            .skip_while(|line| *line != "      |- cyclomatic")
339            .take_while(|line| !line.starts_with("      |- halstead"))
340            .map(|line| line.split_once(": ").map_or(line, |(rail, _)| rail))
341            .collect();
342        assert_eq!(
343            rails,
344            vec![
345                "      |- cyclomatic",
346                "      |  |- average",
347                "      |  |- max",
348                "      |  |- min",
349                "      |  |- modified",
350                "      |  |  |- average",
351                "      |  |  |- max",
352                "      |  |  |- min",
353                "      |  |  |- sum",
354                "      |  |  `- value",
355                "      |  |- sum",
356                "      |  `- value",
357            ],
358            "cyclomatic's rails must survive the nested `modified` \
359             object:\n{out}"
360        );
361        assert!(
362            out.lines().any(|line| line == "      |- halstead"),
363            "the group after cyclomatic must be back on the metrics \
364             rail:\n{out}"
365        );
366    }
367
368    #[test]
369    fn sibling_space_after_a_nested_one_resumes_its_own_rail() {
370        // The walk keeps one shared indentation buffer that is extended
371        // on descent and truncated on the next visit (#1054), and the
372        // metric groups under each space extend it further still. `after`
373        // is a top-level sibling that follows `outer`'s deeper subtree
374        // and its whole metrics block, so a truncation bug leaves it
375        // indented under `inner`'s rail. Built by hand — `FuncSpace` has
376        // an iterative `Drop` (#1056), so struct-update syntax cannot
377        // move fields out of it.
378        //
379        // The expected rails are what the pre-#1054 binary emits for the
380        // equivalent parsed tree (`function outer(){function inner(){}}
381        // function after(){}`).
382        use crate::spaces::SpaceKind;
383        let func = |name: &str, line: usize, spaces: Vec<FuncSpace>| FuncSpace {
384            name: Some(name.to_string()),
385            start_line: line,
386            end_line: line,
387            kind: SpaceKind::Function,
388            spaces,
389            metrics: CodeMetrics::default(),
390            suppressed: crate::SuppressionScope::default(),
391        };
392        let space = FuncSpace {
393            name: Some("u".to_string()),
394            start_line: 1,
395            end_line: 4,
396            kind: SpaceKind::Unit,
397            spaces: vec![
398                func("outer", 1, vec![func("inner", 2, vec![])]),
399                func("after", 4, vec![]),
400            ],
401            metrics: CodeMetrics::default(),
402            suppressed: crate::SuppressionScope::default(),
403        };
404
405        let out = render(&space);
406        let rails: Vec<&str> = out
407            .lines()
408            .filter(|line| line.contains("(@") || line.ends_with("- metrics"))
409            .collect();
410        assert_eq!(
411            rails,
412            vec![
413                "`- unit: u (@1)",
414                "   |- metrics",
415                "   |- function: outer (@1)",
416                "   |  |- metrics",
417                "   |  `- function: inner (@2)",
418                "   |     `- metrics",
419                "   `- function: after (@4)",
420                "      `- metrics",
421            ],
422            "space and metric-block rails must survive the shared prefix \
423             buffer's truncate/extend cycle:\n{out}"
424        );
425    }
426
427    #[cfg(feature = "cpp")]
428    #[test]
429    fn selection_mask_omits_unselected_metric_groups() {
430        // `with_only(&[Loc])` must restrict the dump to the loc group:
431        // the wire-driven projection (#674) elides unselected metrics, so
432        // they never appear in the walked JSON object. A pre-#674 dump
433        // printed all groups with default/zero stats, contradicting the
434        // serialized "present => selected" contract (#700).
435        let space = analyze(
436            Source::new(LANG::Cpp, b"int a = 42;"),
437            MetricsOptions::default().with_only(&[Metric::Loc]),
438        )
439        .expect("snippet has a top-level FuncSpace");
440        let out = render(&space);
441        assert!(out.contains("loc\n"), "loc group must be present:\n{out}");
442        for omitted in ["cognitive", "cyclomatic", "halstead", "nom", "abc"] {
443            assert!(
444                !out.contains(&format!("{omitted}\n")),
445                "unselected `{omitted}` group must be omitted:\n{out}"
446            );
447        }
448    }
449
450    #[cfg(feature = "cpp")]
451    #[test]
452    fn last_emitted_metric_group_uses_closing_connector() {
453        // The genuinely-last emitted metric group must carry the closing
454        // `` `- `` glyph rather than a dangling `|-` (#700, already made
455        // dynamic by the wire projection in #674). For a non-class C
456        // dump, the wmc/npm/npa class-only groups are elided, so the last
457        // group line under the root `metrics` subtree must end the
458        // subtree with `` `- ``.
459        let space = analyze(
460            Source::new(LANG::Cpp, b"int a = 42;"),
461            MetricsOptions::default(),
462        )
463        .expect("snippet has a top-level FuncSpace");
464        let out = render(&space);
465
466        // Group lines sit six columns in: three for the root space's own
467        // `` `- `` (it is the only space) and three more for the
468        // `metrics` line's. Filtering at three columns instead matched
469        // only the `metrics` line itself, so this test passed even with
470        // every group rendering `|-`.
471        let group_lines: Vec<&str> = out
472            .lines()
473            .filter(|line| line.starts_with("      |- ") || line.starts_with("      `- "))
474            .collect();
475        assert!(
476            group_lines.len() > 1,
477            "expected several metric groups under the root:\n{out}"
478        );
479        let last_group = group_lines.last().expect("at least one metric group");
480        assert!(
481            last_group.starts_with("      `- "),
482            "the last emitted metric group must use the closing connector, got: {last_group:?}\n{out}"
483        );
484        assert!(
485            group_lines[..group_lines.len() - 1]
486                .iter()
487                .all(|line| line.starts_with("      |- ")),
488            "every group but the last must use the mid-child connector:\n{out}"
489        );
490    }
491
492    #[test]
493    fn deeply_nested_spaces_dump_without_stack_overflow() {
494        // The space walk is iterative (#700): a deep chain of nested
495        // function spaces must dump without overflowing the thread stack.
496        // Run on a small-stack thread so a recursion regression fails
497        // loudly rather than relying on the test-runner stack.
498        use crate::spaces::SpaceKind;
499        const DEPTH: usize = 8_000;
500        let handle = std::thread::Builder::new()
501            .stack_size(512 * 1024)
502            .spawn(|| {
503                let leaf = || FuncSpace {
504                    name: Some("f".to_string()),
505                    start_line: 1,
506                    end_line: 1,
507                    kind: SpaceKind::Function,
508                    spaces: Vec::new(),
509                    metrics: CodeMetrics::default(),
510                    suppressed: crate::SuppressionScope::default(),
511                };
512                let mut root = leaf();
513                let mut cursor = &mut root;
514                for _ in 0..DEPTH {
515                    cursor.spaces.push(leaf());
516                    cursor = cursor.spaces.last_mut().expect("just pushed");
517                }
518                // Discard the bytes rather than buffering them: a
519                // depth-8000 chain renders ~8000 metric blocks, each
520                // line carrying ~3 x depth bytes of indentation, so a
521                // `Vec` sink held ~10 GB and made the unit suite an
522                // out-of-memory hazard. Nothing here asserts on the
523                // text, and every write still runs.
524                let mut sink = termcolor::NoColor::new(std::io::sink());
525                let ok = dump_space(&root, &mut sink).is_ok();
526                // `root` drops here without flattening: `FuncSpace`'s
527                // `Drop` is iterative as of #1056, so teardown costs no
528                // stack depth and cannot mask the dump result.
529                ok
530            })
531            .expect("spawn dump thread");
532        assert!(
533            handle.join().expect("dump thread must not overflow"),
534            "deep space nesting must dump successfully"
535        );
536    }
537
538    /// Value printed after `{field}:` in the FIRST `{block}` metric block of
539    /// the dump — i.e. the root `Unit`'s, which is emitted before any child
540    /// space. `{val}` Display renders whole f64s without a decimal point, so
541    /// callers can compare against `"0"`.
542    #[cfg(feature = "rust")]
543    fn root_block_field(out: &str, block: &str, field: &str) -> String {
544        let body = &out[out
545            .find(&format!("{block}\n"))
546            .expect("metric block present")..];
547        let at = body.find(&format!("{field}: ")).expect("field present") + field.len() + 2;
548        body[at..].lines().next().unwrap_or("").trim().to_owned()
549    }
550
551    /// Regression for the parent-space aggregate bug: `dump_nom` / `dump_nargs`
552    /// must print the SUBTREE-AGGREGATE counts (`functions_sum` / `fn_args_sum`,
553    /// matching the JSON serializer and `Display`), not the space's IMMEDIATE
554    /// counts — which are 0 at any parent whose functions all live in a nested
555    /// module/impl, and would not sum to the aggregate `total`.
556    #[cfg(feature = "rust")]
557    #[test]
558    fn dump_nom_and_nargs_use_subtree_aggregates_at_parent_space() {
559        // The one function (with args) is nested in `mod m`, so the root Unit's
560        // immediate function/arg counts are 0 while the subtree aggregates are
561        // not — the exact shape that exposed the bug.
562        let space = analyze(
563            Source::new(
564                LANG::Rust,
565                b"mod m { fn a(x: i32, y: i32) -> i32 { x + y } }",
566            ),
567            MetricsOptions::default(),
568        )
569        .expect("snippet has a top-level FuncSpace");
570
571        let mut buf = termcolor::NoColor::new(Vec::new());
572        dump_space(&space, &mut buf).expect("dump to in-memory buffer");
573        let out = String::from_utf8(buf.into_inner()).expect("utf-8 dump");
574
575        assert_ne!(
576            root_block_field(&out, "nom", "functions"),
577            "0",
578            "root nom must print functions_sum (aggregate), not the immediate 0:\n{out}"
579        );
580        assert_ne!(
581            root_block_field(&out, "nargs", "functions"),
582            "0",
583            "root nargs must print fn_args_sum (aggregate), not the immediate 0:\n{out}"
584        );
585    }
586
587    /// Regression for #562: the two Halstead dump labels must use the
588    /// underscore key that matches the JSON/CSV key name, so a user can grep
589    /// the same token across `dump` and JSON. The space-separated forms
590    /// (`estimated program length` / `purity ratio`) were the only outliers.
591    #[cfg(feature = "cpp")]
592    #[test]
593    fn dump_halstead_labels_use_underscore_keys() {
594        let space = analyze(
595            Source::new(LANG::Cpp, b"int a = 42;"),
596            MetricsOptions::default(),
597        )
598        .expect("snippet has a top-level FuncSpace");
599
600        let mut buf = termcolor::NoColor::new(Vec::new());
601        dump_space(&space, &mut buf).expect("dump to in-memory buffer");
602        let out = String::from_utf8(buf.into_inner()).expect("utf-8 dump");
603
604        assert!(
605            out.contains("estimated_program_length: "),
606            "dump must use the underscore key `estimated_program_length`:\n{out}"
607        );
608        assert!(
609            out.contains("purity_ratio: "),
610            "dump must use the underscore key `purity_ratio`:\n{out}"
611        );
612        assert!(
613            !out.contains("estimated program length"),
614            "dump must not emit the space-separated `estimated program length`:\n{out}"
615        );
616        assert!(
617            !out.contains("purity ratio"),
618            "dump must not emit the space-separated `purity ratio`:\n{out}"
619        );
620    }
621}