Skip to main content

magi_core/
reporting.rs

1// Author: Julian Bolivar
2// Version: 1.0.0
3// Date: 2026-04-05
4
5use serde::Serialize;
6use std::collections::BTreeMap;
7use std::fmt::Write;
8
9use crate::consensus::{Condition, ConsensusResult, DedupFinding, Dissent};
10use crate::schema::{AgentName, AgentOutput, Mode};
11
12/// Configuration for the report formatter.
13///
14/// Controls banner width and agent display names/titles.
15///
16/// # ASCII Constraint
17///
18/// The fixed-width banner guarantee (`banner_width` bytes per line) assumes
19/// all displayed content is ASCII. Agent titles, verdict labels, and consensus
20/// strings are ASCII by default. If `agent_titles` contains multi-byte UTF-8
21/// characters, banner lines will have correct byte length but may appear
22/// visually misaligned in terminals because multi-byte characters can occupy
23/// more than one display column.
24#[non_exhaustive]
25#[derive(Debug, Clone)]
26pub struct ReportConfig {
27    /// Total width of the ASCII banner in bytes, including border characters
28    /// (default: 52). Equals character count for ASCII content.
29    pub banner_width: usize,
30    /// Maps agent name to (display_name, title) for report display.
31    /// Values should be ASCII for correct banner alignment.
32    pub agent_titles: BTreeMap<AgentName, (String, String)>,
33}
34
35/// Formats consensus results into ASCII banners and markdown reports.
36///
37/// Generates fixed-width ASCII banners (exactly 52 characters wide per line)
38/// and full markdown reports from agent outputs and consensus results.
39/// The reporting module is pure string formatting -- no async, no I/O.
40pub struct ReportFormatter {
41    config: ReportConfig,
42    banner_inner: usize,
43}
44
45/// Final output struct returned by the orchestrator's `analyze()` method.
46///
47/// Contains all analysis data plus the formatted report string.
48/// Serializes to JSON matching the Python original format.
49#[derive(Debug, Clone, Serialize)]
50pub struct MagiReport {
51    /// The successful agent outputs used in analysis.
52    pub agents: Vec<AgentOutput>,
53    /// The computed consensus result.
54    pub consensus: ConsensusResult,
55    /// The ASCII banner string.
56    pub banner: String,
57    /// The full markdown report string.
58    pub report: String,
59    /// True if fewer than 3 agents succeeded.
60    pub degraded: bool,
61    /// Agents that failed, mapped to their failure reason
62    /// (e.g., `"parse: no valid JSON"`, `"validation: confidence out of range"`).
63    pub failed_agents: BTreeMap<AgentName, String>,
64}
65
66impl Default for ReportConfig {
67    fn default() -> Self {
68        let mut agent_titles = BTreeMap::new();
69        agent_titles.insert(
70            AgentName::Melchior,
71            ("Melchior".to_string(), "Scientist".to_string()),
72        );
73        agent_titles.insert(
74            AgentName::Balthasar,
75            ("Balthasar".to_string(), "Pragmatist".to_string()),
76        );
77        agent_titles.insert(
78            AgentName::Caspar,
79            ("Caspar".to_string(), "Critic".to_string()),
80        );
81        Self {
82            banner_width: 52,
83            agent_titles,
84        }
85    }
86}
87
88impl ReportFormatter {
89    /// Creates a new formatter with default configuration.
90    pub fn new() -> Self {
91        Self::with_config(ReportConfig::default())
92    }
93
94    /// Creates a new formatter with custom configuration.
95    pub fn with_config(config: ReportConfig) -> Self {
96        let banner_inner = config.banner_width - 2;
97        Self {
98            config,
99            banner_inner,
100        }
101    }
102
103    /// Generates the fixed-width ASCII verdict banner.
104    ///
105    /// Every line is exactly `banner_width` (52) characters. Structure:
106    /// ```text
107    /// +==================================================+
108    /// |          MAGI SYSTEM -- VERDICT                  |
109    /// +==================================================+
110    /// |  Melchior (Scientist):  APPROVE (90%)            |
111    /// +==================================================+
112    /// |  CONSENSUS: GO WITH CAVEATS                      |
113    /// +==================================================+
114    /// ```
115    pub fn format_banner(&self, agents: &[AgentOutput], consensus: &ConsensusResult) -> String {
116        let mut out = String::new();
117        let sep = self.format_separator();
118
119        writeln!(out, "{}", sep).ok();
120        writeln!(
121            out,
122            "{}",
123            self.format_line("        MAGI SYSTEM -- VERDICT")
124        )
125        .ok();
126        writeln!(out, "{}", sep).ok();
127
128        for agent in agents {
129            let (display_name, title) = self.agent_display(&agent.agent);
130            let pct = (agent.confidence * 100.0).round() as u32;
131            let content = format!(
132                "  {} ({}):  {} ({}%)",
133                display_name, title, agent.verdict, pct
134            );
135            writeln!(out, "{}", self.format_line(&content)).ok();
136        }
137
138        writeln!(out, "{}", sep).ok();
139        let consensus_line = format!("  CONSENSUS: {}", consensus.consensus);
140        writeln!(out, "{}", self.format_line(&consensus_line)).ok();
141        write!(out, "{}", sep).ok();
142
143        out
144    }
145
146    /// Generates the pre-analysis initialization banner.
147    ///
148    /// Shows mode, model, and timeout in a fixed-width ASCII box.
149    pub fn format_init_banner(&self, mode: &Mode, model: &str, timeout_secs: u64) -> String {
150        let mut out = String::new();
151        let sep = self.format_separator();
152
153        writeln!(out, "{}", sep).ok();
154        writeln!(
155            out,
156            "{}",
157            self.format_line("        MAGI SYSTEM -- INITIALIZING")
158        )
159        .ok();
160        writeln!(out, "{}", sep).ok();
161        writeln!(
162            out,
163            "{}",
164            self.format_line(&format!("  Mode:     {}", mode))
165        )
166        .ok();
167        writeln!(
168            out,
169            "{}",
170            self.format_line(&format!("  Model:    {}", model))
171        )
172        .ok();
173        writeln!(
174            out,
175            "{}",
176            self.format_line(&format!("  Timeout:  {}s", timeout_secs))
177        )
178        .ok();
179        write!(out, "{}", sep).ok();
180
181        out
182    }
183
184    /// Generates the full markdown report (banner + all sections).
185    ///
186    /// Concatenates sections in order: banner, consensus summary, key findings,
187    /// dissenting opinion, conditions for approval, recommended actions.
188    /// Optional sections are omitted entirely when their data is absent.
189    pub fn format_report(&self, agents: &[AgentOutput], consensus: &ConsensusResult) -> String {
190        let mut out = String::new();
191
192        // 1. Banner
193        out.push_str(&self.format_banner(agents, consensus));
194        out.push('\n');
195
196        // 2. Consensus Summary
197        out.push_str(&self.format_consensus_summary(consensus));
198
199        // 3. Key Findings (optional)
200        if !consensus.findings.is_empty() {
201            out.push_str(&self.format_findings(&consensus.findings));
202        }
203
204        // 4. Dissenting Opinion (optional)
205        if !consensus.dissent.is_empty() {
206            out.push_str(&self.format_dissent(&consensus.dissent));
207        }
208
209        // 5. Conditions for Approval (optional)
210        if !consensus.conditions.is_empty() {
211            out.push_str(&self.format_conditions(&consensus.conditions));
212        }
213
214        // 6. Recommended Actions
215        out.push_str(&self.format_recommendations(&consensus.recommendations));
216
217        out
218    }
219
220    /// Generates the separator line: `+` + `=` * inner + `+`.
221    fn format_separator(&self) -> String {
222        format!("+{}+", "=".repeat(self.banner_inner))
223    }
224
225    /// Generates a content line: `|` + content padded to inner width + `|`.
226    ///
227    /// Content is left-aligned. If content exceeds inner width, it is truncated.
228    fn format_line(&self, content: &str) -> String {
229        if content.len() > self.banner_inner {
230            let boundary = content.floor_char_boundary(self.banner_inner);
231            format!(
232                "|{:<width$}|",
233                &content[..boundary],
234                width = self.banner_inner
235            )
236        } else {
237            format!("|{:<width$}|", content, width = self.banner_inner)
238        }
239    }
240
241    /// Returns `(display_name, title)` for the given agent.
242    ///
243    /// Looks up in `config.agent_titles` first, falls back to
244    /// `AgentName::display_name()` and `AgentName::title()`.
245    fn agent_display(&self, name: &AgentName) -> (&str, &str) {
246        if let Some((display_name, title)) = self.config.agent_titles.get(name) {
247            (display_name.as_str(), title.as_str())
248        } else {
249            (name.display_name(), name.title())
250        }
251    }
252
253    /// Formats the consensus summary section.
254    fn format_consensus_summary(&self, consensus: &ConsensusResult) -> String {
255        let mut out = String::new();
256        writeln!(out, "\n## Consensus Summary\n").ok();
257        writeln!(out, "{}", consensus.majority_summary).ok();
258        out
259    }
260
261    /// Formats the key findings section.
262    fn format_findings(&self, findings: &[DedupFinding]) -> String {
263        let mut out = String::new();
264        writeln!(out, "\n## Key Findings\n").ok();
265        for finding in findings {
266            let sources = finding
267                .sources
268                .iter()
269                .map(|s| s.display_name())
270                .collect::<Vec<_>>()
271                .join(", ");
272            writeln!(
273                out,
274                "{} **[{}]** {} _(from {})_",
275                finding.severity.icon(),
276                finding.severity,
277                finding.title,
278                sources
279            )
280            .ok();
281            writeln!(out, "   {}", finding.detail).ok();
282            writeln!(out).ok();
283        }
284        out
285    }
286
287    /// Formats the dissenting opinion section.
288    fn format_dissent(&self, dissent: &[Dissent]) -> String {
289        let mut out = String::new();
290        writeln!(out, "\n## Dissenting Opinion\n").ok();
291        for d in dissent {
292            let (display_name, title) = self.agent_display(&d.agent);
293            writeln!(out, "**{} ({})**: {}", display_name, title, d.summary).ok();
294            writeln!(out).ok();
295            writeln!(out, "{}", d.reasoning).ok();
296            writeln!(out).ok();
297        }
298        out
299    }
300
301    /// Formats the conditions for approval section.
302    fn format_conditions(&self, conditions: &[Condition]) -> String {
303        let mut out = String::new();
304        writeln!(out, "\n## Conditions for Approval\n").ok();
305        for c in conditions {
306            let (display_name, _) = self.agent_display(&c.agent);
307            writeln!(out, "- **{}**: {}", display_name, c.condition).ok();
308        }
309        writeln!(out).ok();
310        out
311    }
312
313    /// Formats the recommended actions section.
314    fn format_recommendations(&self, recommendations: &BTreeMap<AgentName, String>) -> String {
315        let mut out = String::new();
316        writeln!(out, "\n## Recommended Actions\n").ok();
317        for (name, rec) in recommendations {
318            let (display_name, title) = self.agent_display(name);
319            writeln!(out, "- **{}** ({}): {}", display_name, title, rec).ok();
320        }
321        out
322    }
323}
324
325impl Default for ReportFormatter {
326    fn default() -> Self {
327        Self::new()
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use crate::consensus::*;
335    use crate::schema::*;
336
337    /// Helper: build a minimal AgentOutput for testing.
338    fn make_agent(
339        name: AgentName,
340        verdict: Verdict,
341        confidence: f64,
342        summary: &str,
343        reasoning: &str,
344        recommendation: &str,
345    ) -> AgentOutput {
346        AgentOutput {
347            agent: name,
348            verdict,
349            confidence,
350            summary: summary.to_string(),
351            reasoning: reasoning.to_string(),
352            findings: vec![],
353            recommendation: recommendation.to_string(),
354        }
355    }
356
357    /// Helper: build a minimal ConsensusResult for testing.
358    fn make_consensus(
359        label: &str,
360        verdict: Verdict,
361        confidence: f64,
362        score: f64,
363        agents: &[&AgentOutput],
364    ) -> ConsensusResult {
365        let mut votes = BTreeMap::new();
366        let mut recommendations = BTreeMap::new();
367        for a in agents {
368            votes.insert(a.agent, a.verdict);
369            recommendations.insert(a.agent, a.recommendation.clone());
370        }
371
372        let majority_summary = agents
373            .iter()
374            .filter(|a| a.effective_verdict() == verdict.effective())
375            .map(|a| format!("{}: {}", a.agent.display_name(), a.summary))
376            .collect::<Vec<_>>()
377            .join(" | ");
378
379        ConsensusResult {
380            consensus: label.to_string(),
381            consensus_verdict: verdict,
382            confidence,
383            score,
384            agent_count: agents.len(),
385            votes,
386            majority_summary,
387            dissent: vec![],
388            findings: vec![],
389            conditions: vec![],
390            recommendations,
391        }
392    }
393
394    // -- BDD Scenario 15: banner width --
395
396    /// All banner lines are exactly 52 characters wide.
397    #[test]
398    fn test_banner_lines_are_exactly_52_chars_wide() {
399        let m = make_agent(
400            AgentName::Melchior,
401            Verdict::Approve,
402            0.9,
403            "Good",
404            "R",
405            "Rec",
406        );
407        let b = make_agent(
408            AgentName::Balthasar,
409            Verdict::Conditional,
410            0.85,
411            "Ok",
412            "R",
413            "Rec",
414        );
415        let c = make_agent(AgentName::Caspar, Verdict::Reject, 0.78, "Bad", "R", "Rec");
416        let agents = vec![m.clone(), b.clone(), c.clone()];
417        let consensus = make_consensus(
418            "GO WITH CAVEATS",
419            Verdict::Approve,
420            0.85,
421            0.33,
422            &[&m, &b, &c],
423        );
424
425        let formatter = ReportFormatter::new();
426        let banner = formatter.format_banner(&agents, &consensus);
427
428        for line in banner.lines() {
429            if !line.is_empty() {
430                assert_eq!(line.len(), 52, "Line is not 52 chars: '{}'", line);
431            }
432        }
433    }
434
435    /// Banner with long consensus label still fits 52 chars.
436    #[test]
437    fn test_banner_with_long_content_fits_52_chars() {
438        let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
439        let b = make_agent(
440            AgentName::Balthasar,
441            Verdict::Approve,
442            0.85,
443            "S",
444            "R",
445            "Rec",
446        );
447        let c = make_agent(AgentName::Caspar, Verdict::Approve, 0.95, "S", "R", "Rec");
448        let agents = vec![m.clone(), b.clone(), c.clone()];
449        let consensus = make_consensus("STRONG GO", Verdict::Approve, 0.9, 1.0, &[&m, &b, &c]);
450
451        let formatter = ReportFormatter::new();
452        let banner = formatter.format_banner(&agents, &consensus);
453
454        for line in banner.lines() {
455            if !line.is_empty() {
456                assert_eq!(line.len(), 52, "Line is not 52 chars: '{}'", line);
457            }
458        }
459    }
460
461    // -- BDD Scenario 16: report sections --
462
463    /// Report with mixed consensus contains all 5 markdown headers.
464    #[test]
465    fn test_report_with_mixed_consensus_contains_all_headers() {
466        let m = make_agent(
467            AgentName::Melchior,
468            Verdict::Approve,
469            0.9,
470            "Good code",
471            "Solid",
472            "Merge",
473        );
474        let b = make_agent(
475            AgentName::Balthasar,
476            Verdict::Conditional,
477            0.85,
478            "Needs work",
479            "Issues",
480            "Fix first",
481        );
482        let c = make_agent(
483            AgentName::Caspar,
484            Verdict::Reject,
485            0.78,
486            "Problems",
487            "Risky",
488            "Reject",
489        );
490        let agents = vec![m.clone(), b.clone(), c.clone()];
491
492        let mut consensus = make_consensus(
493            "GO WITH CAVEATS",
494            Verdict::Approve,
495            0.85,
496            0.33,
497            &[&m, &b, &c],
498        );
499        consensus.dissent = vec![Dissent {
500            agent: AgentName::Caspar,
501            summary: "Problems found".to_string(),
502            reasoning: "Risk is too high".to_string(),
503        }];
504        consensus.conditions = vec![Condition {
505            agent: AgentName::Balthasar,
506            condition: "Fix first".to_string(),
507        }];
508        consensus.findings = vec![DedupFinding {
509            severity: Severity::Warning,
510            title: "Test finding".to_string(),
511            detail: "Detail here".to_string(),
512            sources: vec![AgentName::Melchior, AgentName::Caspar],
513        }];
514
515        let formatter = ReportFormatter::new();
516        let report = formatter.format_report(&agents, &consensus);
517
518        assert!(
519            report.contains("## Consensus Summary"),
520            "Missing Consensus Summary"
521        );
522        assert!(report.contains("## Key Findings"), "Missing Key Findings");
523        assert!(
524            report.contains("## Dissenting Opinion"),
525            "Missing Dissenting Opinion"
526        );
527        assert!(
528            report.contains("## Conditions for Approval"),
529            "Missing Conditions"
530        );
531        assert!(
532            report.contains("## Recommended Actions"),
533            "Missing Recommended Actions"
534        );
535    }
536
537    /// Report without dissent omits "## Dissenting Opinion".
538    #[test]
539    fn test_report_without_dissent_omits_dissent_section() {
540        let m = make_agent(
541            AgentName::Melchior,
542            Verdict::Approve,
543            0.9,
544            "Good",
545            "R",
546            "Merge",
547        );
548        let b = make_agent(
549            AgentName::Balthasar,
550            Verdict::Approve,
551            0.85,
552            "Good",
553            "R",
554            "Merge",
555        );
556        let c = make_agent(
557            AgentName::Caspar,
558            Verdict::Approve,
559            0.95,
560            "Good",
561            "R",
562            "Merge",
563        );
564        let agents = vec![m.clone(), b.clone(), c.clone()];
565        let consensus = make_consensus("STRONG GO", Verdict::Approve, 0.9, 1.0, &[&m, &b, &c]);
566
567        let formatter = ReportFormatter::new();
568        let report = formatter.format_report(&agents, &consensus);
569
570        assert!(!report.contains("## Dissenting Opinion"));
571    }
572
573    /// Report without conditions omits "## Conditions for Approval".
574    #[test]
575    fn test_report_without_conditions_omits_conditions_section() {
576        let m = make_agent(
577            AgentName::Melchior,
578            Verdict::Approve,
579            0.9,
580            "Good",
581            "R",
582            "Merge",
583        );
584        let b = make_agent(
585            AgentName::Balthasar,
586            Verdict::Approve,
587            0.85,
588            "Good",
589            "R",
590            "Merge",
591        );
592        let c = make_agent(
593            AgentName::Caspar,
594            Verdict::Approve,
595            0.95,
596            "Good",
597            "R",
598            "Merge",
599        );
600        let agents = vec![m.clone(), b.clone(), c.clone()];
601        let consensus = make_consensus("STRONG GO", Verdict::Approve, 0.9, 1.0, &[&m, &b, &c]);
602
603        let formatter = ReportFormatter::new();
604        let report = formatter.format_report(&agents, &consensus);
605
606        assert!(!report.contains("## Conditions for Approval"));
607    }
608
609    /// Report without findings omits "## Key Findings".
610    #[test]
611    fn test_report_without_findings_omits_findings_section() {
612        let m = make_agent(
613            AgentName::Melchior,
614            Verdict::Approve,
615            0.9,
616            "Good",
617            "R",
618            "Merge",
619        );
620        let b = make_agent(
621            AgentName::Balthasar,
622            Verdict::Approve,
623            0.85,
624            "Good",
625            "R",
626            "Merge",
627        );
628        let c = make_agent(
629            AgentName::Caspar,
630            Verdict::Approve,
631            0.95,
632            "Good",
633            "R",
634            "Merge",
635        );
636        let agents = vec![m.clone(), b.clone(), c.clone()];
637        let consensus = make_consensus("STRONG GO", Verdict::Approve, 0.9, 1.0, &[&m, &b, &c]);
638
639        let formatter = ReportFormatter::new();
640        let report = formatter.format_report(&agents, &consensus);
641
642        assert!(!report.contains("## Key Findings"));
643    }
644
645    // -- Banner formatting --
646
647    /// format_banner generates correct ASCII art structure.
648    #[test]
649    fn test_format_banner_has_correct_structure() {
650        let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
651        let b = make_agent(AgentName::Balthasar, Verdict::Reject, 0.7, "S", "R", "Rec");
652        let c = make_agent(AgentName::Caspar, Verdict::Reject, 0.8, "S", "R", "Rec");
653        let agents = vec![m.clone(), b.clone(), c.clone()];
654        let consensus = make_consensus("HOLD (2-1)", Verdict::Reject, 0.7, -0.33, &[&m, &b, &c]);
655
656        let formatter = ReportFormatter::new();
657        let banner = formatter.format_banner(&agents, &consensus);
658
659        assert!(banner.contains("MAGI SYSTEM -- VERDICT"));
660        assert!(banner.contains("Melchior (Scientist)"));
661        assert!(banner.contains("APPROVE"));
662        assert!(banner.contains("CONSENSUS:"));
663        assert!(banner.contains("HOLD (2-1)"));
664    }
665
666    /// format_init_banner shows mode, model, timeout.
667    #[test]
668    fn test_format_init_banner_shows_mode_model_timeout() {
669        let formatter = ReportFormatter::new();
670        let banner = formatter.format_init_banner(&Mode::CodeReview, "claude-sonnet", 300);
671
672        assert!(banner.contains("code-review"), "Missing mode");
673        assert!(banner.contains("claude-sonnet"), "Missing model");
674        assert!(banner.contains("300"), "Missing timeout");
675
676        for line in banner.lines() {
677            if !line.is_empty() {
678                assert_eq!(line.len(), 52, "Init banner line not 52 chars: '{}'", line);
679            }
680        }
681    }
682
683    /// Separator line is "+" + "=" * 50 + "+".
684    #[test]
685    fn test_separator_format() {
686        let formatter = ReportFormatter::new();
687        let banner = formatter.format_init_banner(&Mode::Analysis, "test", 60);
688        let sep = format!("+{}+", "=".repeat(50));
689
690        assert!(banner.contains(&sep), "Missing separator line");
691        assert_eq!(sep.len(), 52);
692    }
693
694    /// Agent line shows "Name (Title):  VERDICT (NN%)" format.
695    #[test]
696    fn test_agent_line_format() {
697        let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
698        let b = make_agent(
699            AgentName::Balthasar,
700            Verdict::Approve,
701            0.85,
702            "S",
703            "R",
704            "Rec",
705        );
706        let c = make_agent(AgentName::Caspar, Verdict::Approve, 0.78, "S", "R", "Rec");
707        let agents = vec![m.clone(), b.clone(), c.clone()];
708        let consensus = make_consensus("STRONG GO", Verdict::Approve, 0.9, 1.0, &[&m, &b, &c]);
709
710        let formatter = ReportFormatter::new();
711        let banner = formatter.format_banner(&agents, &consensus);
712
713        assert!(banner.contains("Melchior (Scientist):  APPROVE (90%)"));
714        assert!(banner.contains("Caspar (Critic):  APPROVE (78%)"));
715    }
716
717    // -- Report content sections --
718
719    /// Findings section shows icon + severity + title + sources + detail.
720    #[test]
721    fn test_findings_section_format() {
722        let m = make_agent(
723            AgentName::Melchior,
724            Verdict::Approve,
725            0.9,
726            "Good",
727            "R",
728            "Merge",
729        );
730        let agents = vec![m.clone()];
731        let mut consensus = make_consensus("GO (1-0)", Verdict::Approve, 0.9, 1.0, &[&m]);
732        consensus.findings = vec![DedupFinding {
733            severity: Severity::Critical,
734            title: "SQL injection risk".to_string(),
735            detail: "User input not sanitized".to_string(),
736            sources: vec![AgentName::Melchior, AgentName::Caspar],
737        }];
738
739        let formatter = ReportFormatter::new();
740        let report = formatter.format_report(&agents, &consensus);
741
742        assert!(report.contains("[!!!]"), "Missing critical icon");
743        assert!(report.contains("[CRITICAL]"), "Missing severity label");
744        assert!(report.contains("SQL injection risk"), "Missing title");
745        assert!(report.contains("Melchior"), "Missing source agent");
746        assert!(report.contains("Caspar"), "Missing source agent");
747        assert!(
748            report.contains("User input not sanitized"),
749            "Missing detail"
750        );
751    }
752
753    /// Dissent section shows agent name, summary, full reasoning.
754    #[test]
755    fn test_dissent_section_format() {
756        let m = make_agent(
757            AgentName::Melchior,
758            Verdict::Approve,
759            0.9,
760            "Good",
761            "R",
762            "Merge",
763        );
764        let c = make_agent(
765            AgentName::Caspar,
766            Verdict::Reject,
767            0.8,
768            "Bad",
769            "Too risky",
770            "Reject",
771        );
772        let agents = vec![m.clone(), c.clone()];
773        let mut consensus = make_consensus("GO (1-1)", Verdict::Approve, 0.8, 0.0, &[&m, &c]);
774        consensus.dissent = vec![Dissent {
775            agent: AgentName::Caspar,
776            summary: "Too many issues".to_string(),
777            reasoning: "The code has critical flaws".to_string(),
778        }];
779
780        let formatter = ReportFormatter::new();
781        let report = formatter.format_report(&agents, &consensus);
782
783        assert!(report.contains("Caspar"), "Missing dissenting agent name");
784        assert!(report.contains("Critic"), "Missing dissenting agent title");
785        assert!(
786            report.contains("Too many issues"),
787            "Missing dissent summary"
788        );
789        assert!(
790            report.contains("The code has critical flaws"),
791            "Missing dissent reasoning"
792        );
793    }
794
795    /// Conditions section shows bulleted list with agent names.
796    #[test]
797    fn test_conditions_section_format() {
798        let m = make_agent(
799            AgentName::Melchior,
800            Verdict::Approve,
801            0.9,
802            "Good",
803            "R",
804            "Merge",
805        );
806        let b = make_agent(
807            AgentName::Balthasar,
808            Verdict::Conditional,
809            0.85,
810            "Ok",
811            "R",
812            "Fix tests",
813        );
814        let agents = vec![m.clone(), b.clone()];
815        let mut consensus =
816            make_consensus("GO WITH CAVEATS", Verdict::Approve, 0.85, 0.75, &[&m, &b]);
817        consensus.conditions = vec![Condition {
818            agent: AgentName::Balthasar,
819            condition: "Fix tests first".to_string(),
820        }];
821
822        let formatter = ReportFormatter::new();
823        let report = formatter.format_report(&agents, &consensus);
824
825        assert!(
826            report.contains("- **Balthasar**:"),
827            "Missing bullet with agent name"
828        );
829        assert!(report.contains("Fix tests first"), "Missing condition text");
830    }
831
832    /// Recommendations section shows per-agent recommendations.
833    #[test]
834    fn test_recommendations_section_format() {
835        let m = make_agent(
836            AgentName::Melchior,
837            Verdict::Approve,
838            0.9,
839            "Good",
840            "R",
841            "Merge immediately",
842        );
843        let b = make_agent(
844            AgentName::Balthasar,
845            Verdict::Approve,
846            0.85,
847            "Good",
848            "R",
849            "Ship it",
850        );
851        let agents = vec![m.clone(), b.clone()];
852        let consensus = make_consensus("GO (2-0)", Verdict::Approve, 0.9, 1.0, &[&m, &b]);
853
854        let formatter = ReportFormatter::new();
855        let report = formatter.format_report(&agents, &consensus);
856
857        assert!(
858            report.contains("Merge immediately"),
859            "Missing Melchior recommendation"
860        );
861        assert!(
862            report.contains("Ship it"),
863            "Missing Balthasar recommendation"
864        );
865    }
866
867    /// Agent display falls back to AgentName methods when not in config.
868    #[test]
869    fn test_agent_display_fallback_to_agent_name_methods() {
870        let config = ReportConfig {
871            banner_width: 52,
872            agent_titles: BTreeMap::new(),
873        };
874        let formatter = ReportFormatter::with_config(config);
875
876        let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
877        let agents = vec![m.clone()];
878        let consensus = make_consensus("GO (1-0)", Verdict::Approve, 0.9, 1.0, &[&m]);
879
880        let banner = formatter.format_banner(&agents, &consensus);
881        assert!(
882            banner.contains("Melchior"),
883            "Should use AgentName::display_name()"
884        );
885    }
886
887    // -- MagiReport tests --
888
889    /// MagiReport serializes to JSON.
890    #[test]
891    fn test_magi_report_serializes_to_json() {
892        let m = make_agent(
893            AgentName::Melchior,
894            Verdict::Approve,
895            0.9,
896            "Good",
897            "R",
898            "Merge",
899        );
900        let agents = vec![m.clone()];
901        let consensus = make_consensus("GO (1-0)", Verdict::Approve, 0.9, 1.0, &[&m]);
902
903        let report = MagiReport {
904            agents,
905            consensus,
906            banner: "banner".to_string(),
907            report: "report".to_string(),
908            degraded: false,
909            failed_agents: BTreeMap::new(),
910        };
911
912        let json = serde_json::to_string(&report).expect("serialize");
913        assert!(json.contains("\"consensus\""));
914        assert!(json.contains("\"agents\""));
915        assert!(json.contains("\"degraded\""));
916    }
917
918    /// degraded=false when all 3 agents succeed.
919    #[test]
920    fn test_magi_report_not_degraded_with_three_agents() {
921        let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
922        let b = make_agent(
923            AgentName::Balthasar,
924            Verdict::Approve,
925            0.85,
926            "S",
927            "R",
928            "Rec",
929        );
930        let c = make_agent(AgentName::Caspar, Verdict::Approve, 0.95, "S", "R", "Rec");
931        let agents = vec![m.clone(), b.clone(), c.clone()];
932        let consensus = make_consensus("STRONG GO", Verdict::Approve, 0.9, 1.0, &[&m, &b, &c]);
933
934        let report = MagiReport {
935            agents,
936            consensus,
937            banner: String::new(),
938            report: String::new(),
939            degraded: false,
940            failed_agents: BTreeMap::new(),
941        };
942
943        assert!(!report.degraded);
944        assert!(report.failed_agents.is_empty());
945    }
946
947    /// degraded=true with failed_agents populated when agent fails.
948    #[test]
949    fn test_magi_report_degraded_with_failed_agents() {
950        let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
951        let b = make_agent(
952            AgentName::Balthasar,
953            Verdict::Approve,
954            0.85,
955            "S",
956            "R",
957            "Rec",
958        );
959        let agents = vec![m.clone(), b.clone()];
960        let consensus = make_consensus("GO (2-0)", Verdict::Approve, 0.9, 1.0, &[&m, &b]);
961
962        let report = MagiReport {
963            agents,
964            consensus,
965            banner: String::new(),
966            report: String::new(),
967            degraded: true,
968            failed_agents: BTreeMap::from([(AgentName::Caspar, "timeout".to_string())]),
969        };
970
971        assert!(report.degraded);
972        assert_eq!(report.failed_agents.len(), 1);
973        assert!(report.failed_agents.contains_key(&AgentName::Caspar));
974    }
975
976    /// Agent names in JSON are lowercase.
977    #[test]
978    fn test_magi_report_json_agent_names_lowercase() {
979        let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
980        let agents = vec![m.clone()];
981        let consensus = make_consensus("GO (1-0)", Verdict::Approve, 0.9, 1.0, &[&m]);
982
983        let report = MagiReport {
984            agents,
985            consensus,
986            banner: String::new(),
987            report: String::new(),
988            degraded: false,
989            failed_agents: BTreeMap::new(),
990        };
991
992        let json = serde_json::to_string(&report).expect("serialize");
993        assert!(
994            json.contains("\"melchior\""),
995            "Agent name should be lowercase in JSON"
996        );
997        assert!(
998            !json.contains("\"Melchior\""),
999            "Agent name should NOT be capitalized in JSON"
1000        );
1001    }
1002
1003    /// consensus.confidence is rounded to 2 decimals.
1004    #[test]
1005    fn test_magi_report_confidence_rounded() {
1006        let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
1007        let agents = vec![m.clone()];
1008        let mut consensus = make_consensus("GO (1-0)", Verdict::Approve, 0.86, 1.0, &[&m]);
1009        consensus.confidence = 0.8567;
1010
1011        let report = MagiReport {
1012            agents,
1013            consensus,
1014            banner: String::new(),
1015            report: String::new(),
1016            degraded: false,
1017            failed_agents: BTreeMap::new(),
1018        };
1019
1020        // Confidence rounding is done by the consensus engine, not by MagiReport.
1021        // Here we verify the field value is preserved as-is during serialization.
1022        let json = serde_json::to_string(&report).expect("serialize");
1023        assert!(
1024            json.contains("0.8567"),
1025            "Confidence should be serialized as-is (rounding is consensus engine's job)"
1026        );
1027    }
1028}