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::config::{MergeMode, MergeStyle};
16use crate::run::{CommandOutcome, E2eStatus, RunState, RunStatus, tail};
17use crate::stats::Stats;
18use crate::verdict::ReviewVote;
19
20static COLOR: AtomicBool = AtomicBool::new(true);
21
22/// Turn colour on or off for every subsequent render.
23pub fn set_color(on: bool) {
24    COLOR.store(on, Ordering::Relaxed);
25}
26
27fn paint(text: &str, code: &str) -> String {
28    if COLOR.load(Ordering::Relaxed) {
29        format!("\x1b[{code}m{text}\x1b[0m")
30    } else {
31        text.to_owned()
32    }
33}
34
35fn bold(t: &str) -> String {
36    paint(t, "1")
37}
38fn dim(t: &str) -> String {
39    paint(t, "2")
40}
41fn red(t: &str) -> String {
42    paint(t, "31")
43}
44fn green(t: &str) -> String {
45    paint(t, "32")
46}
47fn yellow(t: &str) -> String {
48    paint(t, "33")
49}
50fn cyan(t: &str) -> String {
51    paint(t, "36")
52}
53
54/// Colour for a status word.
55///
56/// `Stalled` is deliberately not green: a run whose judges were taken out by a
57/// rate limit must not look like a healthy `Ready` in a one-line listing.
58fn status_word(status: RunStatus) -> String {
59    let text = format!("{status:?}").to_lowercase();
60    match status {
61        RunStatus::Merged => bold(&green(&text)),
62        RunStatus::Ready => green(&text),
63        RunStatus::Stalled => bold(&yellow(&text)),
64        RunStatus::Blocked => yellow(&text),
65        RunStatus::Failed => red(&text),
66        _ => cyan(&text),
67    }
68}
69
70/// Colour for a reviewer vote — the same scale a finding's severity gets:
71/// green for no reservations, yellow for proceed-but-look-at-this, red for a
72/// vote that says stop.
73fn vote_tag(vote: ReviewVote) -> String {
74    let text = vote.label();
75    match vote {
76        ReviewVote::Approve => green(text),
77        ReviewVote::ApproveWithFindings => yellow(text),
78        ReviewVote::Reject => red(text),
79    }
80}
81
82/// One-line summary, for `magi list`.
83pub fn line(state: &RunState) -> String {
84    let winner = state
85        .tally
86        .as_ref()
87        .map_or("-".to_owned(), |t| t.winner.to_string());
88    let agent = state.winner().map_or("-", |c| c.agent.as_str());
89    // A below-quorum verdict carries an explicit stamp so a row in a listing
90    // reads "stalled" and "2/3 judges" without opening the report.
91    let quorum = match state.tally.as_ref() {
92        Some(t) if !t.met_quorum => format!(
93            "  {}",
94            bold(&red(&format!("quorum {}/{}", t.present, t.judges)))
95        ),
96        Some(t) if t.present > 0 && t.present < t.judges => format!(
97            "  {}",
98            yellow(&format!("judges {}/{}", t.present, t.judges))
99        ),
100        _ => String::new(),
101    };
102    format!(
103        "{}  {:<20}  {:>2}c {:>2}j  win {} ({}){quorum}  {}",
104        dim(&state.id),
105        status_word(state.status),
106        state.candidates.len(),
107        state.judgements.len(),
108        winner,
109        agent,
110        first_line(&state.instruction)
111    )
112}
113
114fn first_line(text: &str) -> String {
115    let line = text.lines().next().unwrap_or_default();
116    if line.chars().count() > 68 {
117        format!("{}…", line.chars().take(67).collect::<String>())
118    } else {
119        line.to_owned()
120    }
121}
122
123fn short(commit: &str) -> String {
124    commit.chars().take(7).collect()
125}
126
127/// Full report for one run.
128pub fn run(state: &RunState) -> String {
129    let mut s = String::new();
130    let _ = writeln!(
131        s,
132        "{} {}  {}",
133        bold("magi run"),
134        bold(&state.id),
135        status_word(state.status)
136    );
137    let _ = writeln!(
138        s,
139        "  repo    {} ({} @ {})",
140        state.repo.display(),
141        state.base_branch,
142        short(&state.base_commit)
143    );
144    let _ = writeln!(s, "  created {}", state.created_local());
145    let _ = writeln!(s, "  task    {}", first_line(&state.instruction));
146    let _ = writeln!(s, "  state   {}", state.dir().display());
147
148    let _ = writeln!(s, "\n{}", bold("candidates"));
149    for c in &state.candidates {
150        let flag = match (&c.failed, c.empty) {
151            (Some(e), _) => red(&format!("failed: {e}")),
152            (None, true) => yellow("no change"),
153            _ => format!("{} files, {} commits", c.files, c.commits),
154        };
155        let crown = if state.tally.as_ref().is_some_and(|t| t.winner == c.label) {
156            bold(&green("  <- winner"))
157        } else {
158            String::new()
159        };
160        let _ = writeln!(
161            s,
162            "  {}  {:<12} {:<30} {:>5}s{}",
163            bold(&c.label.to_string()),
164            c.agent,
165            flag,
166            c.duration_ms / 1000,
167            crown
168        );
169    }
170
171    if !state.judgements.is_empty() {
172        let _ = writeln!(s, "\n{}", bold("blind judging"));
173        for j in &state.judgements {
174            match &j.failed {
175                Some(e) => {
176                    let _ = writeln!(
177                        s,
178                        "  judge {}  {}",
179                        j.judge,
180                        red(&format!("no ranking: {e}"))
181                    );
182                }
183                None => {
184                    let _ = writeln!(
185                        s,
186                        "  judge {}  {:<12} {}  confidence {}",
187                        j.judge,
188                        j.agent,
189                        bold(&j.ranking.iter().collect::<String>()),
190                        j.confidence.map_or("-".to_owned(), |c| c.to_string())
191                    );
192                }
193            }
194        }
195    }
196
197    if let Some(t) = &state.tally {
198        if t.deliberated {
199            let _ = writeln!(s, "\n{}", bold("deliberation"));
200            for round in &state.deliberation {
201                for turn in &round.turns {
202                    let _ = writeln!(
203                        s,
204                        "  r{} judge {} -> {}",
205                        round.round,
206                        turn.judge,
207                        turn.tentative.map_or("-".to_owned(), |c| c.to_string())
208                    );
209                }
210            }
211        }
212
213        if !state.votes.is_empty() {
214            let _ = writeln!(s, "\n{}", bold("final votes (collected privately)"));
215            for v in &state.votes {
216                let _ = writeln!(
217                    s,
218                    "  judge {}  {:<12} {}{}",
219                    v.judge,
220                    v.agent,
221                    bold(&v.vote.unwrap_or('?').to_string()),
222                    if v.changed {
223                        yellow("  (changed after deliberation)")
224                    } else {
225                        String::new()
226                    }
227                );
228            }
229        }
230
231        let _ = writeln!(s, "\n{}", bold("tally"));
232        // A tally with no panel (`uncontested`) must not fall through the
233        // judges/first-choice/after-votes lines below: they are written
234        // unconditionally and every one of them reads, in the words a panel
235        // that collapsed would also produce, as a run that lost its judges
236        // rather than one that never needed them.
237        match &t.uncontested {
238            Some(reason) => {
239                let _ = writeln!(
240                    s,
241                    "  judging       {}",
242                    cyan(&format!("not needed — {reason}"))
243                );
244            }
245            None => {
246                let _ = writeln!(
247                    s,
248                    "  judges        {} present{}",
249                    if t.met_quorum {
250                        green(&format!("{}/{}", t.present, t.judges))
251                    } else {
252                        red(&format!("{}/{}", t.present, t.judges))
253                    },
254                    if t.quorum > 0 {
255                        format!(" ({quorum} required)", quorum = t.quorum)
256                    } else {
257                        String::new()
258                    }
259                );
260                if !t.met_quorum {
261                    let _ = writeln!(
262                        s,
263                        "  {}",
264                        bold(&red("BELOW QUORUM — verdict is not trustworthy"))
265                    );
266                }
267                let _ = writeln!(
268                    s,
269                    "  first choice  {}",
270                    t.first_choice
271                        .iter()
272                        .map(|(k, v)| format!("{k}:{v}"))
273                        .collect::<Vec<_>>()
274                        .join("  ")
275                );
276                let _ = writeln!(
277                    s,
278                    "  initial       {}",
279                    match (t.rankings, t.unanimous_initial) {
280                        (0, _) => red("no usable ranking"),
281                        (1, _) => yellow("one usable ranking - not a consensus"),
282                        (_, true) => green("unanimous"),
283                        (_, false) => yellow("split"),
284                    }
285                );
286                let _ = writeln!(
287                    s,
288                    "  after votes   {}  ({} judge(s) moved)",
289                    if t.unanimous_final {
290                        green("unanimous")
291                    } else {
292                        yellow("still split")
293                    },
294                    t.changed_votes
295                );
296                if let Some(tb) = &t.tie_break {
297                    let _ = writeln!(s, "  tie break     {tb}");
298                }
299            }
300        }
301        if !state.quota.is_empty() {
302            let _ = writeln!(
303                s,
304                "  rate limited  {}",
305                state
306                    .quota
307                    .iter()
308                    .map(|q| q.seat.as_str())
309                    .collect::<Vec<_>>()
310                    .join(", ")
311            );
312        }
313        let _ = writeln!(s, "  winner        {}", bold(&green(&t.winner.to_string())));
314    }
315
316    if !state.reviews.is_empty() {
317        let _ = writeln!(s, "\n{}", bold("review + verification"));
318        for r in &state.reviews {
319            let raised: usize = r.reviews.iter().map(|x| x.findings.len()).sum();
320            // A build/link failure is not a verdict on the patch (a shared
321            // `CARGO_TARGET_DIR` link race looks exactly like one), so it
322            // must not read the same as a real test failure. And a deferred
323            // round is not a passed one: `e2e.is_empty()` alone cannot tell
324            // "not configured" from "skipped on purpose" apart, which is
325            // exactly why `e2e_status` exists rather than reading `e2e`
326            // directly here.
327            let e2e = match r.e2e_status() {
328                E2eStatus::NotConfigured => dim("no e2e"),
329                E2eStatus::Deferred => yellow(&format!(
330                    "e2e deferred{}",
331                    r.e2e_defer_reason
332                        .as_deref()
333                        .map(|why| format!(" ({why})"))
334                        .unwrap_or_default()
335                )),
336                E2eStatus::Passed => green("e2e green"),
337                E2eStatus::Failed if r.e2e.iter().any(CommandOutcome::build_failed) => {
338                    yellow("e2e could not run (build/link failure)")
339                }
340                E2eStatus::Failed => red("e2e RED"),
341            };
342            let e2e = if r.verify_retried {
343                format!("{e2e}, retried once")
344            } else {
345                e2e
346            };
347            // Three distinct facts, not two: a round can be *open* (blocking
348            // findings still standing), *incomplete* (a seat never answered,
349            // so what the round says is missing input) or genuinely clean.
350            let status = if r.incomplete() {
351                yellow("incomplete")
352            } else if r.clean {
353                green("clean")
354            } else {
355                yellow("open")
356            };
357            // A missing seat must stay visible even when `warn` policy let
358            // the round gate as clean: the reader should never have to take
359            // "clean" on faith when the panel wasn't full.
360            let panel = if r.incomplete() {
361                let missing: Vec<String> = r
362                    .reviews
363                    .iter()
364                    .filter_map(|x| {
365                        x.failed
366                            .as_ref()
367                            .map(|why| format!("review-{}: {why}", x.reviewer))
368                    })
369                    .collect();
370                format!(
371                    "  {}/{} reviewers answered ({})",
372                    r.answered,
373                    r.expected,
374                    missing.join(", ")
375                )
376            } else {
377                String::new()
378            };
379            // The verdict is the one thing this loop cannot derive from
380            // `blocking`/`e2e` alone: three seats can agree there is nothing
381            // blocking and still split on whether the patch is fine to
382            // proceed as-is, which is exactly the disagreement a vote exists
383            // to surface.
384            let verdict = r.verdict.map_or(String::new(), |v| {
385                format!(
386                    ", verdict {}{}",
387                    vote_tag(v),
388                    if r.vote_split { " (panel split)" } else { "" }
389                )
390            });
391            let _ = writeln!(
392                s,
393                "  round {}  {} @ {}{}{panel}  {raised} finding(s), {} blocking, {e2e}{verdict}{}",
394                r.round,
395                status,
396                short(&r.head),
397                r.verified_head.as_ref().map_or(String::new(), |head| {
398                    format!(" (verified @ {})", short(head))
399                }),
400                r.blocking,
401                r.fix.as_ref().map_or(String::new(), |f| {
402                    let tree = if r.progressed {
403                        green("changed")
404                    } else {
405                        yellow("unchanged")
406                    };
407                    match &f.failed {
408                        // Never the same shape as "N addressed / M rejected": the
409                        // fixer's diff may well have landed (see the `fix` node's
410                        // own event), but whether it addressed anything is
411                        // unknown, not zero.
412                        Some(reason) => format!(
413                            "  fix: {}, tree {tree}{}",
414                            yellow(&format!("adoption report lost ({reason})")),
415                            if f.committed {
416                                String::new()
417                            } else {
418                                red(" (NO COMMIT)")
419                            }
420                        ),
421                        None => format!(
422                            "  fix: {} addressed / {} rejected, tree {tree}{}",
423                            f.addressed.len(),
424                            f.rejected.len(),
425                            if f.committed {
426                                String::new()
427                            } else {
428                                red(" (NO COMMIT)")
429                            }
430                        ),
431                    }
432                })
433            );
434            for rec in &r.reviews {
435                if let Some(vote) = rec.vote {
436                    let _ = writeln!(s, "      review-{} vote {}", rec.reviewer, vote_tag(vote));
437                }
438                for f in &rec.findings {
439                    let adopted = r
440                        .fix
441                        .as_ref()
442                        .is_some_and(|fix| fix.addressed.contains(&f.id));
443                    let _ = writeln!(
444                        s,
445                        "      {} [{:?}] {}{}",
446                        dim(&f.id),
447                        f.severity,
448                        f.title,
449                        if adopted {
450                            green("  fixed")
451                        } else {
452                            String::new()
453                        }
454                    );
455                }
456            }
457            if let Some(fix) = &r.fix {
458                for rej in &fix.rejected {
459                    let _ = writeln!(
460                        s,
461                        "      {} {}: {}",
462                        dim(&rej.id),
463                        yellow("declined"),
464                        rej.why
465                    );
466                }
467            }
468            // Reconsideration only ever has entries when the round's initial
469            // votes split — an empty list here means the panel agreed the
470            // first time, same as an empty `deliberation` for judges.
471            if !r.reconsideration.is_empty() {
472                let _ = writeln!(s, "      {}", dim("reconsideration:"));
473                for rv in &r.reconsideration {
474                    match rv.vote {
475                        Some(v) => {
476                            let _ = writeln!(
477                                s,
478                                "        review-{} -> {}  {}",
479                                rv.reviewer,
480                                vote_tag(v),
481                                rv.reason
482                            );
483                        }
484                        None => {
485                            let _ = writeln!(
486                                s,
487                                "        review-{} -> {}",
488                                rv.reviewer,
489                                red(&format!(
490                                    "no revote ({})",
491                                    rv.failed.as_deref().unwrap_or("unknown")
492                                ))
493                            );
494                        }
495                    }
496                }
497            }
498        }
499        if state.handed_off_with_open_findings() {
500            let _ = writeln!(
501                s,
502                "\n  {}",
503                yellow(&format!(
504                    "handed off with {} finding(s) still open — gate and e2e were green; \
505                     see above for what a person should still look at",
506                    state.open_findings().len()
507                ))
508            );
509        }
510    }
511
512    if let Some(bs) = &state.base_sync {
513        let _ = writeln!(s, "\n{}", bold("base sync"));
514        let status = if let Some(c) = &bs.conflict {
515            red(&format!("conflict: {}", first_line(c)))
516        } else if bs.behind == 0 {
517            green("in sync")
518        } else {
519            yellow(&format!("{} commit(s) behind, not yet rebased", bs.behind))
520        };
521        let _ = writeln!(
522            s,
523            "  {} @ {}  {status}{}",
524            state.base_branch,
525            short(&bs.tip),
526            if bs.attempts > 0 {
527                format!("  ({} rebase attempt(s))", bs.attempts)
528            } else {
529                String::new()
530            }
531        );
532    }
533
534    if !state.gate.is_empty() {
535        let _ = writeln!(s, "\n{}", bold("gate"));
536        for o in &state.gate {
537            let _ = writeln!(
538                s,
539                "  {}  {}",
540                if o.ok() { green("pass") } else { red("FAIL") },
541                o.command
542            );
543            if !o.ok() {
544                let _ = writeln!(s, "{}", dim(&tail(&o.output_tail, 2_000)));
545            }
546        }
547    }
548
549    if let Some(m) = &state.merge {
550        let _ = writeln!(s, "\n{}", bold("merge"));
551        if m.mode == MergeMode::None {
552            // `ok: true` here means "magi did nothing, as configured", not
553            // "landed" — a green `ok` next to a shell command reads as done,
554            // and the branch is still sitting unmerged.
555            let _ = writeln!(
556                s,
557                "  mode None  {}",
558                cyan("not landed — nothing to do by design")
559            );
560            if let Some(w) = state.winner() {
561                let _ = writeln!(
562                    s,
563                    "  branch {} still exists, unmerged into {}",
564                    w.branch, state.base_branch
565                );
566            }
567            // The squash caveat only applies to that one style: `--no-ff` and
568            // `--ff-only` never inherit a candidate's placeholder subject,
569            // since neither ever discards the pull request body `message`
570            // that `manual_merge_command` (graph.rs) already puts on the
571            // squash commit's `-m`.
572            let _ = writeln!(
573                s,
574                "  rebase onto {} before merging by hand{}",
575                state.base_branch,
576                if state.config.merge.style == MergeStyle::Squash {
577                    ", and pass an explicit commit message — a squash merge \
578                     otherwise inherits the candidate's placeholder subject"
579                } else {
580                    ""
581                }
582            );
583            let _ = writeln!(s, "  {}", m.detail.lines().next().unwrap_or(""));
584        } else {
585            let _ = writeln!(
586                s,
587                "  mode {:?}  {}\n  {}",
588                m.mode,
589                if m.ok {
590                    green("ok")
591                } else {
592                    yellow("not merged")
593                },
594                m.detail.lines().next().unwrap_or("")
595            );
596        }
597    }
598
599    if !state.leaks.is_empty() {
600        let _ = writeln!(s, "\n{}", bold(&yellow("blindness warnings")));
601        for l in &state.leaks {
602            let _ = writeln!(s, "  {} x{} in {}", l.token, l.count, l.site);
603        }
604    }
605
606    if let Some(w) = state.winner()
607        && !w.folded
608    {
609        let _ = writeln!(
610            s,
611            "\n{} {}\n  branch {}",
612            bold("winner worktree"),
613            w.worktree.display(),
614            w.branch
615        );
616    }
617    s
618}
619
620/// The seats currently mid-answer, for `magi show` and the raw report route.
621///
622/// Separate from [`run`] on purpose: [`run`] is printed straight after `magi
623/// run` / `magi review`'s own `execute()`, and by then this process has
624/// nothing left in flight to report; the TUI does not track daemon liveness
625/// either. Only a caller reading someone *else's* run — `magi show <id>`, or
626/// the web UI's raw-report route — needs this, and both already know how to
627/// ask whether a daemon is currently driving it.
628///
629/// `live` is whether a daemon's heartbeat currently names this run
630/// (`daemon::is_working_on`). An [`ActiveSeat`](crate::run::ActiveSeat) left
631/// behind by a killed process is not lied about as running just because
632/// nobody has cleared it from disk yet — see that type's own docs for why an
633/// entry alone is not proof of anything.
634pub fn active_seats(state: &RunState, live: bool) -> String {
635    if state.active.is_empty() {
636        return String::new();
637    }
638    let mut s = String::new();
639    let _ = writeln!(s, "\n{}", bold("running now"));
640    if !live {
641        let _ = writeln!(
642            s,
643            "  {}",
644            yellow(
645                "no live daemon claims this run right now — likely left behind by a killed process"
646            )
647        );
648    }
649    let now = jiff::Timestamp::now();
650    for (seat, a) in &state.active {
651        let retry = if a.attempt > 0 {
652            format!(" retry {}", a.attempt)
653        } else {
654            String::new()
655        };
656        let _ = writeln!(
657            s,
658            "  {:<12} {:<12}{retry}  {}s elapsed, {}s left of {}s",
659            seat,
660            a.node,
661            a.elapsed_secs(now),
662            a.remaining_secs(now),
663            a.timeout_secs
664        );
665    }
666    s
667}
668
669/// Aggregate tables, for `magi stats`.
670pub fn stats(stats: &Stats) -> String {
671    let t = &stats.totals;
672    let mut s = String::new();
673    let _ = writeln!(s, "{}", bold("runs"));
674    let _ = writeln!(
675        s,
676        "  {} total - {} merged, {} ready, {} blocked, {} failed ({:.0}% completion)",
677        t.runs,
678        t.merged,
679        t.ready,
680        t.blocked,
681        t.failed,
682        t.completion_rate()
683    );
684    if t.tallied > 0 {
685        let _ = writeln!(
686            s,
687            "  {} tallied - {} split on first choice ({:.0}%), {} deliberated, \
688             {} of those changed a mind, {} converged to unanimous",
689            t.tallied,
690            t.split,
691            t.split_rate(),
692            t.deliberated,
693            t.minds_changed,
694            t.converged
695        );
696    }
697
698    if !stats.agents.is_empty() {
699        let _ = writeln!(
700            s,
701            "\n{}",
702            bold("implementation (relative, on this workload)")
703        );
704        let _ = writeln!(
705            s,
706            "  {:<14}{:>6}{:>8}{:>8}{:>8}",
707            "agent", "won", "entered", "rate", "empty"
708        );
709        for a in &stats.agents {
710            let _ = writeln!(
711                s,
712                "  {:<14}{:>6}{:>8}{:>7.0}%{:>8}",
713                a.agent,
714                a.wins,
715                a.entered,
716                a.win_rate(),
717                a.empty
718            );
719        }
720    }
721
722    if !stats.reviewers.is_empty() {
723        let _ = writeln!(s, "\n{}", bold("review"));
724        let _ = writeln!(
725            s,
726            "  {:<14}{:>8}{:>10}{:>11}{:>9}{:>9}{:>9}",
727            "reviewer", "rounds", "submitted", "adopted/rd", "precision", "unique", "timeout"
728        );
729        for r in &stats.reviewers {
730            let _ = writeln!(
731                s,
732                "  {:<14}{:>8}{:>10}{:>11.2}{:>8.0}%{:>8.0}%{:>8.0}%",
733                r.agent,
734                r.rounds,
735                r.submitted,
736                r.adopted_per_round(),
737                r.precision(),
738                r.unique_rate(),
739                r.timeout_rate()
740            );
741        }
742    }
743
744    if stats.e2e.rounds > 0 || stats.e2e.deferred > 0 {
745        let _ = writeln!(s, "\n{}", bold("verification"));
746        let _ = writeln!(
747            s,
748            "  {} rounds ran e2e, {} failed, {} of those with a clean static \
749             review ({:.0}% sole detections), {} round(s) deferred it to the fixer",
750            stats.e2e.rounds,
751            stats.e2e.failures,
752            stats.e2e.sole_detections,
753            stats.e2e.sole_rate(),
754            stats.e2e.deferred
755        );
756    }
757    s
758}
759
760#[cfg(test)]
761mod tests {
762    use super::*;
763    use crate::config::Config;
764    use crate::run::{
765        Candidate, CommandOutcome, FixRecord, MergeOutcome, ReviewRecord, ReviewRound, RunState,
766        Tally,
767    };
768    use std::collections::BTreeMap;
769    use std::path::PathBuf;
770    use std::sync::{Mutex, MutexGuard};
771
772    /// `COLOR` is process-global, so these tests cannot run concurrently.
773    static SERIAL: Mutex<()> = Mutex::new(());
774
775    fn plain() -> MutexGuard<'static, ()> {
776        let guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
777        set_color(false);
778        guard
779    }
780
781    fn state() -> RunState {
782        // `run()` prints `state.dir()`, which reads the process-global home;
783        // pinning it here keeps this test off the operator's real one. The
784        // directory itself is never read, only its path printed, so nothing
785        // needs to create or clean it up.
786        crate::run::set_home(std::env::temp_dir().join("magi-report-test-home"));
787        let mut s = RunState::new(
788            PathBuf::from("/repo"),
789            "main".to_owned(),
790            "abcdef1234".to_owned(),
791            "add retries to the uploader".to_owned(),
792            Config::default(),
793        );
794        s.candidates = vec![Candidate {
795            index: 0,
796            label: 'A',
797            agent: "opus".to_owned(),
798            branch: "magi/x/A".to_owned(),
799            worktree: PathBuf::from("/wt/A"),
800            summary: String::new(),
801            stat: String::new(),
802            files: 3,
803            commits: 2,
804            empty: false,
805            failed: None,
806            duration_ms: 42_000,
807            folded: false,
808        }];
809        s.tally = Some(Tally {
810            first_choice: BTreeMap::from([('A', 3)]),
811            borda: BTreeMap::new(),
812            winner: 'A',
813            rankings: 3,
814            unanimous_initial: true,
815            deliberated: false,
816            changed_votes: 0,
817            unanimous_final: true,
818            tie_break: None,
819            judges: 3,
820            present: 3,
821            quorum: 2,
822            met_quorum: true,
823            uncontested: None,
824        });
825        s
826    }
827
828    #[test]
829    fn run_report_names_the_winner_and_its_author() {
830        let _guard = plain();
831        let text = run(&state());
832        assert!(text.contains("<- winner"), "{text}");
833        assert!(text.contains("opus"));
834        assert!(text.contains("3 files, 2 commits"));
835        assert!(text.contains("winner        A"));
836        assert!(!text.contains('\x1b'), "colour leaked into a plain render");
837    }
838
839    #[test]
840    fn colour_is_emitted_only_when_enabled() {
841        let _guard = plain();
842        set_color(true);
843        let coloured = run(&state());
844        set_color(false);
845        let plain = run(&state());
846        assert!(coloured.contains('\x1b'));
847        assert!(!plain.contains('\x1b'));
848        assert!(coloured.len() > plain.len());
849    }
850
851    #[test]
852    fn list_line_is_single_line() {
853        let _guard = plain();
854        let l = line(&state());
855        assert_eq!(l.lines().count(), 1);
856        assert!(l.contains("add retries"));
857        assert!(l.contains("win A (opus)"));
858    }
859
860    #[test]
861    fn an_uncontested_run_does_not_read_as_a_collapsed_panel() {
862        let _guard = plain();
863        let mut s = state();
864        s.tally = Some(Tally {
865            first_choice: BTreeMap::from([('A', 0)]),
866            borda: BTreeMap::new(),
867            winner: 'A',
868            rankings: 0,
869            unanimous_initial: false,
870            deliberated: false,
871            changed_votes: 0,
872            unanimous_final: false,
873            tie_break: None,
874            judges: 0,
875            present: 0,
876            quorum: 0,
877            met_quorum: true,
878            uncontested: Some(
879                "only candidate A produced a usable change; no panel was asked".to_owned(),
880            ),
881        });
882        let text = run(&s);
883        assert!(
884            !text.contains("0/3"),
885            "no panel sat, so the judges line must not read as one that collapsed: {text}"
886        );
887        assert!(!text.contains("no usable ranking"), "{text}");
888        assert!(!text.contains("still split"), "{text}");
889        assert!(!text.contains("BELOW QUORUM"), "{text}");
890        assert!(
891            text.contains("not needed"),
892            "the report must say judging was skipped, not silent: {text}"
893        );
894        assert!(text.contains("winner        A"));
895    }
896
897    #[test]
898    fn a_below_quorum_run_still_reads_as_a_collapsed_panel() {
899        let _guard = plain();
900        let mut s = state();
901        s.tally = Some(Tally {
902            first_choice: BTreeMap::from([('A', 1), ('B', 0)]),
903            borda: BTreeMap::new(),
904            winner: 'A',
905            rankings: 1,
906            unanimous_initial: false,
907            deliberated: false,
908            changed_votes: 0,
909            unanimous_final: false,
910            tie_break: None,
911            judges: 3,
912            present: 1,
913            quorum: 2,
914            met_quorum: false,
915            uncontested: None,
916        });
917        let text = run(&s);
918        assert!(text.contains("1/3"), "{text}");
919        assert!(
920            text.contains("BELOW QUORUM"),
921            "a real collapse must still be flagged: {text}"
922        );
923        assert!(
924            !text.contains("not needed"),
925            "a collapsed panel must not be described as one that was never asked: {text}"
926        );
927    }
928
929    #[test]
930    fn a_mode_none_merge_does_not_read_as_landed() {
931        let _guard = plain();
932        let mut s = state();
933        s.merge = Some(MergeOutcome {
934            mode: crate::config::MergeMode::None,
935            ok: true,
936            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
937        });
938        let text = run(&s);
939        assert!(
940            !text.contains("  ok"),
941            "mode none must not be shown as a landed merge: {text}"
942        );
943        assert!(text.contains("not landed"), "{text}");
944        assert!(
945            text.contains("branch magi/x/A"),
946            "the report must say what's left behind: {text}"
947        );
948        assert!(
949            text.contains("rebase"),
950            "the report must point at the hand-landing steps: {text}"
951        );
952        assert!(
953            !text.contains("placeholder subject"),
954            "the default merge style is `merge`, which never inherits a \
955             placeholder subject, so the squash caveat must not appear: {text}"
956        );
957    }
958
959    #[test]
960    fn a_mode_none_squash_merge_warns_about_the_placeholder_subject() {
961        let _guard = plain();
962        let mut s = state();
963        s.config.merge.style = MergeStyle::Squash;
964        s.merge = Some(MergeOutcome {
965            mode: crate::config::MergeMode::None,
966            ok: true,
967            detail: "git -C /repo merge --squash magi/x/A && git -C /repo commit -m \"add \
968                      retries\""
969                .to_owned(),
970        });
971        let text = run(&s);
972        assert!(
973            text.contains("placeholder subject"),
974            "a squash-style manual merge must warn about the missing message: {text}"
975        );
976        assert!(text.contains("--squash"), "{text}");
977    }
978
979    #[test]
980    fn the_list_line_does_not_flag_an_uncontested_run_as_short_judges() {
981        let _guard = plain();
982        let mut s = state();
983        s.tally = Some(Tally {
984            first_choice: BTreeMap::from([('A', 0)]),
985            borda: BTreeMap::new(),
986            winner: 'A',
987            rankings: 0,
988            unanimous_initial: false,
989            deliberated: false,
990            changed_votes: 0,
991            unanimous_final: false,
992            tie_break: None,
993            judges: 0,
994            present: 0,
995            quorum: 0,
996            met_quorum: true,
997            uncontested: Some("only candidate A produced a usable change".to_owned()),
998        });
999        let l = line(&s);
1000        assert!(
1001            !l.contains("judges") && !l.contains("quorum"),
1002            "an uncontested run must not carry the same badge a short panel gets: {l}"
1003        );
1004    }
1005
1006    #[test]
1007    fn long_instructions_are_elided() {
1008        let _guard = plain();
1009        let mut s = state();
1010        s.instruction = "x".repeat(200);
1011        assert!(line(&s).contains('…'));
1012    }
1013
1014    #[test]
1015    fn a_lost_fix_report_reads_differently_from_zero_adoption() {
1016        let _guard = plain();
1017        let mut lost = state();
1018        lost.reviews = vec![ReviewRound {
1019            round: 1,
1020            head: "abc1234".to_owned(),
1021            verified_head: None,
1022            reviews: Vec::new(),
1023            e2e: Vec::new(),
1024            verify_retried: false,
1025            e2e_deferred: false,
1026            e2e_defer_reason: None,
1027            fix: Some(FixRecord {
1028                agent: "opus".to_owned(),
1029                addressed: Vec::new(),
1030                rejected: Vec::new(),
1031                notes: String::new(),
1032                committed: true,
1033                failed: Some("timed out".to_owned()),
1034                duration_ms: 0,
1035            }),
1036            blocking: 3,
1037            answered: 0,
1038            expected: 0,
1039            clean: false,
1040            progressed: false,
1041            vote_split: false,
1042            reconsideration: Vec::new(),
1043            verdict: None,
1044        }];
1045        let text = run(&lost);
1046        assert!(text.contains("adoption report lost (timed out)"), "{text}");
1047        assert!(
1048            !text.contains("0 addressed"),
1049            "a lost report must never read as `0 addressed`: {text}"
1050        );
1051
1052        let mut rejected_all = state();
1053        rejected_all.reviews = vec![ReviewRound {
1054            round: 1,
1055            head: "abc1234".to_owned(),
1056            verified_head: None,
1057            reviews: Vec::new(),
1058            e2e: Vec::new(),
1059            verify_retried: false,
1060            e2e_deferred: false,
1061            e2e_defer_reason: None,
1062            fix: Some(FixRecord {
1063                agent: "opus".to_owned(),
1064                addressed: Vec::new(),
1065                rejected: Vec::new(),
1066                notes: String::new(),
1067                committed: true,
1068                failed: None,
1069                duration_ms: 0,
1070            }),
1071            blocking: 3,
1072            answered: 0,
1073            expected: 0,
1074            clean: false,
1075            progressed: false,
1076            vote_split: false,
1077            reconsideration: Vec::new(),
1078            verdict: None,
1079        }];
1080        let text2 = run(&rejected_all);
1081        assert!(
1082            text2.contains("0 addressed / 0 rejected"),
1083            "a round the fixer actually reported on keeps the count: {text2}"
1084        );
1085    }
1086
1087    #[test]
1088    fn a_split_round_shows_every_seat_vote_and_the_reconsideration() {
1089        use crate::run::ReviewRevoteRecord;
1090        use crate::verdict::ReviewVote;
1091
1092        let _guard = plain();
1093        let mut s = state();
1094        s.reviews = vec![ReviewRound {
1095            round: 1,
1096            head: "abc1234".to_owned(),
1097            verified_head: None,
1098            reviews: vec![
1099                ReviewRecord {
1100                    reviewer: 1,
1101                    agent: "alpha".to_owned(),
1102                    summary: String::new(),
1103                    findings: Vec::new(),
1104                    vote: Some(ReviewVote::Approve),
1105                    failed: None,
1106                    duration_ms: 0,
1107                },
1108                ReviewRecord {
1109                    reviewer: 2,
1110                    agent: "beta".to_owned(),
1111                    summary: String::new(),
1112                    findings: Vec::new(),
1113                    vote: Some(ReviewVote::Reject),
1114                    failed: None,
1115                    duration_ms: 0,
1116                },
1117            ],
1118            e2e: Vec::new(),
1119            verify_retried: false,
1120            e2e_deferred: false,
1121            e2e_defer_reason: None,
1122            fix: None,
1123            blocking: 0,
1124            answered: 2,
1125            expected: 2,
1126            clean: false,
1127            progressed: false,
1128            vote_split: true,
1129            reconsideration: vec![ReviewRevoteRecord {
1130                reviewer: 2,
1131                agent: "beta".to_owned(),
1132                vote: Some(ReviewVote::ApproveWithFindings),
1133                reason: "the other seat's read holds up".to_owned(),
1134                failed: None,
1135            }],
1136            verdict: Some(ReviewVote::ApproveWithFindings),
1137        }];
1138        let text = run(&s);
1139        assert!(text.contains("review-1 vote"), "{text}");
1140        assert!(text.contains("review-2 vote"), "{text}");
1141        assert!(text.contains("panel split"), "{text}");
1142        assert!(text.contains("reconsideration"), "{text}");
1143        assert!(text.contains("the other seat's read holds up"), "{text}");
1144    }
1145
1146    #[test]
1147    fn an_incomplete_panel_and_a_lost_fix_report_both_stay_on_the_round_line() {
1148        // Two independent facts share this one line, and each arrived from a
1149        // different change: a seat that never answered, and a fixer whose
1150        // adoption report was lost. Rendering either must not shadow the
1151        // other, and neither may collapse into the plain `clean`/`open`
1152        // pair the line used to carry.
1153        let _guard = plain();
1154        let mut s = state();
1155        s.reviews = vec![ReviewRound {
1156            round: 1,
1157            head: "abc1234".to_owned(),
1158            verified_head: None,
1159            reviews: vec![
1160                ReviewRecord {
1161                    reviewer: 1,
1162                    agent: "alpha".to_owned(),
1163                    summary: String::new(),
1164                    findings: Vec::new(),
1165                    vote: None,
1166                    failed: None,
1167                    duration_ms: 0,
1168                },
1169                ReviewRecord {
1170                    reviewer: 2,
1171                    agent: "beta".to_owned(),
1172                    summary: String::new(),
1173                    findings: Vec::new(),
1174                    vote: None,
1175                    failed: Some("agent timed out".to_owned()),
1176                    duration_ms: 0,
1177                },
1178            ],
1179            e2e: Vec::new(),
1180            verify_retried: false,
1181            e2e_deferred: false,
1182            e2e_defer_reason: None,
1183            fix: Some(FixRecord {
1184                agent: "opus".to_owned(),
1185                addressed: Vec::new(),
1186                rejected: Vec::new(),
1187                notes: String::new(),
1188                committed: true,
1189                failed: Some("timed out".to_owned()),
1190                duration_ms: 0,
1191            }),
1192            blocking: 0,
1193            answered: 1,
1194            expected: 2,
1195            clean: false,
1196            progressed: true,
1197            vote_split: false,
1198            reconsideration: Vec::new(),
1199            verdict: None,
1200        }];
1201        let text = run(&s);
1202        assert!(text.contains("incomplete"), "{text}");
1203        assert!(text.contains("1/2 reviewers answered"), "{text}");
1204        assert!(text.contains("review-2: agent timed out"), "{text}");
1205        assert!(text.contains("adoption report lost (timed out)"), "{text}");
1206        assert!(
1207            !text.contains("clean"),
1208            "a round missing half its panel must never render as clean: {text}"
1209        );
1210    }
1211
1212    #[test]
1213    fn a_build_failure_is_not_reported_as_a_test_failure() {
1214        let _guard = plain();
1215        let mut s = state();
1216        s.reviews = vec![ReviewRound {
1217            round: 1,
1218            head: "abc1234".to_owned(),
1219            verified_head: None,
1220            reviews: Vec::new(),
1221            e2e: vec![CommandOutcome {
1222                command: "cargo test".to_owned(),
1223                code: Some(1),
1224                output_tail: "LINK : fatal error LNK1104: cannot open file".to_owned(),
1225                duration_ms: 100,
1226            }],
1227            verify_retried: true,
1228            e2e_deferred: false,
1229            e2e_defer_reason: None,
1230            fix: None,
1231            blocking: 0,
1232            answered: 0,
1233            expected: 0,
1234            clean: false,
1235            progressed: false,
1236            vote_split: false,
1237            reconsideration: Vec::new(),
1238            verdict: None,
1239        }];
1240        let text = run(&s);
1241        assert!(text.contains("could not run"), "{text}");
1242        assert!(text.contains("retried once"), "{text}");
1243        assert!(!text.contains("e2e RED"), "{text}");
1244    }
1245
1246    #[test]
1247    fn a_declined_finding_shows_its_reason() {
1248        use crate::verdict::{Finding, Rejection, Severity};
1249
1250        let _guard = plain();
1251        let mut s = state();
1252        s.status = RunStatus::Ready;
1253        s.reviews = vec![ReviewRound {
1254            round: 1,
1255            head: "deadbee".to_owned(),
1256            verified_head: None,
1257            reviews: vec![ReviewRecord {
1258                reviewer: 1,
1259                agent: "alpha".to_owned(),
1260                summary: String::new(),
1261                findings: vec![Finding {
1262                    id: "R1-1-1".to_owned(),
1263                    severity: Severity::Major,
1264                    file: None,
1265                    line: None,
1266                    title: "still open".to_owned(),
1267                    detail: String::new(),
1268                }],
1269                vote: None,
1270                failed: None,
1271                duration_ms: 0,
1272            }],
1273            e2e: vec![CommandOutcome {
1274                command: "cargo test".to_owned(),
1275                code: Some(0),
1276                output_tail: String::new(),
1277                duration_ms: 0,
1278            }],
1279            verify_retried: false,
1280            e2e_deferred: false,
1281            e2e_defer_reason: None,
1282            fix: Some(FixRecord {
1283                agent: "alpha".to_owned(),
1284                addressed: Vec::new(),
1285                rejected: vec![Rejection {
1286                    id: "R1-1-2".to_owned(),
1287                    why: "cannot be triggered from any caller".to_owned(),
1288                }],
1289                notes: String::new(),
1290                committed: true,
1291                failed: None,
1292                duration_ms: 0,
1293            }),
1294            blocking: 1,
1295            answered: 1,
1296            expected: 1,
1297            clean: false,
1298            progressed: true,
1299            vote_split: false,
1300            reconsideration: Vec::new(),
1301            verdict: None,
1302        }];
1303
1304        let text = run(&s);
1305        assert!(text.contains("R1-1-2"), "{text}");
1306        assert!(text.contains("cannot be triggered"), "{text}");
1307        assert!(text.contains("still open"), "{text}");
1308        assert!(
1309            text.contains("handed off"),
1310            "a mergeable run with an open round must say so: {text}"
1311        );
1312    }
1313
1314    #[test]
1315    fn a_failing_gate_command_shows_its_output() {
1316        let _guard = plain();
1317        let mut s = state();
1318        s.status = RunStatus::Blocked;
1319        s.gate = vec![CommandOutcome {
1320            command: "cargo make check".to_owned(),
1321            code: Some(101),
1322            output_tail: "error[E0308]: mismatched types".to_owned(),
1323            duration_ms: 0,
1324        }];
1325
1326        let text = run(&s);
1327        assert!(text.contains("mismatched types"), "{text}");
1328    }
1329
1330    #[test]
1331    fn active_seats_shows_who_has_not_answered_and_how_long_is_left() {
1332        let _guard = plain();
1333        let mut s = state();
1334        s.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
1335        let text = active_seats(&s, true);
1336        assert!(text.contains("running now"));
1337        assert!(text.contains("judge-2"));
1338        assert!(text.contains("judge"));
1339        assert!(!text.contains("no live daemon"), "{text}");
1340    }
1341
1342    #[test]
1343    fn active_seats_flags_a_leftover_from_a_dead_process() {
1344        let _guard = plain();
1345        let mut s = state();
1346        s.seat_started("implement", "impl-B", std::time::Duration::from_secs(60), 0);
1347        let text = active_seats(&s, false);
1348        assert!(
1349            text.contains("no live daemon"),
1350            "a stale entry must not read as running: {text}"
1351        );
1352    }
1353
1354    #[test]
1355    fn active_seats_is_empty_when_nothing_is_running() {
1356        let _guard = plain();
1357        assert_eq!(active_seats(&state(), true), "");
1358    }
1359
1360    #[test]
1361    fn stats_table_renders_without_runs() {
1362        let _guard = plain();
1363        let text = stats(&Stats::default());
1364        assert!(text.contains("0 total"));
1365        assert!(!text.contains("implementation"));
1366    }
1367}