Skip to main content

magi/
report.rs

1//! Terminal rendering.
2//!
3//! A run produces a lot of state; the report exists so the operator can decide
4//! what to do next without opening `run.json`. It leads with the disagreement,
5//! because that is the part that carries information: three judges agreeing
6//! tells you nothing the winner's diff does not.
7//!
8//! Colour is a six-line local implementation rather than a crate. The
9//! alternatives all decide *for* you whether the stream supports colour, which
10//! makes the output untestable — `assert!(text.contains("winner  A"))` fails on
11//! an escape sequence the test never asked for.
12use 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
20/// Turn colour on or off for every subsequent render.
21pub 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
52/// Colour for a status word.
53///
54/// `Stalled` is deliberately not green: a run whose judges were taken out by a
55/// rate limit must not look like a healthy `Ready` in a one-line listing.
56fn 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
68/// One-line summary, for `magi list`.
69pub 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    // A below-quorum verdict carries an explicit stamp so a row in a listing
76    // reads "stalled" and "2/3 judges" without opening the report.
77    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
113/// Full report for one run.
114pub 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
392/// Aggregate tables, for `magi stats`.
393pub 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    /// `COLOR` is process-global, so these tests cannot run concurrently.
491    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        // `run()` prints `state.dir()`, which reads the process-global home;
501        // pinning it here keeps this test off the operator's real one. The
502        // directory itself is never read, only its path printed, so nothing
503        // needs to create or clean it up.
504        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}