1use std::io::{IsTerminal, Write};
7
8use moonpool_explorer::AssertKind;
9
10use super::report::{
11 AssertionDetail, AssertionStatus, BucketSiteSummary, ExplorationReport, SimulationReport,
12};
13
14mod 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
31fn use_color() -> bool {
33 std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none()
34}
35
36fn 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
53fn 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
62fn 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
76fn 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
90fn 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
104const BAR_WIDTH: usize = 20;
109
110fn 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
136const RULE_WIDTH: usize = 56;
141
142fn section_header(w: &mut impl Write, title: &str, color: bool, style: &str) {
144 let prefix = "━━━ ";
145 let suffix_char = '━';
146 let content_len = prefix.len() + title.len() + 1; 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
169fn 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
184pub 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 section_header(w, "Simulation Report", color, ansi::BOLD_CYAN);
201
202 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 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 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 if let Some(ref exp) = report.exploration {
266 write_exploration(w, exp, color);
267 }
268
269 if !report.assertion_details.is_empty() {
271 write_assertions(w, &report.assertion_details, color);
272 }
273
274 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 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 if !report.bucket_summaries.is_empty() {
300 write_buckets(w, &report.bucket_summaries, color);
301 }
302
303 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 if report.seeds_used.len() > 1 {
320 write_seeds(w, report, color);
321 }
322
323 let _ = writeln!(w);
324}
325
326fn 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 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 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 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 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 format!(
473 "{} pass {} fail",
474 fmt_num(detail.pass_count),
475 fmt_num(detail.fail_count),
476 )
477 }
478 };
479
480 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}