Skip to main content

moonpool_sim/runner/
display.rs

1//! Colored terminal display for simulation reports.
2//!
3//! Provides rich, colorized output for TTY stderr. Falls back to the plain
4//! `Display` impl when stderr is not a terminal or `NO_COLOR` is set.
5
6use std::io::{IsTerminal, Write};
7
8use moonpool_assertions::AssertKind;
9
10use super::report::{
11    AssertionDetail, AssertionStatus, BucketSiteSummary, ExplorationReport, SaturationSignal,
12    SimulationReport, format_recipe,
13};
14
15// ---------------------------------------------------------------------------
16// ANSI escape helpers
17// ---------------------------------------------------------------------------
18
19mod ansi {
20    pub const RESET: &str = "\x1b[0m";
21    pub const BOLD: &str = "\x1b[1m";
22    pub const DIM: &str = "\x1b[2m";
23    pub const RED: &str = "\x1b[31m";
24    pub const GREEN: &str = "\x1b[32m";
25    pub const YELLOW: &str = "\x1b[33m";
26    pub const BOLD_RED: &str = "\x1b[1;31m";
27    pub const BOLD_GREEN: &str = "\x1b[1;32m";
28    pub const BOLD_YELLOW: &str = "\x1b[1;33m";
29    pub const BOLD_CYAN: &str = "\x1b[1;36m";
30}
31
32/// Whether to emit ANSI color codes.
33fn use_color() -> bool {
34    std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none()
35}
36
37// ---------------------------------------------------------------------------
38// Formatting helpers (reused from report.rs, extended)
39// ---------------------------------------------------------------------------
40
41/// Format a `u64` with comma separators.
42fn fmt_num(n: u64) -> String {
43    let s = n.to_string();
44    let mut result = String::with_capacity(s.len() + s.len() / 3);
45    for (i, c) in s.chars().rev().enumerate() {
46        if i > 0 && i % 3 == 0 {
47            result.push(',');
48        }
49        result.push(c);
50    }
51    result.chars().rev().collect()
52}
53
54/// Format an `i64` with comma separators.
55fn fmt_i64(n: i64) -> String {
56    if n < 0 {
57        format!("-{}", fmt_num(n.unsigned_abs()))
58    } else {
59        fmt_num(n.unsigned_abs())
60    }
61}
62
63/// Convert a non-negative finite `f64` to a saturated `u64`.
64fn f64_to_u64_saturating(v: f64) -> u64 {
65    const TWO_POW_64: f64 = 18_446_744_073_709_551_616.0;
66    if !v.is_finite() || v <= 0.0 {
67        0
68    } else if v >= TWO_POW_64 {
69        u64::MAX
70    } else {
71        // SAFETY: `v` is finite, non-negative, and strictly below `2^64`.
72        unsafe { v.round().to_int_unchecked::<u64>() }
73    }
74}
75
76/// Format a duration as a human-readable string.
77fn fmt_duration(d: std::time::Duration) -> String {
78    let total_ms = d.as_millis();
79    if total_ms < 1000 {
80        format!("{total_ms}ms")
81    } else if total_ms < 60_000 {
82        format!("{:.2}s", d.as_secs_f64())
83    } else {
84        let mins = d.as_secs() / 60;
85        let secs = d.as_secs() % 60;
86        format!("{mins}m {secs:02}s")
87    }
88}
89
90/// Short label for an assertion kind.
91fn kind_label(kind: AssertKind) -> &'static str {
92    match kind {
93        AssertKind::Always => "always",
94        AssertKind::AlwaysOrUnreachable => "always?",
95        AssertKind::Sometimes => "sometimes",
96        AssertKind::Reachable => "reachable",
97        AssertKind::Unreachable => "unreachable",
98        AssertKind::NumericAlways => "num-always",
99        AssertKind::NumericSometimes => "numeric",
100        AssertKind::BooleanSometimesAll => "frontier",
101    }
102}
103
104/// Sort key for grouping assertion kinds.
105fn kind_sort_order(kind: AssertKind) -> u8 {
106    match kind {
107        AssertKind::Always => 0,
108        AssertKind::AlwaysOrUnreachable => 1,
109        AssertKind::Unreachable => 2,
110        AssertKind::NumericAlways => 3,
111        AssertKind::Sometimes => 4,
112        AssertKind::Reachable => 5,
113        AssertKind::NumericSometimes => 6,
114        AssertKind::BooleanSometimesAll => 7,
115    }
116}
117
118// ---------------------------------------------------------------------------
119// Progress bar
120// ---------------------------------------------------------------------------
121
122const BAR_WIDTH: usize = 20;
123
124/// Render a progress bar: `████████░░░░░░░░░░░░  62.5%`
125fn progress_bar(fraction: f64, color: bool) -> String {
126    let bar_width_u32 = u32::try_from(BAR_WIDTH).expect("bar width fits in u32");
127    let filled_f = (fraction * f64::from(bar_width_u32))
128        .round()
129        .clamp(0.0, f64::from(bar_width_u32));
130    // SAFETY: `filled_f` is in `[0, BAR_WIDTH]` and finite.
131    let filled = unsafe { filled_f.to_int_unchecked::<usize>() }.min(BAR_WIDTH);
132    let empty = BAR_WIDTH - filled;
133
134    let bar_color = if !color {
135        ""
136    } else if fraction >= 0.5 {
137        ansi::GREEN
138    } else if fraction >= 0.2 {
139        ansi::YELLOW
140    } else {
141        ansi::RED
142    };
143    let reset = if color { ansi::RESET } else { "" };
144
145    format!(
146        "{}{}{}{}  {:.1}%",
147        bar_color,
148        "█".repeat(filled),
149        "░".repeat(empty),
150        reset,
151        fraction * 100.0
152    )
153}
154
155// ---------------------------------------------------------------------------
156// Section header
157// ---------------------------------------------------------------------------
158
159const RULE_WIDTH: usize = 56;
160
161/// Print a section header like: `━━━ Title ━━━━━━━━━━━━━━━━━━━━━━`
162fn section_header(w: &mut impl Write, title: &str, color: bool, style: &str) {
163    let prefix = "━━━ ";
164    let suffix_char = '━';
165    // title + spaces around it
166    let content_len = prefix.len() + title.len() + 1; // +1 for trailing space
167    let trail = if RULE_WIDTH > content_len {
168        RULE_WIDTH - content_len
169    } else {
170        3
171    };
172
173    if color {
174        let _ = write!(w, "\n{style}{prefix}{title} ");
175        for _ in 0..trail {
176            let _ = write!(w, "{suffix_char}");
177        }
178        let _ = writeln!(w, "{}", ansi::RESET);
179    } else {
180        let _ = write!(w, "\n{prefix}{title} ");
181        for _ in 0..trail {
182            let _ = write!(w, "{suffix_char}");
183        }
184        let _ = writeln!(w);
185    }
186}
187
188// ---------------------------------------------------------------------------
189// Status indicators
190// ---------------------------------------------------------------------------
191
192fn status_icon(status: AssertionStatus, color: bool) -> &'static str {
193    match (status, color) {
194        (AssertionStatus::Pass, true) => "\x1b[32m✓\x1b[0m",
195        (AssertionStatus::Fail, true) => "\x1b[1;31m✗\x1b[0m",
196        (AssertionStatus::Miss, true) => "\x1b[33m○\x1b[0m",
197        (AssertionStatus::Pass, false) => "✓",
198        (AssertionStatus::Fail, false) => "✗",
199        (AssertionStatus::Miss, false) => "○",
200    }
201}
202
203// ---------------------------------------------------------------------------
204// Main entry point
205// ---------------------------------------------------------------------------
206
207/// Print the simulation report to stderr with colors if supported.
208///
209/// Falls back to the plain `Display` impl when stderr is not a terminal
210/// or the `NO_COLOR` environment variable is set.
211pub fn eprint_report(report: &SimulationReport) {
212    let color = use_color();
213    let mut w = std::io::stderr().lock();
214    write_report(&mut w, report, color);
215}
216
217fn write_report_summary(w: &mut impl Write, report: &SimulationReport, color: bool) {
218    let rate = report.success_rate();
219    let (rate_icon, rate_color) = if report.failed_runs == 0 {
220        ("✓", ansi::BOLD_GREEN)
221    } else {
222        ("✗", ansi::BOLD_RED)
223    };
224
225    if color {
226        let _ = writeln!(
227            w,
228            "  {} iterations   {} passed   {} failed   {rate_color}{rate_icon} {rate:.1}%{reset}",
229            report.iterations,
230            report.successful_runs,
231            report.failed_runs,
232            rate_color = rate_color,
233            rate_icon = rate_icon,
234            rate = rate,
235            reset = ansi::RESET,
236        );
237    } else {
238        let _ = writeln!(
239            w,
240            "  {} iterations   {} passed   {} failed   {rate_icon} {rate:.1}%",
241            report.iterations,
242            report.successful_runs,
243            report.failed_runs,
244            rate_icon = rate_icon,
245            rate = rate,
246        );
247    }
248}
249
250fn write_report_timing(w: &mut impl Write, report: &SimulationReport) {
251    let _ = writeln!(w);
252    let _ = writeln!(
253        w,
254        "  Wall Time    {:<14} {} total",
255        fmt_duration(report.average_wall_time()) + " avg",
256        fmt_duration(report.metrics.wall_time),
257    );
258    let _ = writeln!(
259        w,
260        "  Sim Time     {} avg",
261        fmt_duration(report.average_simulated_time()),
262    );
263    let _ = writeln!(
264        w,
265        "  Events       {} avg",
266        fmt_num(f64_to_u64_saturating(report.average_events_processed())),
267    );
268}
269
270fn write_report_faulty_seeds(w: &mut impl Write, report: &SimulationReport, color: bool) {
271    if report.seeds_failing.is_empty() {
272        return;
273    }
274    let _ = writeln!(w);
275    if color {
276        let _ = write!(w, "  {}Faulty seeds:{} ", ansi::BOLD_RED, ansi::RESET);
277    } else {
278        let _ = write!(w, "  Faulty seeds: ");
279    }
280    let _ = writeln!(w, "{:?}", report.seeds_failing);
281}
282
283fn write_report(w: &mut impl Write, report: &SimulationReport, color: bool) {
284    // === Header ===
285    section_header(w, "Simulation Report", color, ansi::BOLD_CYAN);
286
287    write_report_summary(w, report, color);
288    write_report_timing(w, report);
289    write_report_faulty_seeds(w, report, color);
290
291    // === Exploration ===
292    if let Some(ref exp) = report.exploration {
293        write_exploration(w, exp, color);
294    }
295
296    // === Assertions ===
297    if !report.assertion_details.is_empty() {
298        write_assertions(w, &report.assertion_details, color);
299    }
300
301    // === Violations ===
302    if !report.assertion_violations.is_empty() {
303        section_header(w, "Violations", color, ansi::BOLD_RED);
304        for v in &report.assertion_violations {
305            if color {
306                let _ = writeln!(w, "  {}✗{}  {}", ansi::BOLD_RED, ansi::RESET, v);
307            } else {
308                let _ = writeln!(w, "  ✗  {v}");
309            }
310        }
311    }
312
313    // === Coverage Gaps ===
314    if !report.coverage_violations.is_empty() {
315        section_header(w, "Coverage Gaps", color, ansi::BOLD_YELLOW);
316        for v in &report.coverage_violations {
317            if color {
318                let _ = writeln!(w, "  {}○{}  {}", ansi::YELLOW, ansi::RESET, v);
319            } else {
320                let _ = writeln!(w, "  ○  {v}");
321            }
322        }
323    }
324
325    // === Buckets ===
326    if !report.bucket_summaries.is_empty() {
327        write_buckets(w, &report.bucket_summaries, color);
328    }
329
330    // === Saturation (UntilCoverageStable) ===
331    if let Some(sat) = &report.saturation {
332        let (title, style) = if report.convergence_timeout {
333            ("Saturation FAILED", ansi::BOLD_RED)
334        } else {
335            ("Saturated", ansi::BOLD)
336        };
337        section_header(w, title, color, style);
338        match sat.signal {
339            SaturationSignal::CodeCoverage => {
340                let _ = writeln!(
341                    w,
342                    "  {} / {} sometimes hit · code coverage stable at {} / {} edges for {} seeds",
343                    sat.sometimes_hit,
344                    sat.sometimes_total,
345                    fmt_num(u64::try_from(sat.edges_covered).unwrap_or(u64::MAX)),
346                    fmt_num(u64::try_from(sat.edges_total).unwrap_or(u64::MAX)),
347                    sat.plateau_seeds,
348                );
349            }
350            SaturationSignal::AssertionCoverage => {
351                let _ = writeln!(
352                    w,
353                    "  {} / {} sometimes hit · assertion coverage stable for {} seeds (no sancov — run via 'cargo xtask sim run' for code-coverage-driven stop)",
354                    sat.sometimes_hit, sat.sometimes_total, sat.plateau_seeds,
355                );
356            }
357        }
358        if report.convergence_timeout {
359            let _ = writeln!(
360                w,
361                "  UntilCoverageStable hit the iteration cap without saturating.",
362            );
363        }
364    } else if report.convergence_timeout {
365        section_header(w, "Saturation FAILED", color, ansi::BOLD_RED);
366        let _ = writeln!(
367            w,
368            "  UntilCoverageStable hit the iteration cap without saturating.",
369        );
370    }
371
372    // === Per-Seed Metrics ===
373    if report.seeds_used.len() > 1 {
374        write_seeds(w, report, color);
375    }
376
377    let _ = writeln!(w);
378}
379
380// ---------------------------------------------------------------------------
381// Sub-sections
382// ---------------------------------------------------------------------------
383
384fn write_exploration(w: &mut impl Write, exp: &ExplorationReport, color: bool) {
385    section_header(w, "Exploration", color, ansi::BOLD_CYAN);
386
387    if exp.converged {
388        if color {
389            let _ = writeln!(
390                w,
391                "  Status       {}CONVERGED{}",
392                ansi::BOLD_GREEN,
393                ansi::RESET
394            );
395        } else {
396            let _ = writeln!(w, "  Status       CONVERGED");
397        }
398    }
399
400    let _ = writeln!(
401        w,
402        "  Timelines    {:<16} Bugs         {}",
403        fmt_num(exp.total_timelines),
404        fmt_num(exp.bugs_found),
405    );
406    let _ = writeln!(
407        w,
408        "  Fork Points  {:<16} Energy       {} remaining",
409        fmt_num(exp.fork_points),
410        fmt_i64(exp.energy_remaining),
411    );
412
413    if exp.realloc_pool_remaining != 0 {
414        let _ = writeln!(w, "  Realloc Pool {}", fmt_i64(exp.realloc_pool_remaining));
415    }
416
417    // Progress bars
418    let _ = writeln!(w);
419    if exp.coverage_total > 0 {
420        let frac = f64::from(exp.coverage_bits) / f64::from(exp.coverage_total);
421        let _ = writeln!(
422            w,
423            "  Exploration  {}   {} / {} bits",
424            progress_bar(frac, color),
425            fmt_num(u64::from(exp.coverage_bits)),
426            fmt_num(u64::from(exp.coverage_total)),
427        );
428    }
429    if exp.sancov_edges_total > 0 {
430        let covered_u32 = u32::try_from(exp.sancov_edges_covered).unwrap_or(u32::MAX);
431        let total_u32 = u32::try_from(exp.sancov_edges_total).unwrap_or(u32::MAX);
432        let frac = f64::from(covered_u32) / f64::from(total_u32);
433        let _ = writeln!(
434            w,
435            "  Code Cov     {}   {} / {} edges",
436            progress_bar(frac, color),
437            fmt_num(u64::try_from(exp.sancov_edges_covered).unwrap_or(u64::MAX)),
438            fmt_num(u64::try_from(exp.sancov_edges_total).unwrap_or(u64::MAX)),
439        );
440    }
441
442    // Bug recipes
443    if !exp.bug_recipes.is_empty() {
444        let _ = writeln!(w);
445        if color {
446            let _ = writeln!(w, "  {}Bug Recipes{}", ansi::BOLD, ansi::RESET);
447        } else {
448            let _ = writeln!(w, "  Bug Recipes");
449        }
450        for br in &exp.bug_recipes {
451            let _ = writeln!(w, "    seed={}: {}", br.seed, format_recipe(&br.recipe));
452        }
453    }
454}
455
456fn write_assertions(w: &mut impl Write, details: &[AssertionDetail], color: bool) {
457    section_header(
458        w,
459        &format!("Assertions ({})", details.len()),
460        color,
461        ansi::BOLD_CYAN,
462    );
463
464    let mut sorted: Vec<&AssertionDetail> = details.iter().collect();
465    sorted.sort_by(|a, b| {
466        kind_sort_order(a.kind)
467            .cmp(&kind_sort_order(b.kind))
468            .then(a.status.cmp(&b.status))
469            .then(a.msg.cmp(&b.msg))
470    });
471
472    // Compute max message length for alignment (capped)
473    let max_msg = sorted
474        .iter()
475        .map(|d| d.msg.len())
476        .max()
477        .unwrap_or(0)
478        .min(40);
479
480    for detail in &sorted {
481        let icon = status_icon(detail.status, color);
482        let kind = kind_label(detail.kind);
483        let msg = &detail.msg;
484        // Truncate very long messages
485        let display_msg = if msg.len() > 40 {
486            format!("\"{}...\"", &msg[..37])
487        } else {
488            format!("\"{msg}\"")
489        };
490
491        let stats = match detail.kind {
492            AssertKind::Sometimes | AssertKind::Reachable => {
493                let total = detail.pass_count + detail.fail_count;
494                let rate = if total > 0 {
495                    let pass_u32 = u32::try_from(detail.pass_count).unwrap_or(u32::MAX);
496                    let total_u32 = u32::try_from(total).unwrap_or(u32::MAX);
497                    (f64::from(pass_u32) / f64::from(total_u32)) * 100.0
498                } else {
499                    0.0
500                };
501                format!(
502                    "{} / {}  ({:.1}%)",
503                    fmt_num(detail.pass_count),
504                    fmt_num(total),
505                    rate
506                )
507            }
508            AssertKind::NumericSometimes | AssertKind::NumericAlways => {
509                format!(
510                    "{} pass  {} fail  best: {}",
511                    fmt_num(detail.pass_count),
512                    fmt_num(detail.fail_count),
513                    detail.watermark,
514                )
515            }
516            AssertKind::BooleanSometimesAll => {
517                format!(
518                    "{} calls  frontier: {}",
519                    fmt_num(detail.pass_count),
520                    detail.frontier,
521                )
522            }
523            _ => {
524                // Always, AlwaysOrUnreachable, Unreachable
525                format!(
526                    "{} pass  {} fail",
527                    fmt_num(detail.pass_count),
528                    fmt_num(detail.fail_count),
529                )
530            }
531        };
532
533        // Pad message for alignment
534        let pad = if max_msg + 2 > display_msg.len() {
535            max_msg + 2 - display_msg.len()
536        } else {
537            1
538        };
539
540        let _ = writeln!(
541            w,
542            "  {icon}  {kind:<12} {display_msg}{padding}{stats}",
543            icon = icon,
544            kind = kind,
545            display_msg = display_msg,
546            padding = " ".repeat(pad),
547            stats = stats,
548        );
549    }
550}
551
552fn write_buckets(w: &mut impl Write, summaries: &[BucketSiteSummary], color: bool) {
553    let total_buckets: usize = summaries.iter().map(|s| s.buckets_discovered).sum();
554    section_header(
555        w,
556        &format!(
557            "Buckets ({} across {} sites)",
558            total_buckets,
559            summaries.len()
560        ),
561        color,
562        ansi::BOLD_CYAN,
563    );
564    for bs in summaries {
565        let _ = writeln!(
566            w,
567            "  {:<34}  {:>3} buckets  {:>8} hits",
568            format!("\"{}\"", bs.msg),
569            bs.buckets_discovered,
570            fmt_num(bs.total_hits),
571        );
572    }
573}
574
575fn write_seeds(w: &mut impl Write, report: &SimulationReport, color: bool) {
576    section_header(w, "Seeds", color, ansi::BOLD_CYAN);
577
578    let dim = if color { ansi::DIM } else { "" };
579    let reset = if color { ansi::RESET } else { "" };
580
581    let per_seed_tl = report.exploration.as_ref().map(|e| &e.per_seed_timelines);
582
583    for (i, seed) in report.seeds_used.iter().enumerate() {
584        if let Some(Ok(m)) = report.individual_metrics.get(i) {
585            let tl_suffix = per_seed_tl
586                .and_then(|v| v.get(i))
587                .map(|t| format!("   {} timelines", fmt_num(*t)))
588                .unwrap_or_default();
589            let is_failed = report.seeds_failing.contains(seed);
590            if is_failed && color {
591                let _ = writeln!(
592                    w,
593                    "  {red}#{:<3}  seed={:<14}  {}   {} sim   {} events{tl}{reset}",
594                    i + 1,
595                    seed,
596                    fmt_duration(m.wall_time),
597                    fmt_duration(m.simulated_time),
598                    fmt_num(m.events_processed),
599                    tl = tl_suffix,
600                    red = ansi::RED,
601                    reset = ansi::RESET,
602                );
603            } else {
604                let _ = writeln!(
605                    w,
606                    "  {dim}#{:<3}  seed={:<14}  {}   {} sim   {} events{tl}{reset}",
607                    i + 1,
608                    seed,
609                    fmt_duration(m.wall_time),
610                    fmt_duration(m.simulated_time),
611                    fmt_num(m.events_processed),
612                    tl = tl_suffix,
613                    dim = dim,
614                    reset = reset,
615                );
616            }
617        } else if let Some(Err(_)) = report.individual_metrics.get(i) {
618            if color {
619                let _ = writeln!(
620                    w,
621                    "  {red}#{:<3}  seed={:<14}  FAILED{reset}",
622                    i + 1,
623                    seed,
624                    red = ansi::BOLD_RED,
625                    reset = ansi::RESET,
626                );
627            } else {
628                let _ = writeln!(w, "  #{:<3}  seed={:<14}  FAILED", i + 1, seed);
629            }
630        }
631    }
632}