1use std::fmt::Write as _;
13use std::sync::atomic::{AtomicBool, Ordering};
14
15use crate::run::{RunState, RunStatus};
16use crate::stats::Stats;
17
18static COLOR: AtomicBool = AtomicBool::new(true);
19
20pub fn set_color(on: bool) {
22 COLOR.store(on, Ordering::Relaxed);
23}
24
25fn paint(text: &str, code: &str) -> String {
26 if COLOR.load(Ordering::Relaxed) {
27 format!("\x1b[{code}m{text}\x1b[0m")
28 } else {
29 text.to_owned()
30 }
31}
32
33fn bold(t: &str) -> String {
34 paint(t, "1")
35}
36fn dim(t: &str) -> String {
37 paint(t, "2")
38}
39fn red(t: &str) -> String {
40 paint(t, "31")
41}
42fn green(t: &str) -> String {
43 paint(t, "32")
44}
45fn yellow(t: &str) -> String {
46 paint(t, "33")
47}
48fn cyan(t: &str) -> String {
49 paint(t, "36")
50}
51
52fn status_word(status: RunStatus) -> String {
57 let text = format!("{status:?}").to_lowercase();
58 match status {
59 RunStatus::Merged => bold(&green(&text)),
60 RunStatus::Ready => green(&text),
61 RunStatus::Stalled => bold(&yellow(&text)),
62 RunStatus::Blocked => yellow(&text),
63 RunStatus::Failed => red(&text),
64 _ => cyan(&text),
65 }
66}
67
68pub fn line(state: &RunState) -> String {
70 let winner = state
71 .tally
72 .as_ref()
73 .map_or("-".to_owned(), |t| t.winner.to_string());
74 let agent = state.winner().map_or("-", |c| c.agent.as_str());
75 let quorum = match state.tally.as_ref() {
78 Some(t) if !t.met_quorum => format!(
79 " {}",
80 bold(&red(&format!("quorum {}/{}", t.present, t.judges)))
81 ),
82 Some(t) if t.present > 0 && t.present < t.judges => format!(
83 " {}",
84 yellow(&format!("judges {}/{}", t.present, t.judges))
85 ),
86 _ => String::new(),
87 };
88 format!(
89 "{} {:<20} {:>2}c {:>2}j win {} ({}){quorum} {}",
90 dim(&state.id),
91 status_word(state.status),
92 state.candidates.len(),
93 state.judgements.len(),
94 winner,
95 agent,
96 first_line(&state.instruction)
97 )
98}
99
100fn first_line(text: &str) -> String {
101 let line = text.lines().next().unwrap_or_default();
102 if line.chars().count() > 68 {
103 format!("{}…", line.chars().take(67).collect::<String>())
104 } else {
105 line.to_owned()
106 }
107}
108
109fn short(commit: &str) -> String {
110 commit.chars().take(7).collect()
111}
112
113pub fn run(state: &RunState) -> String {
115 let mut s = String::new();
116 let _ = writeln!(
117 s,
118 "{} {} {}",
119 bold("magi run"),
120 bold(&state.id),
121 status_word(state.status)
122 );
123 let _ = writeln!(
124 s,
125 " repo {} ({} @ {})",
126 state.repo.display(),
127 state.base_branch,
128 short(&state.base_commit)
129 );
130 let _ = writeln!(s, " created {}", state.created_local());
131 let _ = writeln!(s, " task {}", first_line(&state.instruction));
132 let _ = writeln!(s, " state {}", state.dir().display());
133
134 let _ = writeln!(s, "\n{}", bold("candidates"));
135 for c in &state.candidates {
136 let flag = match (&c.failed, c.empty) {
137 (Some(e), _) => red(&format!("failed: {e}")),
138 (None, true) => yellow("no change"),
139 _ => format!("{} files, {} commits", c.files, c.commits),
140 };
141 let crown = if state.tally.as_ref().is_some_and(|t| t.winner == c.label) {
142 bold(&green(" <- winner"))
143 } else {
144 String::new()
145 };
146 let _ = writeln!(
147 s,
148 " {} {:<12} {:<30} {:>5}s{}",
149 bold(&c.label.to_string()),
150 c.agent,
151 flag,
152 c.duration_ms / 1000,
153 crown
154 );
155 }
156
157 if !state.judgements.is_empty() {
158 let _ = writeln!(s, "\n{}", bold("blind judging"));
159 for j in &state.judgements {
160 match &j.failed {
161 Some(e) => {
162 let _ = writeln!(
163 s,
164 " judge {} {}",
165 j.judge,
166 red(&format!("no ranking: {e}"))
167 );
168 }
169 None => {
170 let _ = writeln!(
171 s,
172 " judge {} {:<12} {} confidence {}",
173 j.judge,
174 j.agent,
175 bold(&j.ranking.iter().collect::<String>()),
176 j.confidence.map_or("-".to_owned(), |c| c.to_string())
177 );
178 }
179 }
180 }
181 }
182
183 if let Some(t) = &state.tally {
184 if t.deliberated {
185 let _ = writeln!(s, "\n{}", bold("deliberation"));
186 for round in &state.deliberation {
187 for turn in &round.turns {
188 let _ = writeln!(
189 s,
190 " r{} judge {} -> {}",
191 round.round,
192 turn.judge,
193 turn.tentative.map_or("-".to_owned(), |c| c.to_string())
194 );
195 }
196 }
197 }
198
199 if !state.votes.is_empty() {
200 let _ = writeln!(s, "\n{}", bold("final votes (collected privately)"));
201 for v in &state.votes {
202 let _ = writeln!(
203 s,
204 " judge {} {:<12} {}{}",
205 v.judge,
206 v.agent,
207 bold(&v.vote.unwrap_or('?').to_string()),
208 if v.changed {
209 yellow(" (changed after deliberation)")
210 } else {
211 String::new()
212 }
213 );
214 }
215 }
216
217 let _ = writeln!(s, "\n{}", bold("tally"));
218 if t.judges > 0 {
219 let _ = writeln!(
220 s,
221 " judges {} present{}",
222 if t.met_quorum {
223 green(&format!("{}/{}", t.present, t.judges))
224 } else {
225 red(&format!("{}/{}", t.present, t.judges))
226 },
227 if t.quorum > 0 {
228 format!(" ({quorum} required)", quorum = t.quorum)
229 } else {
230 String::new()
231 }
232 );
233 }
234 if !t.met_quorum {
235 let _ = writeln!(
236 s,
237 " {}",
238 bold(&red("BELOW QUORUM — verdict is not trustworthy"))
239 );
240 }
241 if !state.quota.is_empty() {
242 let _ = writeln!(
243 s,
244 " rate limited {}",
245 state
246 .quota
247 .iter()
248 .map(|q| q.seat.as_str())
249 .collect::<Vec<_>>()
250 .join(", ")
251 );
252 }
253 let _ = writeln!(
254 s,
255 " first choice {}",
256 t.first_choice
257 .iter()
258 .map(|(k, v)| format!("{k}:{v}"))
259 .collect::<Vec<_>>()
260 .join(" ")
261 );
262 let _ = writeln!(
263 s,
264 " initial {}",
265 match (t.rankings, t.unanimous_initial) {
266 (0, _) => red("no usable ranking"),
267 (1, _) => yellow("one usable ranking - not a consensus"),
268 (_, true) => green("unanimous"),
269 (_, false) => yellow("split"),
270 }
271 );
272 let _ = writeln!(
273 s,
274 " after votes {} ({} judge(s) moved)",
275 if t.unanimous_final {
276 green("unanimous")
277 } else {
278 yellow("still split")
279 },
280 t.changed_votes
281 );
282 if let Some(tb) = &t.tie_break {
283 let _ = writeln!(s, " tie break {tb}");
284 }
285 let _ = writeln!(s, " winner {}", bold(&green(&t.winner.to_string())));
286 }
287
288 if !state.reviews.is_empty() {
289 let _ = writeln!(s, "\n{}", bold("review + verification"));
290 for r in &state.reviews {
291 let raised: usize = r.reviews.iter().map(|x| x.findings.len()).sum();
292 let e2e = if r.e2e.is_empty() {
293 dim("no e2e")
294 } else if r.e2e.iter().all(|o| o.ok()) {
295 green("e2e green")
296 } else {
297 red("e2e RED")
298 };
299 let _ = writeln!(
300 s,
301 " round {} {} @ {} {raised} finding(s), {} blocking, {e2e}{}",
302 r.round,
303 if r.clean {
304 green("clean")
305 } else {
306 yellow("open")
307 },
308 short(&r.head),
309 r.blocking,
310 r.fix.as_ref().map_or(String::new(), |f| format!(
311 " fix: {} addressed / {} rejected{}",
312 f.addressed.len(),
313 f.rejected.len(),
314 if f.committed {
315 String::new()
316 } else {
317 red(" (NO COMMIT)")
318 }
319 ))
320 );
321 for rec in &r.reviews {
322 for f in &rec.findings {
323 let adopted = r
324 .fix
325 .as_ref()
326 .is_some_and(|fix| fix.addressed.contains(&f.id));
327 let _ = writeln!(
328 s,
329 " {} [{:?}] {}{}",
330 dim(&f.id),
331 f.severity,
332 f.title,
333 if adopted {
334 green(" fixed")
335 } else {
336 String::new()
337 }
338 );
339 }
340 }
341 }
342 }
343
344 if !state.gate.is_empty() {
345 let _ = writeln!(s, "\n{}", bold("gate"));
346 for o in &state.gate {
347 let _ = writeln!(
348 s,
349 " {} {}",
350 if o.ok() { green("pass") } else { red("FAIL") },
351 o.command
352 );
353 }
354 }
355
356 if let Some(m) = &state.merge {
357 let _ = writeln!(s, "\n{}", bold("merge"));
358 let _ = writeln!(
359 s,
360 " mode {:?} {}\n {}",
361 m.mode,
362 if m.ok {
363 green("ok")
364 } else {
365 yellow("not merged")
366 },
367 m.detail.lines().next().unwrap_or("")
368 );
369 }
370
371 if !state.leaks.is_empty() {
372 let _ = writeln!(s, "\n{}", bold(&yellow("blindness warnings")));
373 for l in &state.leaks {
374 let _ = writeln!(s, " {} x{} in {}", l.token, l.count, l.site);
375 }
376 }
377
378 if let Some(w) = state.winner()
379 && !w.folded
380 {
381 let _ = writeln!(
382 s,
383 "\n{} {}\n branch {}",
384 bold("winner worktree"),
385 w.worktree.display(),
386 w.branch
387 );
388 }
389 s
390}
391
392pub fn stats(stats: &Stats) -> String {
394 let t = &stats.totals;
395 let mut s = String::new();
396 let _ = writeln!(s, "{}", bold("runs"));
397 let _ = writeln!(
398 s,
399 " {} total - {} merged, {} ready, {} blocked, {} failed ({:.0}% completion)",
400 t.runs,
401 t.merged,
402 t.ready,
403 t.blocked,
404 t.failed,
405 t.completion_rate()
406 );
407 if t.tallied > 0 {
408 let _ = writeln!(
409 s,
410 " {} tallied - {} split on first choice ({:.0}%), {} deliberated, \
411 {} of those changed a mind, {} converged to unanimous",
412 t.tallied,
413 t.split,
414 t.split_rate(),
415 t.deliberated,
416 t.minds_changed,
417 t.converged
418 );
419 }
420
421 if !stats.agents.is_empty() {
422 let _ = writeln!(
423 s,
424 "\n{}",
425 bold("implementation (relative, on this workload)")
426 );
427 let _ = writeln!(
428 s,
429 " {:<14}{:>6}{:>8}{:>8}{:>8}",
430 "agent", "won", "entered", "rate", "empty"
431 );
432 for a in &stats.agents {
433 let _ = writeln!(
434 s,
435 " {:<14}{:>6}{:>8}{:>7.0}%{:>8}",
436 a.agent,
437 a.wins,
438 a.entered,
439 a.win_rate(),
440 a.empty
441 );
442 }
443 }
444
445 if !stats.reviewers.is_empty() {
446 let _ = writeln!(s, "\n{}", bold("review"));
447 let _ = writeln!(
448 s,
449 " {:<14}{:>8}{:>10}{:>11}{:>9}{:>9}",
450 "reviewer", "rounds", "submitted", "adopted/rd", "precision", "unique"
451 );
452 for r in &stats.reviewers {
453 let _ = writeln!(
454 s,
455 " {:<14}{:>8}{:>10}{:>11.2}{:>8.0}%{:>8.0}%",
456 r.agent,
457 r.rounds,
458 r.submitted,
459 r.adopted_per_round(),
460 r.precision(),
461 r.unique_rate()
462 );
463 }
464 }
465
466 if stats.e2e.rounds > 0 {
467 let _ = writeln!(s, "\n{}", bold("verification"));
468 let _ = writeln!(
469 s,
470 " {} rounds ran e2e, {} failed, {} of those with a clean static \
471 review ({:.0}% sole detections)",
472 stats.e2e.rounds,
473 stats.e2e.failures,
474 stats.e2e.sole_detections,
475 stats.e2e.sole_rate()
476 );
477 }
478 s
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484 use crate::config::Config;
485 use crate::run::{Candidate, RunState, Tally};
486 use std::collections::BTreeMap;
487 use std::path::PathBuf;
488 use std::sync::{Mutex, MutexGuard};
489
490 static SERIAL: Mutex<()> = Mutex::new(());
492
493 fn plain() -> MutexGuard<'static, ()> {
494 let guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
495 set_color(false);
496 guard
497 }
498
499 fn state() -> RunState {
500 crate::run::set_home(std::env::temp_dir().join("magi-report-test-home"));
505 let mut s = RunState::new(
506 PathBuf::from("/repo"),
507 "main".to_owned(),
508 "abcdef1234".to_owned(),
509 "add retries to the uploader".to_owned(),
510 Config::default(),
511 );
512 s.candidates = vec![Candidate {
513 index: 0,
514 label: 'A',
515 agent: "opus".to_owned(),
516 branch: "magi/x/A".to_owned(),
517 worktree: PathBuf::from("/wt/A"),
518 summary: String::new(),
519 stat: String::new(),
520 files: 3,
521 commits: 2,
522 empty: false,
523 failed: None,
524 duration_ms: 42_000,
525 folded: false,
526 }];
527 s.tally = Some(Tally {
528 first_choice: BTreeMap::from([('A', 3)]),
529 borda: BTreeMap::new(),
530 winner: 'A',
531 rankings: 3,
532 unanimous_initial: true,
533 deliberated: false,
534 changed_votes: 0,
535 unanimous_final: true,
536 tie_break: None,
537 judges: 3,
538 present: 3,
539 quorum: 2,
540 met_quorum: true,
541 });
542 s
543 }
544
545 #[test]
546 fn run_report_names_the_winner_and_its_author() {
547 let _guard = plain();
548 let text = run(&state());
549 assert!(text.contains("<- winner"), "{text}");
550 assert!(text.contains("opus"));
551 assert!(text.contains("3 files, 2 commits"));
552 assert!(text.contains("winner A"));
553 assert!(!text.contains('\x1b'), "colour leaked into a plain render");
554 }
555
556 #[test]
557 fn colour_is_emitted_only_when_enabled() {
558 let _guard = plain();
559 set_color(true);
560 let coloured = run(&state());
561 set_color(false);
562 let plain = run(&state());
563 assert!(coloured.contains('\x1b'));
564 assert!(!plain.contains('\x1b'));
565 assert!(coloured.len() > plain.len());
566 }
567
568 #[test]
569 fn list_line_is_single_line() {
570 let _guard = plain();
571 let l = line(&state());
572 assert_eq!(l.lines().count(), 1);
573 assert!(l.contains("add retries"));
574 assert!(l.contains("win A (opus)"));
575 }
576
577 #[test]
578 fn long_instructions_are_elided() {
579 let _guard = plain();
580 let mut s = state();
581 s.instruction = "x".repeat(200);
582 assert!(line(&s).contains('…'));
583 }
584
585 #[test]
586 fn stats_table_renders_without_runs() {
587 let _guard = plain();
588 let text = stats(&Stats::default());
589 assert!(text.contains("0 total"));
590 assert!(!text.contains("implementation"));
591 }
592}