use serde::Serialize;
use std::collections::BTreeMap;
use std::fmt;
use std::fmt::Write;
use crate::consensus::{Condition, ConsensusResult, DedupFinding, Dissent};
use crate::schema::{AgentName, AgentOutput, Mode};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ReportError {
#[non_exhaustive]
NonAsciiTitle {
agent: AgentName,
field: &'static str,
value: String,
},
#[non_exhaustive]
BannerTooSmall {
requested: usize,
minimum: usize,
},
}
impl fmt::Display for ReportError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ReportError::NonAsciiTitle {
agent,
field,
value,
} => write!(
f,
"agent_titles[{:?}].{} contains non-ASCII characters: {:?}",
agent, field, value
),
ReportError::BannerTooSmall { requested, minimum } => write!(
f,
"banner_width {requested} is below the minimum of {minimum}"
),
}
}
}
pub const BANNER_WIDTH: usize = 52;
pub const BANNER_INNER: usize = BANNER_WIDTH - 2;
const FINDING_MARKER_WIDTH: usize = 5;
fn fit_content(content: &str, width: usize, preserve_suffix: &str) -> String {
debug_assert!(content.is_ascii() && preserve_suffix.is_ascii());
debug_assert!(width > 0);
debug_assert!(
width >= 4,
"fit_content requires width >= 4 for sensible truncation; got {}",
width
);
const ELLIPSIS: &str = "...";
if content.len() <= width {
return content.to_string();
}
if preserve_suffix.is_empty() || preserve_suffix.len() + ELLIPSIS.len() >= width {
let cutoff = (width.saturating_sub(ELLIPSIS.len())).max(1);
let safe_cutoff = content.floor_char_boundary(cutoff);
return format!("{}{}", &content[..safe_cutoff], ELLIPSIS);
}
debug_assert!(content.ends_with(preserve_suffix));
let prefix_budget = width - ELLIPSIS.len() - preserve_suffix.len();
let prefix_source = &content[..content.len() - preserve_suffix.len()];
let safe_prefix_budget = prefix_source.floor_char_boundary(prefix_budget);
format!(
"{}{}{}",
&prefix_source[..safe_prefix_budget],
ELLIPSIS,
preserve_suffix
)
}
const FINDING_SEVERITY_WIDTH: usize = 14;
#[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 ReportConfig {
pub const MIN_BANNER_WIDTH: usize = 8;
pub fn new_checked(
banner_width: usize,
agent_titles: BTreeMap<AgentName, (String, String)>,
) -> Result<Self, ReportError> {
if banner_width < Self::MIN_BANNER_WIDTH {
return Err(ReportError::BannerTooSmall {
requested: banner_width,
minimum: Self::MIN_BANNER_WIDTH,
});
}
for (agent, (display_name, title)) in &agent_titles {
if !display_name.is_ascii() {
return Err(ReportError::NonAsciiTitle {
agent: *agent,
field: "display_name",
value: display_name.clone(),
});
}
if !title.is_ascii() {
return Err(ReportError::NonAsciiTitle {
agent: *agent,
field: "title",
value: title.clone(),
});
}
}
Ok(Self {
banner_width,
agent_titles,
})
}
}
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 {
fn from_valid_config(config: ReportConfig) -> Self {
let banner_inner = config.banner_width - 2;
Self {
config,
banner_inner,
}
}
pub fn new() -> Self {
Self::from_valid_config(ReportConfig::default())
}
pub fn with_config(config: ReportConfig) -> Result<Self, ReportError> {
if config.banner_width < ReportConfig::MIN_BANNER_WIDTH {
return Err(ReportError::BannerTooSmall {
requested: config.banner_width,
minimum: ReportConfig::MIN_BANNER_WIDTH,
});
}
for (agent, (display_name, title)) in &config.agent_titles {
if !display_name.is_ascii() {
return Err(ReportError::NonAsciiTitle {
agent: *agent,
field: "display_name",
value: display_name.clone(),
});
}
if !title.is_ascii() {
return Err(ReportError::NonAsciiTitle {
agent: *agent,
field: "title",
value: title.clone(),
});
}
}
Ok(Self::from_valid_config(config))
}
pub fn format_banner(&self, agents: &[AgentOutput], consensus: &ConsensusResult) -> String {
let mut out = String::new();
let sep = self.format_separator();
let labels: Vec<String> = agents
.iter()
.map(|a| {
let (display_name, title) = self.agent_display(&a.agent);
format!("{} ({}):", display_name, title)
})
.collect();
let max_label_len = labels.iter().map(|l| l.chars().count()).max().unwrap_or(0);
writeln!(out, "{}", sep).ok();
writeln!(
out,
"{}",
self.format_line(" MAGI SYSTEM -- VERDICT")
)
.ok();
writeln!(out, "{}", sep).ok();
for (agent, label) in agents.iter().zip(labels.iter()) {
let pct = (agent.confidence * 100.0).round() as u32;
let verdict_suffix = format!(" {} ({}%)", agent.verdict, pct);
let content = format!(" {:<max_label_len$}{}", label, verdict_suffix);
let fitted = fit_content(&content, self.banner_inner, &verdict_suffix);
writeln!(out, "|{:<width$}|", fitted, width = self.banner_inner).ok();
}
writeln!(out, "{}", sep).ok();
let consensus_content = format!(" CONSENSUS: {}", consensus.consensus);
let fitted_consensus = fit_content(&consensus_content, self.banner_inner, "");
writeln!(
out,
"|{:<width$}|",
fitted_consensus,
width = self.banner_inner
)
.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');
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_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| self.agent_display(s).0)
.collect::<Vec<_>>()
.join(", ");
let severity_label = format!("**[{}]**", finding.severity);
writeln!(
out,
"{:<marker_w$} {:<sev_w$} {} _(from {})_",
finding.severity.icon(),
severity_label,
finding.title,
sources,
marker_w = FINDING_MARKER_WIDTH,
sev_w = FINDING_SEVERITY_WIDTH,
)
.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 (name, title) = self.agent_display(&d.agent);
writeln!(out, "**{} ({})**: {}", name, title, d.summary).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"),
"Consensus Summary must not appear"
);
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_does_not_contain_consensus_summary_heading() {
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("## Consensus Summary"));
}
#[test]
fn test_report_section_order_banner_then_findings_or_dissent_or_conditions_or_actions() {
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.findings = vec![DedupFinding {
severity: Severity::Warning,
title: "Test finding".to_string(),
detail: "Detail here".to_string(),
sources: vec![AgentName::Melchior],
}];
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(),
}];
let formatter = ReportFormatter::new();
let report = formatter.format_report(&agents, &consensus);
assert!(!report.contains("## Consensus Summary"));
let banner_pos = report.find("+====").expect("banner border not found");
let actions_pos = report
.find("## Recommended Actions")
.expect("Recommended Actions not found");
let findings_pos = report
.find("## Key Findings")
.expect("Key Findings not found");
let dissent_pos = report
.find("## Dissenting Opinion")
.expect("Dissenting Opinion not found");
let conditions_pos = report
.find("## Conditions for Approval")
.expect("Conditions not found");
assert!(
banner_pos < findings_pos,
"banner must come before Key Findings"
);
assert!(
banner_pos < dissent_pos,
"banner must come before Dissenting Opinion"
);
assert!(
banner_pos < conditions_pos,
"banner must come before Conditions"
);
assert!(
banner_pos < actions_pos,
"banner must come before Recommended Actions"
);
assert!(
findings_pos < dissent_pos,
"Key Findings must come before Dissenting Opinion"
);
assert!(
dissent_pos < conditions_pos,
"Dissenting Opinion must come before Conditions"
);
assert!(
conditions_pos < actions_pos,
"Conditions must come before 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"),
"Detail must not appear in markdown report"
);
}
#[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"),
"Dissent reasoning must not appear in the rendered report"
);
}
#[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).unwrap();
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_dissent_shows_one_line_per_dissenter() {
let formatter = ReportFormatter::new();
let dissent = vec![
Dissent {
agent: AgentName::Caspar,
summary: "Summary for Caspar".to_string(),
reasoning: "Reasoning for Caspar that is long and detailed".to_string(),
},
Dissent {
agent: AgentName::Balthasar,
summary: "Summary for Balthasar".to_string(),
reasoning: "Reasoning for Balthasar that is very lengthy".to_string(),
},
];
let output = formatter.format_dissent(&dissent);
let header_lines: Vec<&str> = output
.lines()
.filter(|l| l.starts_with("**") && l.contains(")**:"))
.collect();
assert_eq!(
header_lines.len(),
2,
"Expected exactly 2 dissenter header lines, got {}: {:?}",
header_lines.len(),
header_lines
);
}
#[test]
fn test_dissent_line_contains_summary_not_reasoning() {
let formatter = ReportFormatter::new();
let dissent = vec![Dissent {
agent: AgentName::Caspar,
summary: "Unique summary text here".to_string(),
reasoning: "Unique reasoning text should not appear".to_string(),
}];
let output = formatter.format_dissent(&dissent);
assert!(
output.contains("Unique summary text here"),
"Output must contain the summary"
);
assert!(
!output.contains("Unique reasoning text should not appear"),
"Output must NOT contain the reasoning"
);
}
#[test]
fn test_dissent_section_has_blank_line_after() {
let formatter = ReportFormatter::new();
let dissent = vec![Dissent {
agent: AgentName::Caspar,
summary: "Some summary".to_string(),
reasoning: "Some reasoning".to_string(),
}];
let output = formatter.format_dissent(&dissent);
assert!(
output.ends_with("\n\n"),
"Dissent section must end with a blank line (\\n\\n), got: {:?}",
output
);
}
#[test]
fn test_findings_line_does_not_contain_detail_text() {
let formatter = ReportFormatter::new();
let findings = vec![DedupFinding {
severity: Severity::Critical,
title: "SQL injection in query builder".to_string(),
detail: "UNIQUE_DETAIL_SENTINEL_XYZ".to_string(),
sources: vec![AgentName::Melchior],
}];
let output = formatter.format_findings(&findings);
assert!(
!output.contains("UNIQUE_DETAIL_SENTINEL_XYZ"),
"Detail text must not appear in the markdown findings output"
);
}
#[test]
fn test_findings_line_marker_column_is_5_chars_left_justified() {
let formatter = ReportFormatter::new();
let findings = vec![
DedupFinding {
severity: Severity::Critical,
title: "Critical finding".to_string(),
detail: "detail".to_string(),
sources: vec![AgentName::Melchior],
},
DedupFinding {
severity: Severity::Warning,
title: "Warning finding".to_string(),
detail: "detail".to_string(),
sources: vec![AgentName::Balthasar],
},
DedupFinding {
severity: Severity::Info,
title: "Info finding".to_string(),
detail: "detail".to_string(),
sources: vec![AgentName::Caspar],
},
];
let output = formatter.format_findings(&findings);
for line in output.lines() {
if line.starts_with('[') {
let marker_col = &line[..5];
assert_eq!(
marker_col.len(),
5,
"Marker column must be 5 chars; got {:?} in line {:?}",
marker_col,
line
);
assert_eq!(
line.chars().nth(5),
Some(' '),
"Column 5 must be a space separator; got {:?} in line {:?}",
line.chars().nth(5),
line
);
}
}
}
#[test]
fn test_findings_line_severity_label_column_is_14_chars_left_justified() {
let formatter = ReportFormatter::new();
let findings = vec![
DedupFinding {
severity: Severity::Critical,
title: "A".to_string(),
detail: "d".to_string(),
sources: vec![AgentName::Melchior],
},
DedupFinding {
severity: Severity::Warning,
title: "B".to_string(),
detail: "d".to_string(),
sources: vec![AgentName::Balthasar],
},
DedupFinding {
severity: Severity::Info,
title: "C".to_string(),
detail: "d".to_string(),
sources: vec![AgentName::Caspar],
},
];
let output = formatter.format_findings(&findings);
for line in output.lines() {
if line.starts_with('[') {
assert!(
line.len() >= 21,
"Line too short to contain marker+severity columns: {:?}",
line
);
let severity_col = &line[6..20];
assert_eq!(
severity_col.len(),
14,
"Severity label column must be 14 chars; got {:?} in line {:?}",
severity_col,
line
);
assert_eq!(
line.chars().nth(20),
Some(' '),
"Column 20 must be a space separator after severity; got {:?} in line {:?}",
line.chars().nth(20),
line
);
}
}
}
#[test]
fn test_findings_line_matches_python_layout_exactly() {
let formatter = ReportFormatter::new();
let findings = vec![
DedupFinding {
severity: Severity::Critical,
title: "Test title".to_string(),
detail: "ignored detail".to_string(),
sources: vec![AgentName::Melchior, AgentName::Caspar],
},
DedupFinding {
severity: Severity::Warning,
title: "Missing retry logic".to_string(),
detail: "ignored detail".to_string(),
sources: vec![AgentName::Balthasar],
},
DedupFinding {
severity: Severity::Info,
title: "Consider timeout".to_string(),
detail: "ignored detail".to_string(),
sources: vec![AgentName::Caspar],
},
];
let output = formatter.format_findings(&findings);
let expected_critical = "[!!!] **[CRITICAL]** Test title _(from Melchior, Caspar)_";
let expected_warning = "[!!] **[WARNING]** Missing retry logic _(from Balthasar)_";
let expected_info = "[i] **[INFO]** Consider timeout _(from Caspar)_";
assert!(
output.contains(expected_critical),
"Critical line does not match Python layout.\nExpected: {:?}\nOutput:\n{}",
expected_critical,
output
);
assert!(
output.contains(expected_warning),
"Warning line does not match Python layout.\nExpected: {:?}\nOutput:\n{}",
expected_warning,
output
);
assert!(
output.contains(expected_info),
"Info line does not match Python layout.\nExpected: {:?}\nOutput:\n{}",
expected_info,
output
);
}
#[test]
fn test_fit_content_returns_input_when_shorter_than_width() {
assert_eq!(fit_content("hello", 10, ""), "hello");
assert_eq!(fit_content("hi", 10, "lo"), "hi");
}
#[test]
fn test_fit_content_returns_input_when_exactly_width() {
assert_eq!(fit_content("hello", 5, ""), "hello");
assert_eq!(fit_content("abcde", 5, "lo"), "abcde");
}
#[test]
fn test_fit_content_preserves_suffix_when_prefix_overflows() {
assert_eq!(fit_content("abcdefghij", 8, "hij"), "ab...hij");
}
#[test]
fn test_fit_content_falls_back_to_tail_cut_when_no_suffix() {
assert_eq!(fit_content("abcdefghij", 6, ""), "abc...");
}
#[test]
fn test_fit_content_falls_back_to_tail_cut_when_suffix_plus_ellipsis_exceeds_width() {
assert_eq!(fit_content("abcdefghij", 5, "xy"), "ab...");
}
#[test]
fn test_fit_content_ellipsis_is_exactly_three_dots() {
let result = fit_content("abcdefghij", 6, "");
assert!(
result.ends_with("..."),
"Expected ellipsis '...', got: {:?}",
result
);
let ellipsis_start = result.len() - 3;
assert_eq!(&result[ellipsis_start..], "...");
}
#[test]
fn test_fit_content_resulting_length_equals_width_when_truncated() {
for w in 4..=20usize {
let content = "a".repeat(w + 5);
let result = fit_content(&content, w, "");
assert_eq!(
result.len(),
w,
"Expected result length {w} for width={w}, got {} from {:?}",
result.len(),
result
);
}
let result = fit_content("abcdefghij", 8, "hij");
assert_eq!(
result.len(),
8,
"Expected 8, got {}: {:?}",
result.len(),
result
);
}
#[test]
#[cfg(not(debug_assertions))]
fn test_fit_content_boundary_width_1() {
let result = fit_content("abc", 1, "");
assert_eq!(
result, "a...",
"Expected 'a...' for width=1, got: {:?}",
result
);
}
#[test]
fn test_banner_labels_are_column_aligned_to_max_label_len() {
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);
let agent_lines: Vec<&str> = banner
.lines()
.filter(|l| l.starts_with('|') && l.contains("APPROVE") && !l.contains("CONSENSUS"))
.collect();
assert_eq!(agent_lines.len(), 3, "Expected 3 agent lines");
let verdict_positions: Vec<usize> = agent_lines
.iter()
.map(|l| l.find(" APPROVE").expect("APPROVE not found"))
.collect();
let first_pos = verdict_positions[0];
for (i, &pos) in verdict_positions.iter().enumerate() {
assert_eq!(
pos, first_pos,
"Agent line {i} has APPROVE at column {pos}, expected {first_pos}\nLines: {agent_lines:?}"
);
}
}
#[test]
fn test_banner_verdict_preserved_when_label_exceeds_width() {
let mut config = ReportConfig::default();
config.agent_titles.insert(
AgentName::Balthasar,
(
"Balthasar".to_string(),
"Very Long Pragmatist Title Indeed Here".to_string(),
),
);
let formatter = ReportFormatter::with_config(config).unwrap();
let b = make_agent(
AgentName::Balthasar,
Verdict::Approve,
0.85,
"S",
"R",
"Rec",
);
let agents = vec![b.clone()];
let consensus = make_consensus("GO (1-0)", Verdict::Approve, 0.85, 1.0, &[&b]);
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);
}
}
let verdict_suffix = " APPROVE (85%)";
assert!(
banner.contains(verdict_suffix),
"Verdict suffix {:?} must be preserved in banner:\n{}",
verdict_suffix,
banner
);
}
#[test]
fn test_banner_consensus_line_includes_split_for_go_with_caveats() {
let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
let b = make_agent(AgentName::Balthasar, Verdict::Approve, 0.8, "S", "R", "Rec");
let c = make_agent(AgentName::Caspar, Verdict::Reject, 0.75, "S", "R", "Rec");
let agents = vec![m.clone(), b.clone(), c.clone()];
let consensus = make_consensus(
"GO WITH CAVEATS (2-1)",
Verdict::Approve,
0.8,
0.33,
&[&m, &b, &c],
);
let formatter = ReportFormatter::new();
let banner = formatter.format_banner(&agents, &consensus);
assert!(
banner.contains("GO WITH CAVEATS (2-1)"),
"Banner consensus line must include the split count: {banner}"
);
for line in banner.lines() {
if !line.is_empty() {
assert_eq!(line.len(), 52, "Line is not 52 chars: {:?}", line);
}
}
}
#[test]
fn test_banner_all_lines_are_exactly_banner_width() {
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 (2-1)",
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_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)"
);
}
#[test]
fn test_new_checked_accepts_all_ascii_titles() {
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()),
);
let result = ReportConfig::new_checked(52, agent_titles);
assert!(result.is_ok(), "Should accept all ASCII titles");
let config = result.unwrap();
assert_eq!(config.banner_width, 52);
}
#[test]
fn test_new_checked_rejects_non_ascii_display_name() {
let mut agent_titles = BTreeMap::new();
agent_titles.insert(
AgentName::Melchior,
("Mélchior".to_string(), "Scientist".to_string()),
);
let result = ReportConfig::new_checked(52, agent_titles);
assert!(result.is_err(), "Should reject non-ASCII display_name");
let err = result.unwrap_err();
let ReportError::NonAsciiTitle {
agent,
field,
value,
..
} = err
else {
panic!("expected NonAsciiTitle, got {err:?}");
};
assert_eq!(agent, AgentName::Melchior);
assert_eq!(field, "display_name");
assert_eq!(value, "Mélchior");
}
#[test]
fn test_new_checked_rejects_non_ascii_title_field() {
let mut agent_titles = BTreeMap::new();
agent_titles.insert(
AgentName::Balthasar,
("Balthasar".to_string(), "Pragmátist".to_string()),
);
let result = ReportConfig::new_checked(52, agent_titles);
assert!(result.is_err(), "Should reject non-ASCII title");
let err = result.unwrap_err();
let ReportError::NonAsciiTitle {
agent,
field,
value,
..
} = err
else {
panic!("expected NonAsciiTitle, got {err:?}");
};
assert_eq!(agent, AgentName::Balthasar);
assert_eq!(field, "title");
assert_eq!(value, "Pragmátist");
}
#[test]
fn test_new_checked_rejects_banner_width_too_small() {
let titles = BTreeMap::new();
for width in [0usize, 1, 4, 7] {
let result = ReportConfig::new_checked(width, titles.clone());
assert!(result.is_err(), "banner_width={width} should be rejected");
assert_eq!(
result.unwrap_err(),
ReportError::BannerTooSmall {
requested: width,
minimum: ReportConfig::MIN_BANNER_WIDTH,
},
"wrong error variant for banner_width={width}"
);
}
}
#[test]
fn test_new_checked_accepts_banner_width_at_minimum() {
let titles = BTreeMap::new();
assert!(
ReportConfig::new_checked(ReportConfig::MIN_BANNER_WIDTH, titles).is_ok(),
"banner_width == MIN_BANNER_WIDTH should be accepted"
);
}
#[test]
fn test_with_config_rejects_banner_width_too_small() {
let cfg = ReportConfig {
banner_width: 1,
..ReportConfig::default()
};
match ReportFormatter::with_config(cfg) {
Err(ReportError::BannerTooSmall { requested, minimum }) => {
assert_eq!(requested, 1);
assert_eq!(minimum, ReportConfig::MIN_BANNER_WIDTH);
}
Err(other) => panic!("expected BannerTooSmall, got {other:?}"),
Ok(_) => panic!("with_config must re-validate banner_width"),
}
}
#[test]
fn test_with_config_rejects_non_ascii_agent_title() {
let mut titles = BTreeMap::new();
titles.insert(
AgentName::Melchior,
("Ménagère".to_string(), "Scientist".to_string()),
);
let cfg = ReportConfig {
banner_width: 52,
agent_titles: titles,
};
match ReportFormatter::with_config(cfg) {
Err(ReportError::NonAsciiTitle { agent, field, .. }) => {
assert_eq!(agent, AgentName::Melchior);
assert_eq!(field, "display_name");
}
Err(other) => panic!("expected NonAsciiTitle, got {other:?}"),
Ok(_) => panic!("with_config must reject non-ASCII titles"),
}
}
}