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