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, 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.
323            let e2e = if r.e2e.is_empty() {
324                dim("no e2e")
325            } else if r.e2e.iter().all(|o| o.ok()) {
326                green("e2e green")
327            } else if r.e2e.iter().any(CommandOutcome::build_failed) {
328                yellow("e2e could not run (build/link failure)")
329            } else {
330                red("e2e RED")
331            };
332            let e2e = if r.verify_retried {
333                format!("{e2e}, retried once")
334            } else {
335                e2e
336            };
337            // Three distinct facts, not two: a round can be *open* (blocking
338            // findings still standing), *incomplete* (a seat never answered,
339            // so what the round says is missing input) or genuinely clean.
340            let status = if r.incomplete() {
341                yellow("incomplete")
342            } else if r.clean {
343                green("clean")
344            } else {
345                yellow("open")
346            };
347            // A missing seat must stay visible even when `warn` policy let
348            // the round gate as clean: the reader should never have to take
349            // "clean" on faith when the panel wasn't full.
350            let panel = if r.incomplete() {
351                let missing: Vec<String> = r
352                    .reviews
353                    .iter()
354                    .filter_map(|x| {
355                        x.failed
356                            .as_ref()
357                            .map(|why| format!("review-{}: {why}", x.reviewer))
358                    })
359                    .collect();
360                format!(
361                    "  {}/{} reviewers answered ({})",
362                    r.answered,
363                    r.expected,
364                    missing.join(", ")
365                )
366            } else {
367                String::new()
368            };
369            // The verdict is the one thing this loop cannot derive from
370            // `blocking`/`e2e` alone: three seats can agree there is nothing
371            // blocking and still split on whether the patch is fine to
372            // proceed as-is, which is exactly the disagreement a vote exists
373            // to surface.
374            let verdict = r.verdict.map_or(String::new(), |v| {
375                format!(
376                    ", verdict {}{}",
377                    vote_tag(v),
378                    if r.vote_split { " (panel split)" } else { "" }
379                )
380            });
381            let _ = writeln!(
382                s,
383                "  round {}  {} @ {}{panel}  {raised} finding(s), {} blocking, {e2e}{verdict}{}",
384                r.round,
385                status,
386                short(&r.head),
387                r.blocking,
388                r.fix.as_ref().map_or(String::new(), |f| {
389                    let tree = if r.progressed {
390                        green("changed")
391                    } else {
392                        yellow("unchanged")
393                    };
394                    match &f.failed {
395                        // Never the same shape as "N addressed / M rejected": the
396                        // fixer's diff may well have landed (see the `fix` node's
397                        // own event), but whether it addressed anything is
398                        // unknown, not zero.
399                        Some(reason) => format!(
400                            "  fix: {}, tree {tree}{}",
401                            yellow(&format!("adoption report lost ({reason})")),
402                            if f.committed {
403                                String::new()
404                            } else {
405                                red(" (NO COMMIT)")
406                            }
407                        ),
408                        None => format!(
409                            "  fix: {} addressed / {} rejected, tree {tree}{}",
410                            f.addressed.len(),
411                            f.rejected.len(),
412                            if f.committed {
413                                String::new()
414                            } else {
415                                red(" (NO COMMIT)")
416                            }
417                        ),
418                    }
419                })
420            );
421            for rec in &r.reviews {
422                if let Some(vote) = rec.vote {
423                    let _ = writeln!(s, "      review-{} vote {}", rec.reviewer, vote_tag(vote));
424                }
425                for f in &rec.findings {
426                    let adopted = r
427                        .fix
428                        .as_ref()
429                        .is_some_and(|fix| fix.addressed.contains(&f.id));
430                    let _ = writeln!(
431                        s,
432                        "      {} [{:?}] {}{}",
433                        dim(&f.id),
434                        f.severity,
435                        f.title,
436                        if adopted {
437                            green("  fixed")
438                        } else {
439                            String::new()
440                        }
441                    );
442                }
443            }
444            if let Some(fix) = &r.fix {
445                for rej in &fix.rejected {
446                    let _ = writeln!(
447                        s,
448                        "      {} {}: {}",
449                        dim(&rej.id),
450                        yellow("declined"),
451                        rej.why
452                    );
453                }
454            }
455            // Reconsideration only ever has entries when the round's initial
456            // votes split — an empty list here means the panel agreed the
457            // first time, same as an empty `deliberation` for judges.
458            if !r.reconsideration.is_empty() {
459                let _ = writeln!(s, "      {}", dim("reconsideration:"));
460                for rv in &r.reconsideration {
461                    match rv.vote {
462                        Some(v) => {
463                            let _ = writeln!(
464                                s,
465                                "        review-{} -> {}  {}",
466                                rv.reviewer,
467                                vote_tag(v),
468                                rv.reason
469                            );
470                        }
471                        None => {
472                            let _ = writeln!(
473                                s,
474                                "        review-{} -> {}",
475                                rv.reviewer,
476                                red(&format!(
477                                    "no revote ({})",
478                                    rv.failed.as_deref().unwrap_or("unknown")
479                                ))
480                            );
481                        }
482                    }
483                }
484            }
485        }
486        if state.handed_off_with_open_findings() {
487            let _ = writeln!(
488                s,
489                "\n  {}",
490                yellow(&format!(
491                    "handed off with {} finding(s) still open — gate and e2e were green; \
492                     see above for what a person should still look at",
493                    state.open_findings().len()
494                ))
495            );
496        }
497    }
498
499    if let Some(bs) = &state.base_sync {
500        let _ = writeln!(s, "\n{}", bold("base sync"));
501        let status = if let Some(c) = &bs.conflict {
502            red(&format!("conflict: {}", first_line(c)))
503        } else if bs.behind == 0 {
504            green("in sync")
505        } else {
506            yellow(&format!("{} commit(s) behind, not yet rebased", bs.behind))
507        };
508        let _ = writeln!(
509            s,
510            "  {} @ {}  {status}{}",
511            state.base_branch,
512            short(&bs.tip),
513            if bs.attempts > 0 {
514                format!("  ({} rebase attempt(s))", bs.attempts)
515            } else {
516                String::new()
517            }
518        );
519    }
520
521    if !state.gate.is_empty() {
522        let _ = writeln!(s, "\n{}", bold("gate"));
523        for o in &state.gate {
524            let _ = writeln!(
525                s,
526                "  {}  {}",
527                if o.ok() { green("pass") } else { red("FAIL") },
528                o.command
529            );
530            if !o.ok() {
531                let _ = writeln!(s, "{}", dim(&tail(&o.output_tail, 2_000)));
532            }
533        }
534    }
535
536    if let Some(m) = &state.merge {
537        let _ = writeln!(s, "\n{}", bold("merge"));
538        if m.mode == MergeMode::None {
539            // `ok: true` here means "magi did nothing, as configured", not
540            // "landed" — a green `ok` next to a shell command reads as done,
541            // and the branch is still sitting unmerged.
542            let _ = writeln!(
543                s,
544                "  mode None  {}",
545                cyan("not landed — nothing to do by design")
546            );
547            if let Some(w) = state.winner() {
548                let _ = writeln!(
549                    s,
550                    "  branch {} still exists, unmerged into {}",
551                    w.branch, state.base_branch
552                );
553            }
554            // The squash caveat only applies to that one style: `--no-ff` and
555            // `--ff-only` never inherit a candidate's placeholder subject,
556            // since neither ever discards the pull request body `message`
557            // that `manual_merge_command` (graph.rs) already puts on the
558            // squash commit's `-m`.
559            let _ = writeln!(
560                s,
561                "  rebase onto {} before merging by hand{}",
562                state.base_branch,
563                if state.config.merge.style == MergeStyle::Squash {
564                    ", and pass an explicit commit message — a squash merge \
565                     otherwise inherits the candidate's placeholder subject"
566                } else {
567                    ""
568                }
569            );
570            let _ = writeln!(s, "  {}", m.detail.lines().next().unwrap_or(""));
571        } else {
572            let _ = writeln!(
573                s,
574                "  mode {:?}  {}\n  {}",
575                m.mode,
576                if m.ok {
577                    green("ok")
578                } else {
579                    yellow("not merged")
580                },
581                m.detail.lines().next().unwrap_or("")
582            );
583        }
584    }
585
586    if !state.leaks.is_empty() {
587        let _ = writeln!(s, "\n{}", bold(&yellow("blindness warnings")));
588        for l in &state.leaks {
589            let _ = writeln!(s, "  {} x{} in {}", l.token, l.count, l.site);
590        }
591    }
592
593    if let Some(w) = state.winner()
594        && !w.folded
595    {
596        let _ = writeln!(
597            s,
598            "\n{} {}\n  branch {}",
599            bold("winner worktree"),
600            w.worktree.display(),
601            w.branch
602        );
603    }
604    s
605}
606
607/// The seats currently mid-answer, for `magi show` and the raw report route.
608///
609/// Separate from [`run`] on purpose: [`run`] is printed straight after `magi
610/// run` / `magi review`'s own `execute()`, and by then this process has
611/// nothing left in flight to report; the TUI does not track daemon liveness
612/// either. Only a caller reading someone *else's* run — `magi show <id>`, or
613/// the web UI's raw-report route — needs this, and both already know how to
614/// ask whether a daemon is currently driving it.
615///
616/// `live` is whether a daemon's heartbeat currently names this run
617/// (`daemon::is_working_on`). An [`ActiveSeat`](crate::run::ActiveSeat) left
618/// behind by a killed process is not lied about as running just because
619/// nobody has cleared it from disk yet — see that type's own docs for why an
620/// entry alone is not proof of anything.
621pub fn active_seats(state: &RunState, live: bool) -> String {
622    if state.active.is_empty() {
623        return String::new();
624    }
625    let mut s = String::new();
626    let _ = writeln!(s, "\n{}", bold("running now"));
627    if !live {
628        let _ = writeln!(
629            s,
630            "  {}",
631            yellow(
632                "no live daemon claims this run right now — likely left behind by a killed process"
633            )
634        );
635    }
636    let now = jiff::Timestamp::now();
637    for (seat, a) in &state.active {
638        let retry = if a.attempt > 0 {
639            format!(" retry {}", a.attempt)
640        } else {
641            String::new()
642        };
643        let _ = writeln!(
644            s,
645            "  {:<12} {:<12}{retry}  {}s elapsed, {}s left of {}s",
646            seat,
647            a.node,
648            a.elapsed_secs(now),
649            a.remaining_secs(now),
650            a.timeout_secs
651        );
652    }
653    s
654}
655
656/// Aggregate tables, for `magi stats`.
657pub fn stats(stats: &Stats) -> String {
658    let t = &stats.totals;
659    let mut s = String::new();
660    let _ = writeln!(s, "{}", bold("runs"));
661    let _ = writeln!(
662        s,
663        "  {} total - {} merged, {} ready, {} blocked, {} failed ({:.0}% completion)",
664        t.runs,
665        t.merged,
666        t.ready,
667        t.blocked,
668        t.failed,
669        t.completion_rate()
670    );
671    if t.tallied > 0 {
672        let _ = writeln!(
673            s,
674            "  {} tallied - {} split on first choice ({:.0}%), {} deliberated, \
675             {} of those changed a mind, {} converged to unanimous",
676            t.tallied,
677            t.split,
678            t.split_rate(),
679            t.deliberated,
680            t.minds_changed,
681            t.converged
682        );
683    }
684
685    if !stats.agents.is_empty() {
686        let _ = writeln!(
687            s,
688            "\n{}",
689            bold("implementation (relative, on this workload)")
690        );
691        let _ = writeln!(
692            s,
693            "  {:<14}{:>6}{:>8}{:>8}{:>8}",
694            "agent", "won", "entered", "rate", "empty"
695        );
696        for a in &stats.agents {
697            let _ = writeln!(
698                s,
699                "  {:<14}{:>6}{:>8}{:>7.0}%{:>8}",
700                a.agent,
701                a.wins,
702                a.entered,
703                a.win_rate(),
704                a.empty
705            );
706        }
707    }
708
709    if !stats.reviewers.is_empty() {
710        let _ = writeln!(s, "\n{}", bold("review"));
711        let _ = writeln!(
712            s,
713            "  {:<14}{:>8}{:>10}{:>11}{:>9}{:>9}{:>9}",
714            "reviewer", "rounds", "submitted", "adopted/rd", "precision", "unique", "timeout"
715        );
716        for r in &stats.reviewers {
717            let _ = writeln!(
718                s,
719                "  {:<14}{:>8}{:>10}{:>11.2}{:>8.0}%{:>8.0}%{:>8.0}%",
720                r.agent,
721                r.rounds,
722                r.submitted,
723                r.adopted_per_round(),
724                r.precision(),
725                r.unique_rate(),
726                r.timeout_rate()
727            );
728        }
729    }
730
731    if stats.e2e.rounds > 0 {
732        let _ = writeln!(s, "\n{}", bold("verification"));
733        let _ = writeln!(
734            s,
735            "  {} rounds ran e2e, {} failed, {} of those with a clean static \
736             review ({:.0}% sole detections)",
737            stats.e2e.rounds,
738            stats.e2e.failures,
739            stats.e2e.sole_detections,
740            stats.e2e.sole_rate()
741        );
742    }
743    s
744}
745
746#[cfg(test)]
747mod tests {
748    use super::*;
749    use crate::config::Config;
750    use crate::run::{
751        Candidate, CommandOutcome, FixRecord, MergeOutcome, ReviewRecord, ReviewRound, RunState,
752        Tally,
753    };
754    use std::collections::BTreeMap;
755    use std::path::PathBuf;
756    use std::sync::{Mutex, MutexGuard};
757
758    /// `COLOR` is process-global, so these tests cannot run concurrently.
759    static SERIAL: Mutex<()> = Mutex::new(());
760
761    fn plain() -> MutexGuard<'static, ()> {
762        let guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
763        set_color(false);
764        guard
765    }
766
767    fn state() -> RunState {
768        // `run()` prints `state.dir()`, which reads the process-global home;
769        // pinning it here keeps this test off the operator's real one. The
770        // directory itself is never read, only its path printed, so nothing
771        // needs to create or clean it up.
772        crate::run::set_home(std::env::temp_dir().join("magi-report-test-home"));
773        let mut s = RunState::new(
774            PathBuf::from("/repo"),
775            "main".to_owned(),
776            "abcdef1234".to_owned(),
777            "add retries to the uploader".to_owned(),
778            Config::default(),
779        );
780        s.candidates = vec![Candidate {
781            index: 0,
782            label: 'A',
783            agent: "opus".to_owned(),
784            branch: "magi/x/A".to_owned(),
785            worktree: PathBuf::from("/wt/A"),
786            summary: String::new(),
787            stat: String::new(),
788            files: 3,
789            commits: 2,
790            empty: false,
791            failed: None,
792            duration_ms: 42_000,
793            folded: false,
794        }];
795        s.tally = Some(Tally {
796            first_choice: BTreeMap::from([('A', 3)]),
797            borda: BTreeMap::new(),
798            winner: 'A',
799            rankings: 3,
800            unanimous_initial: true,
801            deliberated: false,
802            changed_votes: 0,
803            unanimous_final: true,
804            tie_break: None,
805            judges: 3,
806            present: 3,
807            quorum: 2,
808            met_quorum: true,
809            uncontested: None,
810        });
811        s
812    }
813
814    #[test]
815    fn run_report_names_the_winner_and_its_author() {
816        let _guard = plain();
817        let text = run(&state());
818        assert!(text.contains("<- winner"), "{text}");
819        assert!(text.contains("opus"));
820        assert!(text.contains("3 files, 2 commits"));
821        assert!(text.contains("winner        A"));
822        assert!(!text.contains('\x1b'), "colour leaked into a plain render");
823    }
824
825    #[test]
826    fn colour_is_emitted_only_when_enabled() {
827        let _guard = plain();
828        set_color(true);
829        let coloured = run(&state());
830        set_color(false);
831        let plain = run(&state());
832        assert!(coloured.contains('\x1b'));
833        assert!(!plain.contains('\x1b'));
834        assert!(coloured.len() > plain.len());
835    }
836
837    #[test]
838    fn list_line_is_single_line() {
839        let _guard = plain();
840        let l = line(&state());
841        assert_eq!(l.lines().count(), 1);
842        assert!(l.contains("add retries"));
843        assert!(l.contains("win A (opus)"));
844    }
845
846    #[test]
847    fn an_uncontested_run_does_not_read_as_a_collapsed_panel() {
848        let _guard = plain();
849        let mut s = state();
850        s.tally = Some(Tally {
851            first_choice: BTreeMap::from([('A', 0)]),
852            borda: BTreeMap::new(),
853            winner: 'A',
854            rankings: 0,
855            unanimous_initial: false,
856            deliberated: false,
857            changed_votes: 0,
858            unanimous_final: false,
859            tie_break: None,
860            judges: 0,
861            present: 0,
862            quorum: 0,
863            met_quorum: true,
864            uncontested: Some(
865                "only candidate A produced a usable change; no panel was asked".to_owned(),
866            ),
867        });
868        let text = run(&s);
869        assert!(
870            !text.contains("0/3"),
871            "no panel sat, so the judges line must not read as one that collapsed: {text}"
872        );
873        assert!(!text.contains("no usable ranking"), "{text}");
874        assert!(!text.contains("still split"), "{text}");
875        assert!(!text.contains("BELOW QUORUM"), "{text}");
876        assert!(
877            text.contains("not needed"),
878            "the report must say judging was skipped, not silent: {text}"
879        );
880        assert!(text.contains("winner        A"));
881    }
882
883    #[test]
884    fn a_below_quorum_run_still_reads_as_a_collapsed_panel() {
885        let _guard = plain();
886        let mut s = state();
887        s.tally = Some(Tally {
888            first_choice: BTreeMap::from([('A', 1), ('B', 0)]),
889            borda: BTreeMap::new(),
890            winner: 'A',
891            rankings: 1,
892            unanimous_initial: false,
893            deliberated: false,
894            changed_votes: 0,
895            unanimous_final: false,
896            tie_break: None,
897            judges: 3,
898            present: 1,
899            quorum: 2,
900            met_quorum: false,
901            uncontested: None,
902        });
903        let text = run(&s);
904        assert!(text.contains("1/3"), "{text}");
905        assert!(
906            text.contains("BELOW QUORUM"),
907            "a real collapse must still be flagged: {text}"
908        );
909        assert!(
910            !text.contains("not needed"),
911            "a collapsed panel must not be described as one that was never asked: {text}"
912        );
913    }
914
915    #[test]
916    fn a_mode_none_merge_does_not_read_as_landed() {
917        let _guard = plain();
918        let mut s = state();
919        s.merge = Some(MergeOutcome {
920            mode: crate::config::MergeMode::None,
921            ok: true,
922            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
923        });
924        let text = run(&s);
925        assert!(
926            !text.contains("  ok"),
927            "mode none must not be shown as a landed merge: {text}"
928        );
929        assert!(text.contains("not landed"), "{text}");
930        assert!(
931            text.contains("branch magi/x/A"),
932            "the report must say what's left behind: {text}"
933        );
934        assert!(
935            text.contains("rebase"),
936            "the report must point at the hand-landing steps: {text}"
937        );
938        assert!(
939            !text.contains("placeholder subject"),
940            "the default merge style is `merge`, which never inherits a \
941             placeholder subject, so the squash caveat must not appear: {text}"
942        );
943    }
944
945    #[test]
946    fn a_mode_none_squash_merge_warns_about_the_placeholder_subject() {
947        let _guard = plain();
948        let mut s = state();
949        s.config.merge.style = MergeStyle::Squash;
950        s.merge = Some(MergeOutcome {
951            mode: crate::config::MergeMode::None,
952            ok: true,
953            detail: "git -C /repo merge --squash magi/x/A && git -C /repo commit -m \"add \
954                      retries\""
955                .to_owned(),
956        });
957        let text = run(&s);
958        assert!(
959            text.contains("placeholder subject"),
960            "a squash-style manual merge must warn about the missing message: {text}"
961        );
962        assert!(text.contains("--squash"), "{text}");
963    }
964
965    #[test]
966    fn the_list_line_does_not_flag_an_uncontested_run_as_short_judges() {
967        let _guard = plain();
968        let mut s = state();
969        s.tally = Some(Tally {
970            first_choice: BTreeMap::from([('A', 0)]),
971            borda: BTreeMap::new(),
972            winner: 'A',
973            rankings: 0,
974            unanimous_initial: false,
975            deliberated: false,
976            changed_votes: 0,
977            unanimous_final: false,
978            tie_break: None,
979            judges: 0,
980            present: 0,
981            quorum: 0,
982            met_quorum: true,
983            uncontested: Some("only candidate A produced a usable change".to_owned()),
984        });
985        let l = line(&s);
986        assert!(
987            !l.contains("judges") && !l.contains("quorum"),
988            "an uncontested run must not carry the same badge a short panel gets: {l}"
989        );
990    }
991
992    #[test]
993    fn long_instructions_are_elided() {
994        let _guard = plain();
995        let mut s = state();
996        s.instruction = "x".repeat(200);
997        assert!(line(&s).contains('…'));
998    }
999
1000    #[test]
1001    fn a_lost_fix_report_reads_differently_from_zero_adoption() {
1002        let _guard = plain();
1003        let mut lost = state();
1004        lost.reviews = vec![ReviewRound {
1005            round: 1,
1006            head: "abc1234".to_owned(),
1007            reviews: Vec::new(),
1008            e2e: Vec::new(),
1009            verify_retried: false,
1010            fix: Some(FixRecord {
1011                agent: "opus".to_owned(),
1012                addressed: Vec::new(),
1013                rejected: Vec::new(),
1014                notes: String::new(),
1015                committed: true,
1016                failed: Some("timed out".to_owned()),
1017                duration_ms: 0,
1018            }),
1019            blocking: 3,
1020            answered: 0,
1021            expected: 0,
1022            clean: false,
1023            progressed: false,
1024            vote_split: false,
1025            reconsideration: Vec::new(),
1026            verdict: None,
1027        }];
1028        let text = run(&lost);
1029        assert!(text.contains("adoption report lost (timed out)"), "{text}");
1030        assert!(
1031            !text.contains("0 addressed"),
1032            "a lost report must never read as `0 addressed`: {text}"
1033        );
1034
1035        let mut rejected_all = state();
1036        rejected_all.reviews = vec![ReviewRound {
1037            round: 1,
1038            head: "abc1234".to_owned(),
1039            reviews: Vec::new(),
1040            e2e: Vec::new(),
1041            verify_retried: false,
1042            fix: Some(FixRecord {
1043                agent: "opus".to_owned(),
1044                addressed: Vec::new(),
1045                rejected: Vec::new(),
1046                notes: String::new(),
1047                committed: true,
1048                failed: None,
1049                duration_ms: 0,
1050            }),
1051            blocking: 3,
1052            answered: 0,
1053            expected: 0,
1054            clean: false,
1055            progressed: false,
1056            vote_split: false,
1057            reconsideration: Vec::new(),
1058            verdict: None,
1059        }];
1060        let text2 = run(&rejected_all);
1061        assert!(
1062            text2.contains("0 addressed / 0 rejected"),
1063            "a round the fixer actually reported on keeps the count: {text2}"
1064        );
1065    }
1066
1067    #[test]
1068    fn a_split_round_shows_every_seat_vote_and_the_reconsideration() {
1069        use crate::run::ReviewRevoteRecord;
1070        use crate::verdict::ReviewVote;
1071
1072        let _guard = plain();
1073        let mut s = state();
1074        s.reviews = vec![ReviewRound {
1075            round: 1,
1076            head: "abc1234".to_owned(),
1077            reviews: vec![
1078                ReviewRecord {
1079                    reviewer: 1,
1080                    agent: "alpha".to_owned(),
1081                    summary: String::new(),
1082                    findings: Vec::new(),
1083                    vote: Some(ReviewVote::Approve),
1084                    failed: None,
1085                    duration_ms: 0,
1086                },
1087                ReviewRecord {
1088                    reviewer: 2,
1089                    agent: "beta".to_owned(),
1090                    summary: String::new(),
1091                    findings: Vec::new(),
1092                    vote: Some(ReviewVote::Reject),
1093                    failed: None,
1094                    duration_ms: 0,
1095                },
1096            ],
1097            e2e: Vec::new(),
1098            verify_retried: false,
1099            fix: None,
1100            blocking: 0,
1101            answered: 2,
1102            expected: 2,
1103            clean: false,
1104            progressed: false,
1105            vote_split: true,
1106            reconsideration: vec![ReviewRevoteRecord {
1107                reviewer: 2,
1108                agent: "beta".to_owned(),
1109                vote: Some(ReviewVote::ApproveWithFindings),
1110                reason: "the other seat's read holds up".to_owned(),
1111                failed: None,
1112            }],
1113            verdict: Some(ReviewVote::ApproveWithFindings),
1114        }];
1115        let text = run(&s);
1116        assert!(text.contains("review-1 vote"), "{text}");
1117        assert!(text.contains("review-2 vote"), "{text}");
1118        assert!(text.contains("panel split"), "{text}");
1119        assert!(text.contains("reconsideration"), "{text}");
1120        assert!(text.contains("the other seat's read holds up"), "{text}");
1121    }
1122
1123    #[test]
1124    fn an_incomplete_panel_and_a_lost_fix_report_both_stay_on_the_round_line() {
1125        // Two independent facts share this one line, and each arrived from a
1126        // different change: a seat that never answered, and a fixer whose
1127        // adoption report was lost. Rendering either must not shadow the
1128        // other, and neither may collapse into the plain `clean`/`open`
1129        // pair the line used to carry.
1130        let _guard = plain();
1131        let mut s = state();
1132        s.reviews = vec![ReviewRound {
1133            round: 1,
1134            head: "abc1234".to_owned(),
1135            reviews: vec![
1136                ReviewRecord {
1137                    reviewer: 1,
1138                    agent: "alpha".to_owned(),
1139                    summary: String::new(),
1140                    findings: Vec::new(),
1141                    vote: None,
1142                    failed: None,
1143                    duration_ms: 0,
1144                },
1145                ReviewRecord {
1146                    reviewer: 2,
1147                    agent: "beta".to_owned(),
1148                    summary: String::new(),
1149                    findings: Vec::new(),
1150                    vote: None,
1151                    failed: Some("agent timed out".to_owned()),
1152                    duration_ms: 0,
1153                },
1154            ],
1155            e2e: Vec::new(),
1156            verify_retried: false,
1157            fix: Some(FixRecord {
1158                agent: "opus".to_owned(),
1159                addressed: Vec::new(),
1160                rejected: Vec::new(),
1161                notes: String::new(),
1162                committed: true,
1163                failed: Some("timed out".to_owned()),
1164                duration_ms: 0,
1165            }),
1166            blocking: 0,
1167            answered: 1,
1168            expected: 2,
1169            clean: false,
1170            progressed: true,
1171            vote_split: false,
1172            reconsideration: Vec::new(),
1173            verdict: None,
1174        }];
1175        let text = run(&s);
1176        assert!(text.contains("incomplete"), "{text}");
1177        assert!(text.contains("1/2 reviewers answered"), "{text}");
1178        assert!(text.contains("review-2: agent timed out"), "{text}");
1179        assert!(text.contains("adoption report lost (timed out)"), "{text}");
1180        assert!(
1181            !text.contains("clean"),
1182            "a round missing half its panel must never render as clean: {text}"
1183        );
1184    }
1185
1186    #[test]
1187    fn a_build_failure_is_not_reported_as_a_test_failure() {
1188        let _guard = plain();
1189        let mut s = state();
1190        s.reviews = vec![ReviewRound {
1191            round: 1,
1192            head: "abc1234".to_owned(),
1193            reviews: Vec::new(),
1194            e2e: vec![CommandOutcome {
1195                command: "cargo test".to_owned(),
1196                code: Some(1),
1197                output_tail: "LINK : fatal error LNK1104: cannot open file".to_owned(),
1198                duration_ms: 100,
1199            }],
1200            verify_retried: true,
1201            fix: None,
1202            blocking: 0,
1203            answered: 0,
1204            expected: 0,
1205            clean: false,
1206            progressed: false,
1207            vote_split: false,
1208            reconsideration: Vec::new(),
1209            verdict: None,
1210        }];
1211        let text = run(&s);
1212        assert!(text.contains("could not run"), "{text}");
1213        assert!(text.contains("retried once"), "{text}");
1214        assert!(!text.contains("e2e RED"), "{text}");
1215    }
1216
1217    #[test]
1218    fn a_declined_finding_shows_its_reason() {
1219        use crate::verdict::{Finding, Rejection, Severity};
1220
1221        let _guard = plain();
1222        let mut s = state();
1223        s.status = RunStatus::Ready;
1224        s.reviews = vec![ReviewRound {
1225            round: 1,
1226            head: "deadbee".to_owned(),
1227            reviews: vec![ReviewRecord {
1228                reviewer: 1,
1229                agent: "alpha".to_owned(),
1230                summary: String::new(),
1231                findings: vec![Finding {
1232                    id: "R1-1-1".to_owned(),
1233                    severity: Severity::Major,
1234                    file: None,
1235                    line: None,
1236                    title: "still open".to_owned(),
1237                    detail: String::new(),
1238                }],
1239                vote: None,
1240                failed: None,
1241                duration_ms: 0,
1242            }],
1243            e2e: vec![CommandOutcome {
1244                command: "cargo test".to_owned(),
1245                code: Some(0),
1246                output_tail: String::new(),
1247                duration_ms: 0,
1248            }],
1249            verify_retried: false,
1250            fix: Some(FixRecord {
1251                agent: "alpha".to_owned(),
1252                addressed: Vec::new(),
1253                rejected: vec![Rejection {
1254                    id: "R1-1-2".to_owned(),
1255                    why: "cannot be triggered from any caller".to_owned(),
1256                }],
1257                notes: String::new(),
1258                committed: true,
1259                failed: None,
1260                duration_ms: 0,
1261            }),
1262            blocking: 1,
1263            answered: 1,
1264            expected: 1,
1265            clean: false,
1266            progressed: true,
1267            vote_split: false,
1268            reconsideration: Vec::new(),
1269            verdict: None,
1270        }];
1271
1272        let text = run(&s);
1273        assert!(text.contains("R1-1-2"), "{text}");
1274        assert!(text.contains("cannot be triggered"), "{text}");
1275        assert!(text.contains("still open"), "{text}");
1276        assert!(
1277            text.contains("handed off"),
1278            "a mergeable run with an open round must say so: {text}"
1279        );
1280    }
1281
1282    #[test]
1283    fn a_failing_gate_command_shows_its_output() {
1284        let _guard = plain();
1285        let mut s = state();
1286        s.status = RunStatus::Blocked;
1287        s.gate = vec![CommandOutcome {
1288            command: "cargo make check".to_owned(),
1289            code: Some(101),
1290            output_tail: "error[E0308]: mismatched types".to_owned(),
1291            duration_ms: 0,
1292        }];
1293
1294        let text = run(&s);
1295        assert!(text.contains("mismatched types"), "{text}");
1296    }
1297
1298    #[test]
1299    fn active_seats_shows_who_has_not_answered_and_how_long_is_left() {
1300        let _guard = plain();
1301        let mut s = state();
1302        s.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
1303        let text = active_seats(&s, true);
1304        assert!(text.contains("running now"));
1305        assert!(text.contains("judge-2"));
1306        assert!(text.contains("judge"));
1307        assert!(!text.contains("no live daemon"), "{text}");
1308    }
1309
1310    #[test]
1311    fn active_seats_flags_a_leftover_from_a_dead_process() {
1312        let _guard = plain();
1313        let mut s = state();
1314        s.seat_started("implement", "impl-B", std::time::Duration::from_secs(60), 0);
1315        let text = active_seats(&s, false);
1316        assert!(
1317            text.contains("no live daemon"),
1318            "a stale entry must not read as running: {text}"
1319        );
1320    }
1321
1322    #[test]
1323    fn active_seats_is_empty_when_nothing_is_running() {
1324        let _guard = plain();
1325        assert_eq!(active_seats(&state(), true), "");
1326    }
1327
1328    #[test]
1329    fn stats_table_renders_without_runs() {
1330        let _guard = plain();
1331        let text = stats(&Stats::default());
1332        assert!(text.contains("0 total"));
1333        assert!(!text.contains("implementation"));
1334    }
1335}