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;
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            let _ = writeln!(
555                s,
556                "  rebase onto {} before merging by hand, and pass an explicit \
557                 commit message — a squash merge otherwise inherits the \
558                 candidate's placeholder subject",
559                state.base_branch
560            );
561            let _ = writeln!(s, "  {}", m.detail.lines().next().unwrap_or(""));
562        } else {
563            let _ = writeln!(
564                s,
565                "  mode {:?}  {}\n  {}",
566                m.mode,
567                if m.ok {
568                    green("ok")
569                } else {
570                    yellow("not merged")
571                },
572                m.detail.lines().next().unwrap_or("")
573            );
574        }
575    }
576
577    if !state.leaks.is_empty() {
578        let _ = writeln!(s, "\n{}", bold(&yellow("blindness warnings")));
579        for l in &state.leaks {
580            let _ = writeln!(s, "  {} x{} in {}", l.token, l.count, l.site);
581        }
582    }
583
584    if let Some(w) = state.winner()
585        && !w.folded
586    {
587        let _ = writeln!(
588            s,
589            "\n{} {}\n  branch {}",
590            bold("winner worktree"),
591            w.worktree.display(),
592            w.branch
593        );
594    }
595    s
596}
597
598/// The seats currently mid-answer, for `magi show` and the raw report route.
599///
600/// Separate from [`run`] on purpose: [`run`] is printed straight after `magi
601/// run` / `magi review`'s own `execute()`, and by then this process has
602/// nothing left in flight to report; the TUI does not track daemon liveness
603/// either. Only a caller reading someone *else's* run — `magi show <id>`, or
604/// the web UI's raw-report route — needs this, and both already know how to
605/// ask whether a daemon is currently driving it.
606///
607/// `live` is whether a daemon's heartbeat currently names this run
608/// (`daemon::is_working_on`). An [`ActiveSeat`](crate::run::ActiveSeat) left
609/// behind by a killed process is not lied about as running just because
610/// nobody has cleared it from disk yet — see that type's own docs for why an
611/// entry alone is not proof of anything.
612pub fn active_seats(state: &RunState, live: bool) -> String {
613    if state.active.is_empty() {
614        return String::new();
615    }
616    let mut s = String::new();
617    let _ = writeln!(s, "\n{}", bold("running now"));
618    if !live {
619        let _ = writeln!(
620            s,
621            "  {}",
622            yellow(
623                "no live daemon claims this run right now — likely left behind by a killed process"
624            )
625        );
626    }
627    let now = jiff::Timestamp::now();
628    for (seat, a) in &state.active {
629        let retry = if a.attempt > 0 {
630            format!(" retry {}", a.attempt)
631        } else {
632            String::new()
633        };
634        let _ = writeln!(
635            s,
636            "  {:<12} {:<12}{retry}  {}s elapsed, {}s left of {}s",
637            seat,
638            a.node,
639            a.elapsed_secs(now),
640            a.remaining_secs(now),
641            a.timeout_secs
642        );
643    }
644    s
645}
646
647/// Aggregate tables, for `magi stats`.
648pub fn stats(stats: &Stats) -> String {
649    let t = &stats.totals;
650    let mut s = String::new();
651    let _ = writeln!(s, "{}", bold("runs"));
652    let _ = writeln!(
653        s,
654        "  {} total - {} merged, {} ready, {} blocked, {} failed ({:.0}% completion)",
655        t.runs,
656        t.merged,
657        t.ready,
658        t.blocked,
659        t.failed,
660        t.completion_rate()
661    );
662    if t.tallied > 0 {
663        let _ = writeln!(
664            s,
665            "  {} tallied - {} split on first choice ({:.0}%), {} deliberated, \
666             {} of those changed a mind, {} converged to unanimous",
667            t.tallied,
668            t.split,
669            t.split_rate(),
670            t.deliberated,
671            t.minds_changed,
672            t.converged
673        );
674    }
675
676    if !stats.agents.is_empty() {
677        let _ = writeln!(
678            s,
679            "\n{}",
680            bold("implementation (relative, on this workload)")
681        );
682        let _ = writeln!(
683            s,
684            "  {:<14}{:>6}{:>8}{:>8}{:>8}",
685            "agent", "won", "entered", "rate", "empty"
686        );
687        for a in &stats.agents {
688            let _ = writeln!(
689                s,
690                "  {:<14}{:>6}{:>8}{:>7.0}%{:>8}",
691                a.agent,
692                a.wins,
693                a.entered,
694                a.win_rate(),
695                a.empty
696            );
697        }
698    }
699
700    if !stats.reviewers.is_empty() {
701        let _ = writeln!(s, "\n{}", bold("review"));
702        let _ = writeln!(
703            s,
704            "  {:<14}{:>8}{:>10}{:>11}{:>9}{:>9}{:>9}",
705            "reviewer", "rounds", "submitted", "adopted/rd", "precision", "unique", "timeout"
706        );
707        for r in &stats.reviewers {
708            let _ = writeln!(
709                s,
710                "  {:<14}{:>8}{:>10}{:>11.2}{:>8.0}%{:>8.0}%{:>8.0}%",
711                r.agent,
712                r.rounds,
713                r.submitted,
714                r.adopted_per_round(),
715                r.precision(),
716                r.unique_rate(),
717                r.timeout_rate()
718            );
719        }
720    }
721
722    if stats.e2e.rounds > 0 {
723        let _ = writeln!(s, "\n{}", bold("verification"));
724        let _ = writeln!(
725            s,
726            "  {} rounds ran e2e, {} failed, {} of those with a clean static \
727             review ({:.0}% sole detections)",
728            stats.e2e.rounds,
729            stats.e2e.failures,
730            stats.e2e.sole_detections,
731            stats.e2e.sole_rate()
732        );
733    }
734    s
735}
736
737#[cfg(test)]
738mod tests {
739    use super::*;
740    use crate::config::Config;
741    use crate::run::{
742        Candidate, CommandOutcome, FixRecord, MergeOutcome, ReviewRecord, ReviewRound, RunState,
743        Tally,
744    };
745    use std::collections::BTreeMap;
746    use std::path::PathBuf;
747    use std::sync::{Mutex, MutexGuard};
748
749    /// `COLOR` is process-global, so these tests cannot run concurrently.
750    static SERIAL: Mutex<()> = Mutex::new(());
751
752    fn plain() -> MutexGuard<'static, ()> {
753        let guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
754        set_color(false);
755        guard
756    }
757
758    fn state() -> RunState {
759        // `run()` prints `state.dir()`, which reads the process-global home;
760        // pinning it here keeps this test off the operator's real one. The
761        // directory itself is never read, only its path printed, so nothing
762        // needs to create or clean it up.
763        crate::run::set_home(std::env::temp_dir().join("magi-report-test-home"));
764        let mut s = RunState::new(
765            PathBuf::from("/repo"),
766            "main".to_owned(),
767            "abcdef1234".to_owned(),
768            "add retries to the uploader".to_owned(),
769            Config::default(),
770        );
771        s.candidates = vec![Candidate {
772            index: 0,
773            label: 'A',
774            agent: "opus".to_owned(),
775            branch: "magi/x/A".to_owned(),
776            worktree: PathBuf::from("/wt/A"),
777            summary: String::new(),
778            stat: String::new(),
779            files: 3,
780            commits: 2,
781            empty: false,
782            failed: None,
783            duration_ms: 42_000,
784            folded: false,
785        }];
786        s.tally = Some(Tally {
787            first_choice: BTreeMap::from([('A', 3)]),
788            borda: BTreeMap::new(),
789            winner: 'A',
790            rankings: 3,
791            unanimous_initial: true,
792            deliberated: false,
793            changed_votes: 0,
794            unanimous_final: true,
795            tie_break: None,
796            judges: 3,
797            present: 3,
798            quorum: 2,
799            met_quorum: true,
800            uncontested: None,
801        });
802        s
803    }
804
805    #[test]
806    fn run_report_names_the_winner_and_its_author() {
807        let _guard = plain();
808        let text = run(&state());
809        assert!(text.contains("<- winner"), "{text}");
810        assert!(text.contains("opus"));
811        assert!(text.contains("3 files, 2 commits"));
812        assert!(text.contains("winner        A"));
813        assert!(!text.contains('\x1b'), "colour leaked into a plain render");
814    }
815
816    #[test]
817    fn colour_is_emitted_only_when_enabled() {
818        let _guard = plain();
819        set_color(true);
820        let coloured = run(&state());
821        set_color(false);
822        let plain = run(&state());
823        assert!(coloured.contains('\x1b'));
824        assert!(!plain.contains('\x1b'));
825        assert!(coloured.len() > plain.len());
826    }
827
828    #[test]
829    fn list_line_is_single_line() {
830        let _guard = plain();
831        let l = line(&state());
832        assert_eq!(l.lines().count(), 1);
833        assert!(l.contains("add retries"));
834        assert!(l.contains("win A (opus)"));
835    }
836
837    #[test]
838    fn an_uncontested_run_does_not_read_as_a_collapsed_panel() {
839        let _guard = plain();
840        let mut s = state();
841        s.tally = Some(Tally {
842            first_choice: BTreeMap::from([('A', 0)]),
843            borda: BTreeMap::new(),
844            winner: 'A',
845            rankings: 0,
846            unanimous_initial: false,
847            deliberated: false,
848            changed_votes: 0,
849            unanimous_final: false,
850            tie_break: None,
851            judges: 0,
852            present: 0,
853            quorum: 0,
854            met_quorum: true,
855            uncontested: Some(
856                "only candidate A produced a usable change; no panel was asked".to_owned(),
857            ),
858        });
859        let text = run(&s);
860        assert!(
861            !text.contains("0/3"),
862            "no panel sat, so the judges line must not read as one that collapsed: {text}"
863        );
864        assert!(!text.contains("no usable ranking"), "{text}");
865        assert!(!text.contains("still split"), "{text}");
866        assert!(!text.contains("BELOW QUORUM"), "{text}");
867        assert!(
868            text.contains("not needed"),
869            "the report must say judging was skipped, not silent: {text}"
870        );
871        assert!(text.contains("winner        A"));
872    }
873
874    #[test]
875    fn a_below_quorum_run_still_reads_as_a_collapsed_panel() {
876        let _guard = plain();
877        let mut s = state();
878        s.tally = Some(Tally {
879            first_choice: BTreeMap::from([('A', 1), ('B', 0)]),
880            borda: BTreeMap::new(),
881            winner: 'A',
882            rankings: 1,
883            unanimous_initial: false,
884            deliberated: false,
885            changed_votes: 0,
886            unanimous_final: false,
887            tie_break: None,
888            judges: 3,
889            present: 1,
890            quorum: 2,
891            met_quorum: false,
892            uncontested: None,
893        });
894        let text = run(&s);
895        assert!(text.contains("1/3"), "{text}");
896        assert!(
897            text.contains("BELOW QUORUM"),
898            "a real collapse must still be flagged: {text}"
899        );
900        assert!(
901            !text.contains("not needed"),
902            "a collapsed panel must not be described as one that was never asked: {text}"
903        );
904    }
905
906    #[test]
907    fn a_mode_none_merge_does_not_read_as_landed() {
908        let _guard = plain();
909        let mut s = state();
910        s.merge = Some(MergeOutcome {
911            mode: crate::config::MergeMode::None,
912            ok: true,
913            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
914        });
915        let text = run(&s);
916        assert!(
917            !text.contains("  ok"),
918            "mode none must not be shown as a landed merge: {text}"
919        );
920        assert!(text.contains("not landed"), "{text}");
921        assert!(
922            text.contains("branch magi/x/A"),
923            "the report must say what's left behind: {text}"
924        );
925        assert!(
926            text.contains("rebase"),
927            "the report must point at the hand-landing steps: {text}"
928        );
929    }
930
931    #[test]
932    fn the_list_line_does_not_flag_an_uncontested_run_as_short_judges() {
933        let _guard = plain();
934        let mut s = state();
935        s.tally = Some(Tally {
936            first_choice: BTreeMap::from([('A', 0)]),
937            borda: BTreeMap::new(),
938            winner: 'A',
939            rankings: 0,
940            unanimous_initial: false,
941            deliberated: false,
942            changed_votes: 0,
943            unanimous_final: false,
944            tie_break: None,
945            judges: 0,
946            present: 0,
947            quorum: 0,
948            met_quorum: true,
949            uncontested: Some("only candidate A produced a usable change".to_owned()),
950        });
951        let l = line(&s);
952        assert!(
953            !l.contains("judges") && !l.contains("quorum"),
954            "an uncontested run must not carry the same badge a short panel gets: {l}"
955        );
956    }
957
958    #[test]
959    fn long_instructions_are_elided() {
960        let _guard = plain();
961        let mut s = state();
962        s.instruction = "x".repeat(200);
963        assert!(line(&s).contains('…'));
964    }
965
966    #[test]
967    fn a_lost_fix_report_reads_differently_from_zero_adoption() {
968        let _guard = plain();
969        let mut lost = state();
970        lost.reviews = vec![ReviewRound {
971            round: 1,
972            head: "abc1234".to_owned(),
973            reviews: Vec::new(),
974            e2e: Vec::new(),
975            verify_retried: false,
976            fix: Some(FixRecord {
977                agent: "opus".to_owned(),
978                addressed: Vec::new(),
979                rejected: Vec::new(),
980                notes: String::new(),
981                committed: true,
982                failed: Some("timed out".to_owned()),
983                duration_ms: 0,
984            }),
985            blocking: 3,
986            answered: 0,
987            expected: 0,
988            clean: false,
989            progressed: false,
990            vote_split: false,
991            reconsideration: Vec::new(),
992            verdict: None,
993        }];
994        let text = run(&lost);
995        assert!(text.contains("adoption report lost (timed out)"), "{text}");
996        assert!(
997            !text.contains("0 addressed"),
998            "a lost report must never read as `0 addressed`: {text}"
999        );
1000
1001        let mut rejected_all = state();
1002        rejected_all.reviews = vec![ReviewRound {
1003            round: 1,
1004            head: "abc1234".to_owned(),
1005            reviews: Vec::new(),
1006            e2e: Vec::new(),
1007            verify_retried: false,
1008            fix: Some(FixRecord {
1009                agent: "opus".to_owned(),
1010                addressed: Vec::new(),
1011                rejected: Vec::new(),
1012                notes: String::new(),
1013                committed: true,
1014                failed: None,
1015                duration_ms: 0,
1016            }),
1017            blocking: 3,
1018            answered: 0,
1019            expected: 0,
1020            clean: false,
1021            progressed: false,
1022            vote_split: false,
1023            reconsideration: Vec::new(),
1024            verdict: None,
1025        }];
1026        let text2 = run(&rejected_all);
1027        assert!(
1028            text2.contains("0 addressed / 0 rejected"),
1029            "a round the fixer actually reported on keeps the count: {text2}"
1030        );
1031    }
1032
1033    #[test]
1034    fn a_split_round_shows_every_seat_vote_and_the_reconsideration() {
1035        use crate::run::ReviewRevoteRecord;
1036        use crate::verdict::ReviewVote;
1037
1038        let _guard = plain();
1039        let mut s = state();
1040        s.reviews = vec![ReviewRound {
1041            round: 1,
1042            head: "abc1234".to_owned(),
1043            reviews: vec![
1044                ReviewRecord {
1045                    reviewer: 1,
1046                    agent: "alpha".to_owned(),
1047                    summary: String::new(),
1048                    findings: Vec::new(),
1049                    vote: Some(ReviewVote::Approve),
1050                    failed: None,
1051                    duration_ms: 0,
1052                },
1053                ReviewRecord {
1054                    reviewer: 2,
1055                    agent: "beta".to_owned(),
1056                    summary: String::new(),
1057                    findings: Vec::new(),
1058                    vote: Some(ReviewVote::Reject),
1059                    failed: None,
1060                    duration_ms: 0,
1061                },
1062            ],
1063            e2e: Vec::new(),
1064            verify_retried: false,
1065            fix: None,
1066            blocking: 0,
1067            answered: 2,
1068            expected: 2,
1069            clean: false,
1070            progressed: false,
1071            vote_split: true,
1072            reconsideration: vec![ReviewRevoteRecord {
1073                reviewer: 2,
1074                agent: "beta".to_owned(),
1075                vote: Some(ReviewVote::ApproveWithFindings),
1076                reason: "the other seat's read holds up".to_owned(),
1077                failed: None,
1078            }],
1079            verdict: Some(ReviewVote::ApproveWithFindings),
1080        }];
1081        let text = run(&s);
1082        assert!(text.contains("review-1 vote"), "{text}");
1083        assert!(text.contains("review-2 vote"), "{text}");
1084        assert!(text.contains("panel split"), "{text}");
1085        assert!(text.contains("reconsideration"), "{text}");
1086        assert!(text.contains("the other seat's read holds up"), "{text}");
1087    }
1088
1089    #[test]
1090    fn an_incomplete_panel_and_a_lost_fix_report_both_stay_on_the_round_line() {
1091        // Two independent facts share this one line, and each arrived from a
1092        // different change: a seat that never answered, and a fixer whose
1093        // adoption report was lost. Rendering either must not shadow the
1094        // other, and neither may collapse into the plain `clean`/`open`
1095        // pair the line used to carry.
1096        let _guard = plain();
1097        let mut s = state();
1098        s.reviews = vec![ReviewRound {
1099            round: 1,
1100            head: "abc1234".to_owned(),
1101            reviews: vec![
1102                ReviewRecord {
1103                    reviewer: 1,
1104                    agent: "alpha".to_owned(),
1105                    summary: String::new(),
1106                    findings: Vec::new(),
1107                    vote: None,
1108                    failed: None,
1109                    duration_ms: 0,
1110                },
1111                ReviewRecord {
1112                    reviewer: 2,
1113                    agent: "beta".to_owned(),
1114                    summary: String::new(),
1115                    findings: Vec::new(),
1116                    vote: None,
1117                    failed: Some("agent timed out".to_owned()),
1118                    duration_ms: 0,
1119                },
1120            ],
1121            e2e: Vec::new(),
1122            verify_retried: false,
1123            fix: Some(FixRecord {
1124                agent: "opus".to_owned(),
1125                addressed: Vec::new(),
1126                rejected: Vec::new(),
1127                notes: String::new(),
1128                committed: true,
1129                failed: Some("timed out".to_owned()),
1130                duration_ms: 0,
1131            }),
1132            blocking: 0,
1133            answered: 1,
1134            expected: 2,
1135            clean: false,
1136            progressed: true,
1137            vote_split: false,
1138            reconsideration: Vec::new(),
1139            verdict: None,
1140        }];
1141        let text = run(&s);
1142        assert!(text.contains("incomplete"), "{text}");
1143        assert!(text.contains("1/2 reviewers answered"), "{text}");
1144        assert!(text.contains("review-2: agent timed out"), "{text}");
1145        assert!(text.contains("adoption report lost (timed out)"), "{text}");
1146        assert!(
1147            !text.contains("clean"),
1148            "a round missing half its panel must never render as clean: {text}"
1149        );
1150    }
1151
1152    #[test]
1153    fn a_build_failure_is_not_reported_as_a_test_failure() {
1154        let _guard = plain();
1155        let mut s = state();
1156        s.reviews = vec![ReviewRound {
1157            round: 1,
1158            head: "abc1234".to_owned(),
1159            reviews: Vec::new(),
1160            e2e: vec![CommandOutcome {
1161                command: "cargo test".to_owned(),
1162                code: Some(1),
1163                output_tail: "LINK : fatal error LNK1104: cannot open file".to_owned(),
1164                duration_ms: 100,
1165            }],
1166            verify_retried: true,
1167            fix: None,
1168            blocking: 0,
1169            answered: 0,
1170            expected: 0,
1171            clean: false,
1172            progressed: false,
1173            vote_split: false,
1174            reconsideration: Vec::new(),
1175            verdict: None,
1176        }];
1177        let text = run(&s);
1178        assert!(text.contains("could not run"), "{text}");
1179        assert!(text.contains("retried once"), "{text}");
1180        assert!(!text.contains("e2e RED"), "{text}");
1181    }
1182
1183    #[test]
1184    fn a_declined_finding_shows_its_reason() {
1185        use crate::verdict::{Finding, Rejection, Severity};
1186
1187        let _guard = plain();
1188        let mut s = state();
1189        s.status = RunStatus::Ready;
1190        s.reviews = vec![ReviewRound {
1191            round: 1,
1192            head: "deadbee".to_owned(),
1193            reviews: vec![ReviewRecord {
1194                reviewer: 1,
1195                agent: "alpha".to_owned(),
1196                summary: String::new(),
1197                findings: vec![Finding {
1198                    id: "R1-1-1".to_owned(),
1199                    severity: Severity::Major,
1200                    file: None,
1201                    line: None,
1202                    title: "still open".to_owned(),
1203                    detail: String::new(),
1204                }],
1205                vote: None,
1206                failed: None,
1207                duration_ms: 0,
1208            }],
1209            e2e: vec![CommandOutcome {
1210                command: "cargo test".to_owned(),
1211                code: Some(0),
1212                output_tail: String::new(),
1213                duration_ms: 0,
1214            }],
1215            verify_retried: false,
1216            fix: Some(FixRecord {
1217                agent: "alpha".to_owned(),
1218                addressed: Vec::new(),
1219                rejected: vec![Rejection {
1220                    id: "R1-1-2".to_owned(),
1221                    why: "cannot be triggered from any caller".to_owned(),
1222                }],
1223                notes: String::new(),
1224                committed: true,
1225                failed: None,
1226                duration_ms: 0,
1227            }),
1228            blocking: 1,
1229            answered: 1,
1230            expected: 1,
1231            clean: false,
1232            progressed: true,
1233            vote_split: false,
1234            reconsideration: Vec::new(),
1235            verdict: None,
1236        }];
1237
1238        let text = run(&s);
1239        assert!(text.contains("R1-1-2"), "{text}");
1240        assert!(text.contains("cannot be triggered"), "{text}");
1241        assert!(text.contains("still open"), "{text}");
1242        assert!(
1243            text.contains("handed off"),
1244            "a mergeable run with an open round must say so: {text}"
1245        );
1246    }
1247
1248    #[test]
1249    fn a_failing_gate_command_shows_its_output() {
1250        let _guard = plain();
1251        let mut s = state();
1252        s.status = RunStatus::Blocked;
1253        s.gate = vec![CommandOutcome {
1254            command: "cargo make check".to_owned(),
1255            code: Some(101),
1256            output_tail: "error[E0308]: mismatched types".to_owned(),
1257            duration_ms: 0,
1258        }];
1259
1260        let text = run(&s);
1261        assert!(text.contains("mismatched types"), "{text}");
1262    }
1263
1264    #[test]
1265    fn active_seats_shows_who_has_not_answered_and_how_long_is_left() {
1266        let _guard = plain();
1267        let mut s = state();
1268        s.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
1269        let text = active_seats(&s, true);
1270        assert!(text.contains("running now"));
1271        assert!(text.contains("judge-2"));
1272        assert!(text.contains("judge"));
1273        assert!(!text.contains("no live daemon"), "{text}");
1274    }
1275
1276    #[test]
1277    fn active_seats_flags_a_leftover_from_a_dead_process() {
1278        let _guard = plain();
1279        let mut s = state();
1280        s.seat_started("implement", "impl-B", std::time::Duration::from_secs(60), 0);
1281        let text = active_seats(&s, false);
1282        assert!(
1283            text.contains("no live daemon"),
1284            "a stale entry must not read as running: {text}"
1285        );
1286    }
1287
1288    #[test]
1289    fn active_seats_is_empty_when_nothing_is_running() {
1290        let _guard = plain();
1291        assert_eq!(active_seats(&state(), true), "");
1292    }
1293
1294    #[test]
1295    fn stats_table_renders_without_runs() {
1296        let _guard = plain();
1297        let text = stats(&Stats::default());
1298        assert!(text.contains("0 total"));
1299        assert!(!text.contains("implementation"));
1300    }
1301}