Skip to main content

magi/
report.rs

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