use serde::Serialize;
use std::collections::BTreeMap;
use std::fmt::Write;
use crate::consensus::{Condition, ConsensusResult, DedupFinding, Dissent};
use crate::schema::{AgentName, AgentOutput, Mode};
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct ReportConfig {
pub banner_width: usize,
pub agent_titles: BTreeMap<AgentName, (String, String)>,
}
pub struct ReportFormatter {
config: ReportConfig,
banner_inner: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct MagiReport {
pub agents: Vec<AgentOutput>,
pub consensus: ConsensusResult,
pub banner: String,
pub report: String,
pub degraded: bool,
pub failed_agents: BTreeMap<AgentName, String>,
}
impl Default for ReportConfig {
fn default() -> Self {
let mut agent_titles = BTreeMap::new();
agent_titles.insert(
AgentName::Melchior,
("Melchior".to_string(), "Scientist".to_string()),
);
agent_titles.insert(
AgentName::Balthasar,
("Balthasar".to_string(), "Pragmatist".to_string()),
);
agent_titles.insert(
AgentName::Caspar,
("Caspar".to_string(), "Critic".to_string()),
);
Self {
banner_width: 52,
agent_titles,
}
}
}
impl ReportFormatter {
pub fn new() -> Self {
Self::with_config(ReportConfig::default())
}
pub fn with_config(config: ReportConfig) -> Self {
let banner_inner = config.banner_width - 2;
Self {
config,
banner_inner,
}
}
pub fn format_banner(&self, agents: &[AgentOutput], consensus: &ConsensusResult) -> String {
let mut out = String::new();
let sep = self.format_separator();
writeln!(out, "{}", sep).ok();
writeln!(
out,
"{}",
self.format_line(" MAGI SYSTEM -- VERDICT")
)
.ok();
writeln!(out, "{}", sep).ok();
for agent in agents {
let (display_name, title) = self.agent_display(&agent.agent);
let pct = (agent.confidence * 100.0).round() as u32;
let content = format!(
" {} ({}): {} ({}%)",
display_name, title, agent.verdict, pct
);
writeln!(out, "{}", self.format_line(&content)).ok();
}
writeln!(out, "{}", sep).ok();
let consensus_line = format!(" CONSENSUS: {}", consensus.consensus);
writeln!(out, "{}", self.format_line(&consensus_line)).ok();
write!(out, "{}", sep).ok();
out
}
pub fn format_init_banner(&self, mode: &Mode, model: &str, timeout_secs: u64) -> String {
let mut out = String::new();
let sep = self.format_separator();
writeln!(out, "{}", sep).ok();
writeln!(
out,
"{}",
self.format_line(" MAGI SYSTEM -- INITIALIZING")
)
.ok();
writeln!(out, "{}", sep).ok();
writeln!(
out,
"{}",
self.format_line(&format!(" Mode: {}", mode))
)
.ok();
writeln!(
out,
"{}",
self.format_line(&format!(" Model: {}", model))
)
.ok();
writeln!(
out,
"{}",
self.format_line(&format!(" Timeout: {}s", timeout_secs))
)
.ok();
write!(out, "{}", sep).ok();
out
}
pub fn format_report(&self, agents: &[AgentOutput], consensus: &ConsensusResult) -> String {
let mut out = String::new();
out.push_str(&self.format_banner(agents, consensus));
out.push('\n');
out.push_str(&self.format_consensus_summary(consensus));
if !consensus.findings.is_empty() {
out.push_str(&self.format_findings(&consensus.findings));
}
if !consensus.dissent.is_empty() {
out.push_str(&self.format_dissent(&consensus.dissent));
}
if !consensus.conditions.is_empty() {
out.push_str(&self.format_conditions(&consensus.conditions));
}
out.push_str(&self.format_recommendations(&consensus.recommendations));
out
}
fn format_separator(&self) -> String {
format!("+{}+", "=".repeat(self.banner_inner))
}
fn format_line(&self, content: &str) -> String {
if content.len() > self.banner_inner {
let boundary = content.floor_char_boundary(self.banner_inner);
format!(
"|{:<width$}|",
&content[..boundary],
width = self.banner_inner
)
} else {
format!("|{:<width$}|", content, width = self.banner_inner)
}
}
fn agent_display(&self, name: &AgentName) -> (&str, &str) {
if let Some((display_name, title)) = self.config.agent_titles.get(name) {
(display_name.as_str(), title.as_str())
} else {
(name.display_name(), name.title())
}
}
fn format_consensus_summary(&self, consensus: &ConsensusResult) -> String {
let mut out = String::new();
writeln!(out, "\n## Consensus Summary\n").ok();
writeln!(out, "{}", consensus.majority_summary).ok();
out
}
fn format_findings(&self, findings: &[DedupFinding]) -> String {
let mut out = String::new();
writeln!(out, "\n## Key Findings\n").ok();
for finding in findings {
let sources = finding
.sources
.iter()
.map(|s| s.display_name())
.collect::<Vec<_>>()
.join(", ");
writeln!(
out,
"{} **[{}]** {} _(from {})_",
finding.severity.icon(),
finding.severity,
finding.title,
sources
)
.ok();
writeln!(out, " {}", finding.detail).ok();
writeln!(out).ok();
}
out
}
fn format_dissent(&self, dissent: &[Dissent]) -> String {
let mut out = String::new();
writeln!(out, "\n## Dissenting Opinion\n").ok();
for d in dissent {
let (display_name, title) = self.agent_display(&d.agent);
writeln!(out, "**{} ({})**: {}", display_name, title, d.summary).ok();
writeln!(out).ok();
writeln!(out, "{}", d.reasoning).ok();
writeln!(out).ok();
}
out
}
fn format_conditions(&self, conditions: &[Condition]) -> String {
let mut out = String::new();
writeln!(out, "\n## Conditions for Approval\n").ok();
for c in conditions {
let (display_name, _) = self.agent_display(&c.agent);
writeln!(out, "- **{}**: {}", display_name, c.condition).ok();
}
writeln!(out).ok();
out
}
fn format_recommendations(&self, recommendations: &BTreeMap<AgentName, String>) -> String {
let mut out = String::new();
writeln!(out, "\n## Recommended Actions\n").ok();
for (name, rec) in recommendations {
let (display_name, title) = self.agent_display(name);
writeln!(out, "- **{}** ({}): {}", display_name, title, rec).ok();
}
out
}
}
impl Default for ReportFormatter {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::consensus::*;
use crate::schema::*;
fn make_agent(
name: AgentName,
verdict: Verdict,
confidence: f64,
summary: &str,
reasoning: &str,
recommendation: &str,
) -> AgentOutput {
AgentOutput {
agent: name,
verdict,
confidence,
summary: summary.to_string(),
reasoning: reasoning.to_string(),
findings: vec![],
recommendation: recommendation.to_string(),
}
}
fn make_consensus(
label: &str,
verdict: Verdict,
confidence: f64,
score: f64,
agents: &[&AgentOutput],
) -> ConsensusResult {
let mut votes = BTreeMap::new();
let mut recommendations = BTreeMap::new();
for a in agents {
votes.insert(a.agent, a.verdict);
recommendations.insert(a.agent, a.recommendation.clone());
}
let majority_summary = agents
.iter()
.filter(|a| a.effective_verdict() == verdict.effective())
.map(|a| format!("{}: {}", a.agent.display_name(), a.summary))
.collect::<Vec<_>>()
.join(" | ");
ConsensusResult {
consensus: label.to_string(),
consensus_verdict: verdict,
confidence,
score,
agent_count: agents.len(),
votes,
majority_summary,
dissent: vec![],
findings: vec![],
conditions: vec![],
recommendations,
}
}
#[test]
fn test_banner_lines_are_exactly_52_chars_wide() {
let m = make_agent(
AgentName::Melchior,
Verdict::Approve,
0.9,
"Good",
"R",
"Rec",
);
let b = make_agent(
AgentName::Balthasar,
Verdict::Conditional,
0.85,
"Ok",
"R",
"Rec",
);
let c = make_agent(AgentName::Caspar, Verdict::Reject, 0.78, "Bad", "R", "Rec");
let agents = vec![m.clone(), b.clone(), c.clone()];
let consensus = make_consensus(
"GO WITH CAVEATS",
Verdict::Approve,
0.85,
0.33,
&[&m, &b, &c],
);
let formatter = ReportFormatter::new();
let banner = formatter.format_banner(&agents, &consensus);
for line in banner.lines() {
if !line.is_empty() {
assert_eq!(line.len(), 52, "Line is not 52 chars: '{}'", line);
}
}
}
#[test]
fn test_banner_with_long_content_fits_52_chars() {
let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
let b = make_agent(
AgentName::Balthasar,
Verdict::Approve,
0.85,
"S",
"R",
"Rec",
);
let c = make_agent(AgentName::Caspar, Verdict::Approve, 0.95, "S", "R", "Rec");
let agents = vec![m.clone(), b.clone(), c.clone()];
let consensus = make_consensus("STRONG GO", Verdict::Approve, 0.9, 1.0, &[&m, &b, &c]);
let formatter = ReportFormatter::new();
let banner = formatter.format_banner(&agents, &consensus);
for line in banner.lines() {
if !line.is_empty() {
assert_eq!(line.len(), 52, "Line is not 52 chars: '{}'", line);
}
}
}
#[test]
fn test_report_with_mixed_consensus_contains_all_headers() {
let m = make_agent(
AgentName::Melchior,
Verdict::Approve,
0.9,
"Good code",
"Solid",
"Merge",
);
let b = make_agent(
AgentName::Balthasar,
Verdict::Conditional,
0.85,
"Needs work",
"Issues",
"Fix first",
);
let c = make_agent(
AgentName::Caspar,
Verdict::Reject,
0.78,
"Problems",
"Risky",
"Reject",
);
let agents = vec![m.clone(), b.clone(), c.clone()];
let mut consensus = make_consensus(
"GO WITH CAVEATS",
Verdict::Approve,
0.85,
0.33,
&[&m, &b, &c],
);
consensus.dissent = vec![Dissent {
agent: AgentName::Caspar,
summary: "Problems found".to_string(),
reasoning: "Risk is too high".to_string(),
}];
consensus.conditions = vec![Condition {
agent: AgentName::Balthasar,
condition: "Fix first".to_string(),
}];
consensus.findings = vec![DedupFinding {
severity: Severity::Warning,
title: "Test finding".to_string(),
detail: "Detail here".to_string(),
sources: vec![AgentName::Melchior, AgentName::Caspar],
}];
let formatter = ReportFormatter::new();
let report = formatter.format_report(&agents, &consensus);
assert!(
report.contains("## Consensus Summary"),
"Missing Consensus Summary"
);
assert!(report.contains("## Key Findings"), "Missing Key Findings");
assert!(
report.contains("## Dissenting Opinion"),
"Missing Dissenting Opinion"
);
assert!(
report.contains("## Conditions for Approval"),
"Missing Conditions"
);
assert!(
report.contains("## Recommended Actions"),
"Missing Recommended Actions"
);
}
#[test]
fn test_report_without_dissent_omits_dissent_section() {
let m = make_agent(
AgentName::Melchior,
Verdict::Approve,
0.9,
"Good",
"R",
"Merge",
);
let b = make_agent(
AgentName::Balthasar,
Verdict::Approve,
0.85,
"Good",
"R",
"Merge",
);
let c = make_agent(
AgentName::Caspar,
Verdict::Approve,
0.95,
"Good",
"R",
"Merge",
);
let agents = vec![m.clone(), b.clone(), c.clone()];
let consensus = make_consensus("STRONG GO", Verdict::Approve, 0.9, 1.0, &[&m, &b, &c]);
let formatter = ReportFormatter::new();
let report = formatter.format_report(&agents, &consensus);
assert!(!report.contains("## Dissenting Opinion"));
}
#[test]
fn test_report_without_conditions_omits_conditions_section() {
let m = make_agent(
AgentName::Melchior,
Verdict::Approve,
0.9,
"Good",
"R",
"Merge",
);
let b = make_agent(
AgentName::Balthasar,
Verdict::Approve,
0.85,
"Good",
"R",
"Merge",
);
let c = make_agent(
AgentName::Caspar,
Verdict::Approve,
0.95,
"Good",
"R",
"Merge",
);
let agents = vec![m.clone(), b.clone(), c.clone()];
let consensus = make_consensus("STRONG GO", Verdict::Approve, 0.9, 1.0, &[&m, &b, &c]);
let formatter = ReportFormatter::new();
let report = formatter.format_report(&agents, &consensus);
assert!(!report.contains("## Conditions for Approval"));
}
#[test]
fn test_report_without_findings_omits_findings_section() {
let m = make_agent(
AgentName::Melchior,
Verdict::Approve,
0.9,
"Good",
"R",
"Merge",
);
let b = make_agent(
AgentName::Balthasar,
Verdict::Approve,
0.85,
"Good",
"R",
"Merge",
);
let c = make_agent(
AgentName::Caspar,
Verdict::Approve,
0.95,
"Good",
"R",
"Merge",
);
let agents = vec![m.clone(), b.clone(), c.clone()];
let consensus = make_consensus("STRONG GO", Verdict::Approve, 0.9, 1.0, &[&m, &b, &c]);
let formatter = ReportFormatter::new();
let report = formatter.format_report(&agents, &consensus);
assert!(!report.contains("## Key Findings"));
}
#[test]
fn test_format_banner_has_correct_structure() {
let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
let b = make_agent(AgentName::Balthasar, Verdict::Reject, 0.7, "S", "R", "Rec");
let c = make_agent(AgentName::Caspar, Verdict::Reject, 0.8, "S", "R", "Rec");
let agents = vec![m.clone(), b.clone(), c.clone()];
let consensus = make_consensus("HOLD (2-1)", Verdict::Reject, 0.7, -0.33, &[&m, &b, &c]);
let formatter = ReportFormatter::new();
let banner = formatter.format_banner(&agents, &consensus);
assert!(banner.contains("MAGI SYSTEM -- VERDICT"));
assert!(banner.contains("Melchior (Scientist)"));
assert!(banner.contains("APPROVE"));
assert!(banner.contains("CONSENSUS:"));
assert!(banner.contains("HOLD (2-1)"));
}
#[test]
fn test_format_init_banner_shows_mode_model_timeout() {
let formatter = ReportFormatter::new();
let banner = formatter.format_init_banner(&Mode::CodeReview, "claude-sonnet", 300);
assert!(banner.contains("code-review"), "Missing mode");
assert!(banner.contains("claude-sonnet"), "Missing model");
assert!(banner.contains("300"), "Missing timeout");
for line in banner.lines() {
if !line.is_empty() {
assert_eq!(line.len(), 52, "Init banner line not 52 chars: '{}'", line);
}
}
}
#[test]
fn test_separator_format() {
let formatter = ReportFormatter::new();
let banner = formatter.format_init_banner(&Mode::Analysis, "test", 60);
let sep = format!("+{}+", "=".repeat(50));
assert!(banner.contains(&sep), "Missing separator line");
assert_eq!(sep.len(), 52);
}
#[test]
fn test_agent_line_format() {
let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
let b = make_agent(
AgentName::Balthasar,
Verdict::Approve,
0.85,
"S",
"R",
"Rec",
);
let c = make_agent(AgentName::Caspar, Verdict::Approve, 0.78, "S", "R", "Rec");
let agents = vec![m.clone(), b.clone(), c.clone()];
let consensus = make_consensus("STRONG GO", Verdict::Approve, 0.9, 1.0, &[&m, &b, &c]);
let formatter = ReportFormatter::new();
let banner = formatter.format_banner(&agents, &consensus);
assert!(banner.contains("Melchior (Scientist): APPROVE (90%)"));
assert!(banner.contains("Caspar (Critic): APPROVE (78%)"));
}
#[test]
fn test_findings_section_format() {
let m = make_agent(
AgentName::Melchior,
Verdict::Approve,
0.9,
"Good",
"R",
"Merge",
);
let agents = vec![m.clone()];
let mut consensus = make_consensus("GO (1-0)", Verdict::Approve, 0.9, 1.0, &[&m]);
consensus.findings = vec![DedupFinding {
severity: Severity::Critical,
title: "SQL injection risk".to_string(),
detail: "User input not sanitized".to_string(),
sources: vec![AgentName::Melchior, AgentName::Caspar],
}];
let formatter = ReportFormatter::new();
let report = formatter.format_report(&agents, &consensus);
assert!(report.contains("[!!!]"), "Missing critical icon");
assert!(report.contains("[CRITICAL]"), "Missing severity label");
assert!(report.contains("SQL injection risk"), "Missing title");
assert!(report.contains("Melchior"), "Missing source agent");
assert!(report.contains("Caspar"), "Missing source agent");
assert!(
report.contains("User input not sanitized"),
"Missing detail"
);
}
#[test]
fn test_dissent_section_format() {
let m = make_agent(
AgentName::Melchior,
Verdict::Approve,
0.9,
"Good",
"R",
"Merge",
);
let c = make_agent(
AgentName::Caspar,
Verdict::Reject,
0.8,
"Bad",
"Too risky",
"Reject",
);
let agents = vec![m.clone(), c.clone()];
let mut consensus = make_consensus("GO (1-1)", Verdict::Approve, 0.8, 0.0, &[&m, &c]);
consensus.dissent = vec![Dissent {
agent: AgentName::Caspar,
summary: "Too many issues".to_string(),
reasoning: "The code has critical flaws".to_string(),
}];
let formatter = ReportFormatter::new();
let report = formatter.format_report(&agents, &consensus);
assert!(report.contains("Caspar"), "Missing dissenting agent name");
assert!(report.contains("Critic"), "Missing dissenting agent title");
assert!(
report.contains("Too many issues"),
"Missing dissent summary"
);
assert!(
report.contains("The code has critical flaws"),
"Missing dissent reasoning"
);
}
#[test]
fn test_conditions_section_format() {
let m = make_agent(
AgentName::Melchior,
Verdict::Approve,
0.9,
"Good",
"R",
"Merge",
);
let b = make_agent(
AgentName::Balthasar,
Verdict::Conditional,
0.85,
"Ok",
"R",
"Fix tests",
);
let agents = vec![m.clone(), b.clone()];
let mut consensus =
make_consensus("GO WITH CAVEATS", Verdict::Approve, 0.85, 0.75, &[&m, &b]);
consensus.conditions = vec![Condition {
agent: AgentName::Balthasar,
condition: "Fix tests first".to_string(),
}];
let formatter = ReportFormatter::new();
let report = formatter.format_report(&agents, &consensus);
assert!(
report.contains("- **Balthasar**:"),
"Missing bullet with agent name"
);
assert!(report.contains("Fix tests first"), "Missing condition text");
}
#[test]
fn test_recommendations_section_format() {
let m = make_agent(
AgentName::Melchior,
Verdict::Approve,
0.9,
"Good",
"R",
"Merge immediately",
);
let b = make_agent(
AgentName::Balthasar,
Verdict::Approve,
0.85,
"Good",
"R",
"Ship it",
);
let agents = vec![m.clone(), b.clone()];
let consensus = make_consensus("GO (2-0)", Verdict::Approve, 0.9, 1.0, &[&m, &b]);
let formatter = ReportFormatter::new();
let report = formatter.format_report(&agents, &consensus);
assert!(
report.contains("Merge immediately"),
"Missing Melchior recommendation"
);
assert!(
report.contains("Ship it"),
"Missing Balthasar recommendation"
);
}
#[test]
fn test_agent_display_fallback_to_agent_name_methods() {
let config = ReportConfig {
banner_width: 52,
agent_titles: BTreeMap::new(),
};
let formatter = ReportFormatter::with_config(config);
let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
let agents = vec![m.clone()];
let consensus = make_consensus("GO (1-0)", Verdict::Approve, 0.9, 1.0, &[&m]);
let banner = formatter.format_banner(&agents, &consensus);
assert!(
banner.contains("Melchior"),
"Should use AgentName::display_name()"
);
}
#[test]
fn test_magi_report_serializes_to_json() {
let m = make_agent(
AgentName::Melchior,
Verdict::Approve,
0.9,
"Good",
"R",
"Merge",
);
let agents = vec![m.clone()];
let consensus = make_consensus("GO (1-0)", Verdict::Approve, 0.9, 1.0, &[&m]);
let report = MagiReport {
agents,
consensus,
banner: "banner".to_string(),
report: "report".to_string(),
degraded: false,
failed_agents: BTreeMap::new(),
};
let json = serde_json::to_string(&report).expect("serialize");
assert!(json.contains("\"consensus\""));
assert!(json.contains("\"agents\""));
assert!(json.contains("\"degraded\""));
}
#[test]
fn test_magi_report_not_degraded_with_three_agents() {
let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
let b = make_agent(
AgentName::Balthasar,
Verdict::Approve,
0.85,
"S",
"R",
"Rec",
);
let c = make_agent(AgentName::Caspar, Verdict::Approve, 0.95, "S", "R", "Rec");
let agents = vec![m.clone(), b.clone(), c.clone()];
let consensus = make_consensus("STRONG GO", Verdict::Approve, 0.9, 1.0, &[&m, &b, &c]);
let report = MagiReport {
agents,
consensus,
banner: String::new(),
report: String::new(),
degraded: false,
failed_agents: BTreeMap::new(),
};
assert!(!report.degraded);
assert!(report.failed_agents.is_empty());
}
#[test]
fn test_magi_report_degraded_with_failed_agents() {
let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
let b = make_agent(
AgentName::Balthasar,
Verdict::Approve,
0.85,
"S",
"R",
"Rec",
);
let agents = vec![m.clone(), b.clone()];
let consensus = make_consensus("GO (2-0)", Verdict::Approve, 0.9, 1.0, &[&m, &b]);
let report = MagiReport {
agents,
consensus,
banner: String::new(),
report: String::new(),
degraded: true,
failed_agents: BTreeMap::from([(AgentName::Caspar, "timeout".to_string())]),
};
assert!(report.degraded);
assert_eq!(report.failed_agents.len(), 1);
assert!(report.failed_agents.contains_key(&AgentName::Caspar));
}
#[test]
fn test_magi_report_json_agent_names_lowercase() {
let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
let agents = vec![m.clone()];
let consensus = make_consensus("GO (1-0)", Verdict::Approve, 0.9, 1.0, &[&m]);
let report = MagiReport {
agents,
consensus,
banner: String::new(),
report: String::new(),
degraded: false,
failed_agents: BTreeMap::new(),
};
let json = serde_json::to_string(&report).expect("serialize");
assert!(
json.contains("\"melchior\""),
"Agent name should be lowercase in JSON"
);
assert!(
!json.contains("\"Melchior\""),
"Agent name should NOT be capitalized in JSON"
);
}
#[test]
fn test_magi_report_confidence_rounded() {
let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
let agents = vec![m.clone()];
let mut consensus = make_consensus("GO (1-0)", Verdict::Approve, 0.86, 1.0, &[&m]);
consensus.confidence = 0.8567;
let report = MagiReport {
agents,
consensus,
banner: String::new(),
report: String::new(),
degraded: false,
failed_agents: BTreeMap::new(),
};
let json = serde_json::to_string(&report).expect("serialize");
assert!(
json.contains("0.8567"),
"Confidence should be serialized as-is (rounding is consensus engine's job)"
);
}
}