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;
8use std::fmt::Write;
9
10use crate::consensus::{Condition, ConsensusResult, DedupFinding, Dissent};
11use crate::schema::{AgentName, AgentOutput, Mode};
12
13/// Error returned by `ReportConfig::new_checked` when validation fails.
14#[derive(Debug, Clone, PartialEq, Eq)]
15#[non_exhaustive]
16pub enum ReportError {
17    /// An `agent_titles` value contains non-ASCII characters.
18    ///
19    /// The field name is either `"display_name"` or `"title"`,
20    /// and `agent` identifies which agent's entry failed validation.
21    #[non_exhaustive]
22    NonAsciiTitle {
23        /// The agent whose title failed validation.
24        agent: AgentName,
25        /// Which field: `"display_name"` or `"title"`.
26        field: &'static str,
27        /// The invalid value.
28        value: String,
29    },
30    /// `banner_width` is below the minimum required for meaningful rendering.
31    ///
32    /// The minimum is 8: one `|` border + 6 content bytes + one `|` border.
33    #[non_exhaustive]
34    BannerTooSmall {
35        /// The requested (invalid) width.
36        requested: usize,
37        /// The minimum accepted width.
38        minimum: usize,
39    },
40}
41
42impl fmt::Display for ReportError {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        match self {
45            ReportError::NonAsciiTitle {
46                agent,
47                field,
48                value,
49            } => write!(
50                f,
51                "agent_titles[{:?}].{} contains non-ASCII characters: {:?}",
52                agent, field, value
53            ),
54            ReportError::BannerTooSmall { requested, minimum } => write!(
55                f,
56                "banner_width {requested} is below the minimum of {minimum}"
57            ),
58        }
59    }
60}
61
62/// Default total width of the ASCII banner in bytes, including the two border `|` characters.
63///
64/// Equals [`BANNER_INNER`] + 2. Assumes ASCII content for correct visual alignment.
65pub const BANNER_WIDTH: usize = 52;
66
67/// Default inner width of the ASCII banner in bytes (between the two border `|` characters).
68///
69/// Equals [`BANNER_WIDTH`] - 2. All content lines are padded or truncated to exactly this width.
70pub const BANNER_INNER: usize = BANNER_WIDTH - 2;
71
72/// Left-justified width of the severity icon column (e.g., `[!!!]`, `[!!]`, `[i]`).
73const FINDING_MARKER_WIDTH: usize = 5;
74
75/// Fits `content` into exactly `width` bytes, preserving `preserve_suffix` when truncating.
76///
77/// # Preconditions (ASCII-only input)
78///
79/// - `content` and `preserve_suffix` must be ASCII.
80///   `debug_assert!(content.is_ascii() && preserve_suffix.is_ascii())`
81/// - `width > 0`.
82///   `debug_assert!(width > 0)`
83/// - When `preserve_suffix` is non-empty **and** truncation occurs and the suffix is not
84///   consumed by the fallback path, `content.ends_with(preserve_suffix)` must hold.
85///   Step 3 (suffix-preserving truncation) slices `content` by
86///   `content.len() - preserve_suffix.len()` assuming the tail matches. If violated,
87///   the function still produces a String but the returned prefix is arbitrary (not a
88///   meaningful truncation). This precondition is not checked when `content.len() <= width`
89///   (no truncation) or when the fallback path fires.
90///   `debug_assert!(content.ends_with(preserve_suffix))` — checked at Step 3 entry.
91///
92/// # Post-condition
93///
94/// The byte length of the result is:
95/// - `content.len()` when `content.len() <= width` (no truncation).
96/// - `<= width` when `width >= 4` and truncation occurs. When `content` is ASCII the result is
97///   exactly `width` bytes; for non-ASCII input `floor_char_boundary` may snap the cutoff to a
98///   slightly smaller value (graceful degradation, no panic).
99/// - May exceed `width` when `width < 4` (documented edge case: cannot fit one char + `"..."`).
100///   Callers should ensure `width >= 4` for sensible truncation.
101///
102/// # Algorithm
103///
104/// 1. If `content.len() <= width`, return `content` unchanged.
105/// 2. Fallback (tail-cut) applies when `preserve_suffix` is empty or
106///    `preserve_suffix.len() + 3 >= width`:
107///    `cutoff = max(1, width.saturating_sub(3))`, return `content[..cutoff] + "..."`.
108/// 3. Otherwise prefix-truncate with suffix protected:
109///    `prefix_budget = width - 3 - preserve_suffix.len()`,
110///    return `prefix_source[..prefix_budget] + "..." + preserve_suffix`.
111///
112/// # Panics
113///
114/// In release mode, if preconditions are violated and byte-slice boundaries fall
115/// inside a multi-byte codepoint, this function panics (loud failure, no UB).
116fn fit_content(content: &str, width: usize, preserve_suffix: &str) -> String {
117    debug_assert!(content.is_ascii() && preserve_suffix.is_ascii());
118    debug_assert!(width > 0);
119    debug_assert!(
120        width >= 4,
121        "fit_content requires width >= 4 for sensible truncation; got {}",
122        width
123    );
124
125    const ELLIPSIS: &str = "...";
126
127    // Step 1: no truncation needed
128    if content.len() <= width {
129        return content.to_string();
130    }
131
132    // Step 2: fallback tail-cut when no suffix or suffix + ellipsis fills width
133    if preserve_suffix.is_empty() || preserve_suffix.len() + ELLIPSIS.len() >= width {
134        let cutoff = (width.saturating_sub(ELLIPSIS.len())).max(1);
135        let safe_cutoff = content.floor_char_boundary(cutoff);
136        return format!("{}{}", &content[..safe_cutoff], ELLIPSIS);
137    }
138
139    // Step 3: prefix-truncate with suffix protected.
140    // Precondition: content must end with preserve_suffix so the tail-slice is meaningful.
141    debug_assert!(content.ends_with(preserve_suffix));
142    let prefix_budget = width - ELLIPSIS.len() - preserve_suffix.len();
143    // prefix_source is content with the suffix tail removed
144    let prefix_source = &content[..content.len() - preserve_suffix.len()];
145    let safe_prefix_budget = prefix_source.floor_char_boundary(prefix_budget);
146    format!(
147        "{}{}{}",
148        &prefix_source[..safe_prefix_budget],
149        ELLIPSIS,
150        preserve_suffix
151    )
152}
153
154/// Left-justified width of the markdown severity label column (e.g., `**[CRITICAL]**`).
155///
156/// `**[CRITICAL]**` = 14 chars (fits exactly).
157/// `**[WARNING]**`  = 13 chars (1 trailing space added by padding).
158/// `**[INFO]**`     = 10 chars (4 trailing spaces added by padding).
159const FINDING_SEVERITY_WIDTH: usize = 14;
160
161/// Configuration for the report formatter.
162///
163/// Controls banner width and agent display names/titles.
164///
165/// # ASCII Constraint
166///
167/// The fixed-width banner guarantee (`banner_width` bytes per line) assumes
168/// all displayed content is ASCII. Agent titles, verdict labels, and consensus
169/// strings are ASCII by default. If `agent_titles` contains multi-byte UTF-8
170/// characters, banner lines will have correct byte length but may appear
171/// visually misaligned in terminals because multi-byte characters can occupy
172/// more than one display column.
173#[non_exhaustive]
174#[derive(Debug, Clone)]
175pub struct ReportConfig {
176    /// Total width of the ASCII banner in bytes, including border characters
177    /// (default: 52). Equals character count for ASCII content.
178    pub banner_width: usize,
179    /// Maps agent name to (display_name, title) for report display.
180    /// Values should be ASCII for correct banner alignment.
181    pub agent_titles: BTreeMap<AgentName, (String, String)>,
182}
183
184/// Formats consensus results into ASCII banners and markdown reports.
185///
186/// Generates fixed-width ASCII banners (exactly 52 characters wide per line)
187/// and full markdown reports from agent outputs and consensus results.
188/// The reporting module is pure string formatting -- no async, no I/O.
189pub struct ReportFormatter {
190    config: ReportConfig,
191    banner_inner: usize,
192}
193
194/// Final output struct returned by the orchestrator's `analyze()` method.
195///
196/// Contains all analysis data plus the formatted report string.
197/// Serializes to JSON matching the Python original format.
198#[derive(Debug, Clone, Serialize)]
199pub struct MagiReport {
200    /// The successful agent outputs used in analysis.
201    pub agents: Vec<AgentOutput>,
202    /// The computed consensus result.
203    pub consensus: ConsensusResult,
204    /// The ASCII banner string.
205    pub banner: String,
206    /// The full markdown report string.
207    pub report: String,
208    /// True if fewer than 3 agents succeeded.
209    pub degraded: bool,
210    /// Agents that failed, mapped to their failure reason
211    /// (e.g., `"parse: no valid JSON"`, `"validation: confidence out of range"`).
212    pub failed_agents: BTreeMap<AgentName, String>,
213}
214
215impl ReportConfig {
216    /// Minimum `banner_width` accepted by [`ReportConfig::new_checked`].
217    ///
218    /// Allows `|` + 6 content bytes + `|` = 8 bytes minimum for any meaningful banner.
219    pub const MIN_BANNER_WIDTH: usize = 8;
220
221    /// Creates a `ReportConfig` with validated `banner_width` and ASCII `agent_titles`.
222    ///
223    /// Returns `Err(ReportError::BannerTooSmall)` if `banner_width < 8`.
224    /// Returns `Err(ReportError::NonAsciiTitle)` if any display name or title in
225    /// `agent_titles` contains non-ASCII characters.
226    ///
227    /// This allows `fit_content` to assume ASCII without run-time panic and
228    /// ensures the banner is at least minimally renderable.
229    ///
230    /// # Example
231    ///
232    /// ```rust,ignore
233    /// let mut titles = BTreeMap::new();
234    /// titles.insert(AgentName::Melchior, ("Melchior".to_string(), "Scientist".to_string()));
235    /// let config = ReportConfig::new_checked(52, titles)?;
236    /// ```
237    pub fn new_checked(
238        banner_width: usize,
239        agent_titles: BTreeMap<AgentName, (String, String)>,
240    ) -> Result<Self, ReportError> {
241        if banner_width < Self::MIN_BANNER_WIDTH {
242            return Err(ReportError::BannerTooSmall {
243                requested: banner_width,
244                minimum: Self::MIN_BANNER_WIDTH,
245            });
246        }
247        for (agent, (display_name, title)) in &agent_titles {
248            if !display_name.is_ascii() {
249                return Err(ReportError::NonAsciiTitle {
250                    agent: *agent,
251                    field: "display_name",
252                    value: display_name.clone(),
253                });
254            }
255            if !title.is_ascii() {
256                return Err(ReportError::NonAsciiTitle {
257                    agent: *agent,
258                    field: "title",
259                    value: title.clone(),
260                });
261            }
262        }
263        Ok(Self {
264            banner_width,
265            agent_titles,
266        })
267    }
268}
269
270impl Default for ReportConfig {
271    fn default() -> Self {
272        let mut agent_titles = BTreeMap::new();
273        agent_titles.insert(
274            AgentName::Melchior,
275            ("Melchior".to_string(), "Scientist".to_string()),
276        );
277        agent_titles.insert(
278            AgentName::Balthasar,
279            ("Balthasar".to_string(), "Pragmatist".to_string()),
280        );
281        agent_titles.insert(
282            AgentName::Caspar,
283            ("Caspar".to_string(), "Critic".to_string()),
284        );
285        Self {
286            banner_width: 52,
287            agent_titles,
288        }
289    }
290}
291
292impl ReportFormatter {
293    /// Internal infallible constructor — assumes `config` is already validated.
294    fn from_valid_config(config: ReportConfig) -> Self {
295        let banner_inner = config.banner_width - 2;
296        Self {
297            config,
298            banner_inner,
299        }
300    }
301
302    /// Creates a new formatter with default configuration.
303    ///
304    /// Infallible because [`ReportConfig::default`] always produces a valid
305    /// configuration with an 8-byte-or-larger banner width and ASCII agent titles.
306    pub fn new() -> Self {
307        Self::from_valid_config(ReportConfig::default())
308    }
309
310    /// Creates a new formatter with a validated custom configuration.
311    ///
312    /// Re-runs the same ASCII and minimum-width checks as
313    /// [`ReportConfig::new_checked`], so callers who mutate a `ReportConfig`
314    /// after construction (e.g., via `default()` + field assignment) cannot
315    /// bypass validation.
316    ///
317    /// # Errors
318    ///
319    /// Returns [`ReportError::BannerTooSmall`] if `config.banner_width < 8`.
320    /// Returns [`ReportError::NonAsciiTitle`] if any agent title contains
321    /// non-ASCII characters.
322    ///
323    /// # Example
324    ///
325    /// ```rust,ignore
326    /// let cfg = ReportConfig::default();
327    /// let fmt = ReportFormatter::with_config(cfg).expect("default config is valid");
328    /// ```
329    pub fn with_config(config: ReportConfig) -> Result<Self, ReportError> {
330        if config.banner_width < ReportConfig::MIN_BANNER_WIDTH {
331            return Err(ReportError::BannerTooSmall {
332                requested: config.banner_width,
333                minimum: ReportConfig::MIN_BANNER_WIDTH,
334            });
335        }
336        for (agent, (display_name, title)) in &config.agent_titles {
337            if !display_name.is_ascii() {
338                return Err(ReportError::NonAsciiTitle {
339                    agent: *agent,
340                    field: "display_name",
341                    value: display_name.clone(),
342                });
343            }
344            if !title.is_ascii() {
345                return Err(ReportError::NonAsciiTitle {
346                    agent: *agent,
347                    field: "title",
348                    value: title.clone(),
349                });
350            }
351        }
352        Ok(Self::from_valid_config(config))
353    }
354
355    /// Generates the fixed-width ASCII verdict banner with column-aligned agent labels.
356    ///
357    /// Every line is exactly `banner_width` (52) characters. Agent labels are
358    /// left-justified to the same width (`max_label_len`), so verdict suffixes
359    /// start at the same column for all agents. When content overflows the inner
360    /// width, the label is ellipsized while the verdict suffix is preserved intact.
361    ///
362    /// Structure:
363    /// ```text
364    /// +==================================================+
365    /// |          MAGI SYSTEM -- VERDICT                  |
366    /// +==================================================+
367    /// |  Melchior (Scientist):  APPROVE (90%)            |
368    /// |  Balthasar (Pragmatist): APPROVE (85%)           |
369    /// |  Caspar (Critic):        APPROVE (78%)           |
370    /// +==================================================+
371    /// |  CONSENSUS: GO WITH CAVEATS (2-1)                |
372    /// +==================================================+
373    /// ```
374    pub fn format_banner(&self, agents: &[AgentOutput], consensus: &ConsensusResult) -> String {
375        let mut out = String::new();
376        let sep = self.format_separator();
377
378        // Step 1: compute per-agent labels and max label length
379        let labels: Vec<String> = agents
380            .iter()
381            .map(|a| {
382                let (display_name, title) = self.agent_display(&a.agent);
383                format!("{} ({}):", display_name, title)
384            })
385            .collect();
386        let max_label_len = labels.iter().map(|l| l.chars().count()).max().unwrap_or(0);
387
388        writeln!(out, "{}", sep).ok();
389        writeln!(
390            out,
391            "{}",
392            self.format_line("        MAGI SYSTEM -- VERDICT")
393        )
394        .ok();
395        writeln!(out, "{}", sep).ok();
396
397        // Step 2: render each agent line with aligned label
398        for (agent, label) in agents.iter().zip(labels.iter()) {
399            let pct = (agent.confidence * 100.0).round() as u32;
400            let verdict_suffix = format!(" {} ({}%)", agent.verdict, pct);
401            let content = format!("  {:<max_label_len$}{}", label, verdict_suffix);
402            let fitted = fit_content(&content, self.banner_inner, &verdict_suffix);
403            writeln!(out, "|{:<width$}|", fitted, width = self.banner_inner).ok();
404        }
405
406        // Step 3: consensus line (no suffix protection)
407        writeln!(out, "{}", sep).ok();
408        let consensus_content = format!("  CONSENSUS: {}", consensus.consensus);
409        let fitted_consensus = fit_content(&consensus_content, self.banner_inner, "");
410        writeln!(
411            out,
412            "|{:<width$}|",
413            fitted_consensus,
414            width = self.banner_inner
415        )
416        .ok();
417        write!(out, "{}", sep).ok();
418
419        out
420    }
421
422    /// Generates the pre-analysis initialization banner.
423    ///
424    /// Shows mode, model, and timeout in a fixed-width ASCII box.
425    pub fn format_init_banner(&self, mode: &Mode, model: &str, timeout_secs: u64) -> String {
426        let mut out = String::new();
427        let sep = self.format_separator();
428
429        writeln!(out, "{}", sep).ok();
430        writeln!(
431            out,
432            "{}",
433            self.format_line("        MAGI SYSTEM -- INITIALIZING")
434        )
435        .ok();
436        writeln!(out, "{}", sep).ok();
437        writeln!(
438            out,
439            "{}",
440            self.format_line(&format!("  Mode:     {}", mode))
441        )
442        .ok();
443        writeln!(
444            out,
445            "{}",
446            self.format_line(&format!("  Model:    {}", model))
447        )
448        .ok();
449        writeln!(
450            out,
451            "{}",
452            self.format_line(&format!("  Timeout:  {}s", timeout_secs))
453        )
454        .ok();
455        write!(out, "{}", sep).ok();
456
457        out
458    }
459
460    /// Generates the full markdown report (banner + all sections).
461    ///
462    /// Concatenates sections in order: banner, key findings, dissenting opinion,
463    /// conditions for approval, recommended actions.
464    /// Optional sections are omitted entirely when their data is absent.
465    pub fn format_report(&self, agents: &[AgentOutput], consensus: &ConsensusResult) -> String {
466        let mut out = String::new();
467
468        // 1. Banner
469        out.push_str(&self.format_banner(agents, consensus));
470        out.push('\n');
471
472        // 2. Key Findings (optional)
473        if !consensus.findings.is_empty() {
474            out.push_str(&self.format_findings(&consensus.findings));
475        }
476
477        // 3. Dissenting Opinion (optional)
478        if !consensus.dissent.is_empty() {
479            out.push_str(&self.format_dissent(&consensus.dissent));
480        }
481
482        // 4. Conditions for Approval (optional)
483        if !consensus.conditions.is_empty() {
484            out.push_str(&self.format_conditions(&consensus.conditions));
485        }
486
487        // 5. Recommended Actions
488        out.push_str(&self.format_recommendations(&consensus.recommendations));
489
490        out
491    }
492
493    /// Generates the separator line: `+` + `=` * inner + `+`.
494    fn format_separator(&self) -> String {
495        format!("+{}+", "=".repeat(self.banner_inner))
496    }
497
498    /// Generates a content line: `|` + content padded to inner width + `|`.
499    ///
500    /// Content is left-aligned. If content exceeds inner width, it is truncated.
501    fn format_line(&self, content: &str) -> String {
502        if content.len() > self.banner_inner {
503            let boundary = content.floor_char_boundary(self.banner_inner);
504            format!(
505                "|{:<width$}|",
506                &content[..boundary],
507                width = self.banner_inner
508            )
509        } else {
510            format!("|{:<width$}|", content, width = self.banner_inner)
511        }
512    }
513
514    /// Returns `(display_name, title)` for the given agent.
515    ///
516    /// Looks up in `config.agent_titles` first, falls back to
517    /// `AgentName::display_name()` and `AgentName::title()`.
518    fn agent_display(&self, name: &AgentName) -> (&str, &str) {
519        if let Some((display_name, title)) = self.config.agent_titles.get(name) {
520            (display_name.as_str(), title.as_str())
521        } else {
522            (name.display_name(), name.title())
523        }
524    }
525
526    /// Formats the key findings section.
527    ///
528    /// Renders one line per finding in the Python MAGI reference layout:
529    /// ```text
530    /// {marker:<5} {severity_label:<14} {title} _(from {sources})_
531    /// ```
532    /// The `detail` field is intentionally excluded from the markdown output.
533    /// It remains accessible via the `ConsensusResult::findings[].detail` field
534    /// in the JSON-serialized `MagiReport`.
535    fn format_findings(&self, findings: &[DedupFinding]) -> String {
536        let mut out = String::new();
537        writeln!(out, "\n## Key Findings\n").ok();
538        for finding in findings {
539            let sources = finding
540                .sources
541                .iter()
542                .map(|s| self.agent_display(s).0)
543                .collect::<Vec<_>>()
544                .join(", ");
545            let severity_label = format!("**[{}]**", finding.severity);
546            writeln!(
547                out,
548                "{:<marker_w$} {:<sev_w$} {} _(from {})_",
549                finding.severity.icon(),
550                severity_label,
551                finding.title,
552                sources,
553                marker_w = FINDING_MARKER_WIDTH,
554                sev_w = FINDING_SEVERITY_WIDTH,
555            )
556            .ok();
557        }
558        writeln!(out).ok();
559        out
560    }
561
562    /// Formats the dissenting opinion section.
563    ///
564    /// Emits one line per dissenter: `**Name (Title)**: <summary>`.
565    /// The `reasoning` field is intentionally excluded — it is preserved
566    /// in the JSON output (`Dissent` struct) but not rendered in the report.
567    fn format_dissent(&self, dissent: &[Dissent]) -> String {
568        let mut out = String::new();
569        writeln!(out, "\n## Dissenting Opinion\n").ok();
570        for d in dissent {
571            let (name, title) = self.agent_display(&d.agent);
572            writeln!(out, "**{} ({})**: {}", name, title, d.summary).ok();
573        }
574        writeln!(out).ok();
575        out
576    }
577
578    /// Formats the conditions for approval section.
579    fn format_conditions(&self, conditions: &[Condition]) -> String {
580        let mut out = String::new();
581        writeln!(out, "\n## Conditions for Approval\n").ok();
582        for c in conditions {
583            let (display_name, _) = self.agent_display(&c.agent);
584            writeln!(out, "- **{}**: {}", display_name, c.condition).ok();
585        }
586        writeln!(out).ok();
587        out
588    }
589
590    /// Formats the recommended actions section.
591    fn format_recommendations(&self, recommendations: &BTreeMap<AgentName, String>) -> String {
592        let mut out = String::new();
593        writeln!(out, "\n## Recommended Actions\n").ok();
594        for (name, rec) in recommendations {
595            let (display_name, title) = self.agent_display(name);
596            writeln!(out, "- **{}** ({}): {}", display_name, title, rec).ok();
597        }
598        out
599    }
600}
601
602impl Default for ReportFormatter {
603    fn default() -> Self {
604        Self::new()
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611    use crate::consensus::*;
612    use crate::schema::*;
613
614    /// Helper: build a minimal AgentOutput for testing.
615    fn make_agent(
616        name: AgentName,
617        verdict: Verdict,
618        confidence: f64,
619        summary: &str,
620        reasoning: &str,
621        recommendation: &str,
622    ) -> AgentOutput {
623        AgentOutput {
624            agent: name,
625            verdict,
626            confidence,
627            summary: summary.to_string(),
628            reasoning: reasoning.to_string(),
629            findings: vec![],
630            recommendation: recommendation.to_string(),
631        }
632    }
633
634    /// Helper: build a minimal ConsensusResult for testing.
635    fn make_consensus(
636        label: &str,
637        verdict: Verdict,
638        confidence: f64,
639        score: f64,
640        agents: &[&AgentOutput],
641    ) -> ConsensusResult {
642        let mut votes = BTreeMap::new();
643        let mut recommendations = BTreeMap::new();
644        for a in agents {
645            votes.insert(a.agent, a.verdict);
646            recommendations.insert(a.agent, a.recommendation.clone());
647        }
648
649        let majority_summary = agents
650            .iter()
651            .filter(|a| a.effective_verdict() == verdict.effective())
652            .map(|a| format!("{}: {}", a.agent.display_name(), a.summary))
653            .collect::<Vec<_>>()
654            .join(" | ");
655
656        ConsensusResult {
657            consensus: label.to_string(),
658            consensus_verdict: verdict,
659            confidence,
660            score,
661            agent_count: agents.len(),
662            votes,
663            majority_summary,
664            dissent: vec![],
665            findings: vec![],
666            conditions: vec![],
667            recommendations,
668        }
669    }
670
671    // -- BDD Scenario 15: banner width --
672
673    /// All banner lines are exactly 52 characters wide.
674    #[test]
675    fn test_banner_lines_are_exactly_52_chars_wide() {
676        let m = make_agent(
677            AgentName::Melchior,
678            Verdict::Approve,
679            0.9,
680            "Good",
681            "R",
682            "Rec",
683        );
684        let b = make_agent(
685            AgentName::Balthasar,
686            Verdict::Conditional,
687            0.85,
688            "Ok",
689            "R",
690            "Rec",
691        );
692        let c = make_agent(AgentName::Caspar, Verdict::Reject, 0.78, "Bad", "R", "Rec");
693        let agents = vec![m.clone(), b.clone(), c.clone()];
694        let consensus = make_consensus(
695            "GO WITH CAVEATS",
696            Verdict::Approve,
697            0.85,
698            0.33,
699            &[&m, &b, &c],
700        );
701
702        let formatter = ReportFormatter::new();
703        let banner = formatter.format_banner(&agents, &consensus);
704
705        for line in banner.lines() {
706            if !line.is_empty() {
707                assert_eq!(line.len(), 52, "Line is not 52 chars: '{}'", line);
708            }
709        }
710    }
711
712    /// Banner with long consensus label still fits 52 chars.
713    #[test]
714    fn test_banner_with_long_content_fits_52_chars() {
715        let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
716        let b = make_agent(
717            AgentName::Balthasar,
718            Verdict::Approve,
719            0.85,
720            "S",
721            "R",
722            "Rec",
723        );
724        let c = make_agent(AgentName::Caspar, Verdict::Approve, 0.95, "S", "R", "Rec");
725        let agents = vec![m.clone(), b.clone(), c.clone()];
726        let consensus = make_consensus("STRONG GO", Verdict::Approve, 0.9, 1.0, &[&m, &b, &c]);
727
728        let formatter = ReportFormatter::new();
729        let banner = formatter.format_banner(&agents, &consensus);
730
731        for line in banner.lines() {
732            if !line.is_empty() {
733                assert_eq!(line.len(), 52, "Line is not 52 chars: '{}'", line);
734            }
735        }
736    }
737
738    // -- BDD Scenario 16: report sections --
739
740    /// Report with mixed consensus contains 4 markdown headers (no Consensus Summary).
741    #[test]
742    fn test_report_with_mixed_consensus_contains_all_headers() {
743        let m = make_agent(
744            AgentName::Melchior,
745            Verdict::Approve,
746            0.9,
747            "Good code",
748            "Solid",
749            "Merge",
750        );
751        let b = make_agent(
752            AgentName::Balthasar,
753            Verdict::Conditional,
754            0.85,
755            "Needs work",
756            "Issues",
757            "Fix first",
758        );
759        let c = make_agent(
760            AgentName::Caspar,
761            Verdict::Reject,
762            0.78,
763            "Problems",
764            "Risky",
765            "Reject",
766        );
767        let agents = vec![m.clone(), b.clone(), c.clone()];
768
769        let mut consensus = make_consensus(
770            "GO WITH CAVEATS",
771            Verdict::Approve,
772            0.85,
773            0.33,
774            &[&m, &b, &c],
775        );
776        consensus.dissent = vec![Dissent {
777            agent: AgentName::Caspar,
778            summary: "Problems found".to_string(),
779            reasoning: "Risk is too high".to_string(),
780        }];
781        consensus.conditions = vec![Condition {
782            agent: AgentName::Balthasar,
783            condition: "Fix first".to_string(),
784        }];
785        consensus.findings = vec![DedupFinding {
786            severity: Severity::Warning,
787            title: "Test finding".to_string(),
788            detail: "Detail here".to_string(),
789            sources: vec![AgentName::Melchior, AgentName::Caspar],
790        }];
791
792        let formatter = ReportFormatter::new();
793        let report = formatter.format_report(&agents, &consensus);
794
795        assert!(
796            !report.contains("## Consensus Summary"),
797            "Consensus Summary must not appear"
798        );
799        assert!(report.contains("## Key Findings"), "Missing Key Findings");
800        assert!(
801            report.contains("## Dissenting Opinion"),
802            "Missing Dissenting Opinion"
803        );
804        assert!(
805            report.contains("## Conditions for Approval"),
806            "Missing Conditions"
807        );
808        assert!(
809            report.contains("## Recommended Actions"),
810            "Missing Recommended Actions"
811        );
812    }
813
814    /// Report does not contain the "## Consensus Summary" heading.
815    #[test]
816    fn test_report_does_not_contain_consensus_summary_heading() {
817        let m = make_agent(
818            AgentName::Melchior,
819            Verdict::Approve,
820            0.9,
821            "Good",
822            "R",
823            "Merge",
824        );
825        let b = make_agent(
826            AgentName::Balthasar,
827            Verdict::Approve,
828            0.85,
829            "Good",
830            "R",
831            "Merge",
832        );
833        let c = make_agent(
834            AgentName::Caspar,
835            Verdict::Approve,
836            0.95,
837            "Good",
838            "R",
839            "Merge",
840        );
841        let agents = vec![m.clone(), b.clone(), c.clone()];
842        let consensus = make_consensus("STRONG GO", Verdict::Approve, 0.9, 1.0, &[&m, &b, &c]);
843
844        let formatter = ReportFormatter::new();
845        let report = formatter.format_report(&agents, &consensus);
846
847        assert!(!report.contains("## Consensus Summary"));
848    }
849
850    /// Report section order: banner ("+====") before any optional sections,
851    /// with no "## Consensus Summary" between them.
852    #[test]
853    fn test_report_section_order_banner_then_findings_or_dissent_or_conditions_or_actions() {
854        let m = make_agent(
855            AgentName::Melchior,
856            Verdict::Approve,
857            0.9,
858            "Good code",
859            "Solid",
860            "Merge",
861        );
862        let b = make_agent(
863            AgentName::Balthasar,
864            Verdict::Conditional,
865            0.85,
866            "Needs work",
867            "Issues",
868            "Fix first",
869        );
870        let c = make_agent(
871            AgentName::Caspar,
872            Verdict::Reject,
873            0.78,
874            "Problems",
875            "Risky",
876            "Reject",
877        );
878        let agents = vec![m.clone(), b.clone(), c.clone()];
879
880        let mut consensus = make_consensus(
881            "GO WITH CAVEATS",
882            Verdict::Approve,
883            0.85,
884            0.33,
885            &[&m, &b, &c],
886        );
887        consensus.findings = vec![DedupFinding {
888            severity: Severity::Warning,
889            title: "Test finding".to_string(),
890            detail: "Detail here".to_string(),
891            sources: vec![AgentName::Melchior],
892        }];
893        consensus.dissent = vec![Dissent {
894            agent: AgentName::Caspar,
895            summary: "Problems found".to_string(),
896            reasoning: "Risk is too high".to_string(),
897        }];
898        consensus.conditions = vec![Condition {
899            agent: AgentName::Balthasar,
900            condition: "Fix first".to_string(),
901        }];
902
903        let formatter = ReportFormatter::new();
904        let report = formatter.format_report(&agents, &consensus);
905
906        // No Consensus Summary anywhere
907        assert!(!report.contains("## Consensus Summary"));
908
909        // Banner border must appear before all section headings
910        let banner_pos = report.find("+====").expect("banner border not found");
911        let actions_pos = report
912            .find("## Recommended Actions")
913            .expect("Recommended Actions not found");
914        let findings_pos = report
915            .find("## Key Findings")
916            .expect("Key Findings not found");
917        let dissent_pos = report
918            .find("## Dissenting Opinion")
919            .expect("Dissenting Opinion not found");
920        let conditions_pos = report
921            .find("## Conditions for Approval")
922            .expect("Conditions not found");
923
924        assert!(
925            banner_pos < findings_pos,
926            "banner must come before Key Findings"
927        );
928        assert!(
929            banner_pos < dissent_pos,
930            "banner must come before Dissenting Opinion"
931        );
932        assert!(
933            banner_pos < conditions_pos,
934            "banner must come before Conditions"
935        );
936        assert!(
937            banner_pos < actions_pos,
938            "banner must come before Recommended Actions"
939        );
940
941        // Section order: findings < dissent < conditions < actions
942        assert!(
943            findings_pos < dissent_pos,
944            "Key Findings must come before Dissenting Opinion"
945        );
946        assert!(
947            dissent_pos < conditions_pos,
948            "Dissenting Opinion must come before Conditions"
949        );
950        assert!(
951            conditions_pos < actions_pos,
952            "Conditions must come before Recommended Actions"
953        );
954    }
955
956    /// Report without dissent omits "## Dissenting Opinion".
957    #[test]
958    fn test_report_without_dissent_omits_dissent_section() {
959        let m = make_agent(
960            AgentName::Melchior,
961            Verdict::Approve,
962            0.9,
963            "Good",
964            "R",
965            "Merge",
966        );
967        let b = make_agent(
968            AgentName::Balthasar,
969            Verdict::Approve,
970            0.85,
971            "Good",
972            "R",
973            "Merge",
974        );
975        let c = make_agent(
976            AgentName::Caspar,
977            Verdict::Approve,
978            0.95,
979            "Good",
980            "R",
981            "Merge",
982        );
983        let agents = vec![m.clone(), b.clone(), c.clone()];
984        let consensus = make_consensus("STRONG GO", Verdict::Approve, 0.9, 1.0, &[&m, &b, &c]);
985
986        let formatter = ReportFormatter::new();
987        let report = formatter.format_report(&agents, &consensus);
988
989        assert!(!report.contains("## Dissenting Opinion"));
990    }
991
992    /// Report without conditions omits "## Conditions for Approval".
993    #[test]
994    fn test_report_without_conditions_omits_conditions_section() {
995        let m = make_agent(
996            AgentName::Melchior,
997            Verdict::Approve,
998            0.9,
999            "Good",
1000            "R",
1001            "Merge",
1002        );
1003        let b = make_agent(
1004            AgentName::Balthasar,
1005            Verdict::Approve,
1006            0.85,
1007            "Good",
1008            "R",
1009            "Merge",
1010        );
1011        let c = make_agent(
1012            AgentName::Caspar,
1013            Verdict::Approve,
1014            0.95,
1015            "Good",
1016            "R",
1017            "Merge",
1018        );
1019        let agents = vec![m.clone(), b.clone(), c.clone()];
1020        let consensus = make_consensus("STRONG GO", Verdict::Approve, 0.9, 1.0, &[&m, &b, &c]);
1021
1022        let formatter = ReportFormatter::new();
1023        let report = formatter.format_report(&agents, &consensus);
1024
1025        assert!(!report.contains("## Conditions for Approval"));
1026    }
1027
1028    /// Report without findings omits "## Key Findings".
1029    #[test]
1030    fn test_report_without_findings_omits_findings_section() {
1031        let m = make_agent(
1032            AgentName::Melchior,
1033            Verdict::Approve,
1034            0.9,
1035            "Good",
1036            "R",
1037            "Merge",
1038        );
1039        let b = make_agent(
1040            AgentName::Balthasar,
1041            Verdict::Approve,
1042            0.85,
1043            "Good",
1044            "R",
1045            "Merge",
1046        );
1047        let c = make_agent(
1048            AgentName::Caspar,
1049            Verdict::Approve,
1050            0.95,
1051            "Good",
1052            "R",
1053            "Merge",
1054        );
1055        let agents = vec![m.clone(), b.clone(), c.clone()];
1056        let consensus = make_consensus("STRONG GO", Verdict::Approve, 0.9, 1.0, &[&m, &b, &c]);
1057
1058        let formatter = ReportFormatter::new();
1059        let report = formatter.format_report(&agents, &consensus);
1060
1061        assert!(!report.contains("## Key Findings"));
1062    }
1063
1064    // -- Banner formatting --
1065
1066    /// format_banner generates correct ASCII art structure.
1067    #[test]
1068    fn test_format_banner_has_correct_structure() {
1069        let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
1070        let b = make_agent(AgentName::Balthasar, Verdict::Reject, 0.7, "S", "R", "Rec");
1071        let c = make_agent(AgentName::Caspar, Verdict::Reject, 0.8, "S", "R", "Rec");
1072        let agents = vec![m.clone(), b.clone(), c.clone()];
1073        let consensus = make_consensus("HOLD (2-1)", Verdict::Reject, 0.7, -0.33, &[&m, &b, &c]);
1074
1075        let formatter = ReportFormatter::new();
1076        let banner = formatter.format_banner(&agents, &consensus);
1077
1078        assert!(banner.contains("MAGI SYSTEM -- VERDICT"));
1079        assert!(banner.contains("Melchior (Scientist)"));
1080        assert!(banner.contains("APPROVE"));
1081        assert!(banner.contains("CONSENSUS:"));
1082        assert!(banner.contains("HOLD (2-1)"));
1083    }
1084
1085    /// format_init_banner shows mode, model, timeout.
1086    #[test]
1087    fn test_format_init_banner_shows_mode_model_timeout() {
1088        let formatter = ReportFormatter::new();
1089        let banner = formatter.format_init_banner(&Mode::CodeReview, "claude-sonnet", 300);
1090
1091        assert!(banner.contains("code-review"), "Missing mode");
1092        assert!(banner.contains("claude-sonnet"), "Missing model");
1093        assert!(banner.contains("300"), "Missing timeout");
1094
1095        for line in banner.lines() {
1096            if !line.is_empty() {
1097                assert_eq!(line.len(), 52, "Init banner line not 52 chars: '{}'", line);
1098            }
1099        }
1100    }
1101
1102    /// Separator line is "+" + "=" * 50 + "+".
1103    #[test]
1104    fn test_separator_format() {
1105        let formatter = ReportFormatter::new();
1106        let banner = formatter.format_init_banner(&Mode::Analysis, "test", 60);
1107        let sep = format!("+{}+", "=".repeat(50));
1108
1109        assert!(banner.contains(&sep), "Missing separator line");
1110        assert_eq!(sep.len(), 52);
1111    }
1112
1113    /// Agent line shows "Name (Title):" aligned to max label width, then " VERDICT (NN%)".
1114    ///
1115    /// With default config:
1116    /// - "Melchior (Scientist):"   = 21 chars → padded to 23 → 2 trailing spaces before verdict
1117    /// - "Balthasar (Pragmatist):" = 23 chars → max, no padding
1118    /// - "Caspar (Critic):"        = 17 chars → padded to 23 → 6 trailing spaces before verdict
1119    ///
1120    /// Content = "  {label:<23}{verdict_suffix}" where verdict_suffix starts with one space:
1121    /// - Melchior: "  Melchior (Scientist):   APPROVE (90%)"  (2+21+2 padding+1 from suffix)
1122    /// - Caspar:   "  Caspar (Critic):         APPROVE (78%)" (2+17+6 padding+1 from suffix)
1123    #[test]
1124    fn test_agent_line_format() {
1125        let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
1126        let b = make_agent(
1127            AgentName::Balthasar,
1128            Verdict::Approve,
1129            0.85,
1130            "S",
1131            "R",
1132            "Rec",
1133        );
1134        let c = make_agent(AgentName::Caspar, Verdict::Approve, 0.78, "S", "R", "Rec");
1135        let agents = vec![m.clone(), b.clone(), c.clone()];
1136        let consensus = make_consensus("STRONG GO", Verdict::Approve, 0.9, 1.0, &[&m, &b, &c]);
1137
1138        let formatter = ReportFormatter::new();
1139        let banner = formatter.format_banner(&agents, &consensus);
1140
1141        // "Melchior (Scientist):" = 21 chars, padded to 23 (max): 2 extra spaces.
1142        // verdict_suffix starts with 1 space → total 3 spaces between colon and APPROVE.
1143        assert!(banner.contains("Melchior (Scientist):   APPROVE (90%)"));
1144        // "Caspar (Critic):" = 17 chars, padded to 23: 6 extra spaces.
1145        // verdict_suffix starts with 1 space → total 7 spaces between colon and APPROVE.
1146        assert!(banner.contains("Caspar (Critic):        APPROVE (78%)"));
1147    }
1148
1149    // -- Report content sections --
1150
1151    /// Findings section shows icon + severity + title + sources (detail is excluded from markdown).
1152    #[test]
1153    fn test_findings_section_format() {
1154        let m = make_agent(
1155            AgentName::Melchior,
1156            Verdict::Approve,
1157            0.9,
1158            "Good",
1159            "R",
1160            "Merge",
1161        );
1162        let agents = vec![m.clone()];
1163        let mut consensus = make_consensus("GO (1-0)", Verdict::Approve, 0.9, 1.0, &[&m]);
1164        consensus.findings = vec![DedupFinding {
1165            severity: Severity::Critical,
1166            title: "SQL injection risk".to_string(),
1167            detail: "User input not sanitized".to_string(),
1168            sources: vec![AgentName::Melchior, AgentName::Caspar],
1169        }];
1170
1171        let formatter = ReportFormatter::new();
1172        let report = formatter.format_report(&agents, &consensus);
1173
1174        assert!(report.contains("[!!!]"), "Missing critical icon");
1175        assert!(report.contains("[CRITICAL]"), "Missing severity label");
1176        assert!(report.contains("SQL injection risk"), "Missing title");
1177        assert!(report.contains("Melchior"), "Missing source agent");
1178        assert!(report.contains("Caspar"), "Missing source agent");
1179        // detail is preserved in JSON but not rendered in markdown
1180        assert!(
1181            !report.contains("User input not sanitized"),
1182            "Detail must not appear in markdown report"
1183        );
1184    }
1185
1186    /// Dissent section shows agent name and summary; reasoning is not rendered.
1187    #[test]
1188    fn test_dissent_section_format() {
1189        let m = make_agent(
1190            AgentName::Melchior,
1191            Verdict::Approve,
1192            0.9,
1193            "Good",
1194            "R",
1195            "Merge",
1196        );
1197        let c = make_agent(
1198            AgentName::Caspar,
1199            Verdict::Reject,
1200            0.8,
1201            "Bad",
1202            "Too risky",
1203            "Reject",
1204        );
1205        let agents = vec![m.clone(), c.clone()];
1206        let mut consensus = make_consensus("GO (1-1)", Verdict::Approve, 0.8, 0.0, &[&m, &c]);
1207        consensus.dissent = vec![Dissent {
1208            agent: AgentName::Caspar,
1209            summary: "Too many issues".to_string(),
1210            reasoning: "The code has critical flaws".to_string(),
1211        }];
1212
1213        let formatter = ReportFormatter::new();
1214        let report = formatter.format_report(&agents, &consensus);
1215
1216        assert!(report.contains("Caspar"), "Missing dissenting agent name");
1217        assert!(report.contains("Critic"), "Missing dissenting agent title");
1218        assert!(
1219            report.contains("Too many issues"),
1220            "Missing dissent summary"
1221        );
1222        // Reasoning is preserved in the Dissent struct (JSON) but not rendered in the report.
1223        assert!(
1224            !report.contains("The code has critical flaws"),
1225            "Dissent reasoning must not appear in the rendered report"
1226        );
1227    }
1228
1229    /// Conditions section shows bulleted list with agent names.
1230    #[test]
1231    fn test_conditions_section_format() {
1232        let m = make_agent(
1233            AgentName::Melchior,
1234            Verdict::Approve,
1235            0.9,
1236            "Good",
1237            "R",
1238            "Merge",
1239        );
1240        let b = make_agent(
1241            AgentName::Balthasar,
1242            Verdict::Conditional,
1243            0.85,
1244            "Ok",
1245            "R",
1246            "Fix tests",
1247        );
1248        let agents = vec![m.clone(), b.clone()];
1249        let mut consensus =
1250            make_consensus("GO WITH CAVEATS", Verdict::Approve, 0.85, 0.75, &[&m, &b]);
1251        consensus.conditions = vec![Condition {
1252            agent: AgentName::Balthasar,
1253            condition: "Fix tests first".to_string(),
1254        }];
1255
1256        let formatter = ReportFormatter::new();
1257        let report = formatter.format_report(&agents, &consensus);
1258
1259        assert!(
1260            report.contains("- **Balthasar**:"),
1261            "Missing bullet with agent name"
1262        );
1263        assert!(report.contains("Fix tests first"), "Missing condition text");
1264    }
1265
1266    /// Recommendations section shows per-agent recommendations.
1267    #[test]
1268    fn test_recommendations_section_format() {
1269        let m = make_agent(
1270            AgentName::Melchior,
1271            Verdict::Approve,
1272            0.9,
1273            "Good",
1274            "R",
1275            "Merge immediately",
1276        );
1277        let b = make_agent(
1278            AgentName::Balthasar,
1279            Verdict::Approve,
1280            0.85,
1281            "Good",
1282            "R",
1283            "Ship it",
1284        );
1285        let agents = vec![m.clone(), b.clone()];
1286        let consensus = make_consensus("GO (2-0)", Verdict::Approve, 0.9, 1.0, &[&m, &b]);
1287
1288        let formatter = ReportFormatter::new();
1289        let report = formatter.format_report(&agents, &consensus);
1290
1291        assert!(
1292            report.contains("Merge immediately"),
1293            "Missing Melchior recommendation"
1294        );
1295        assert!(
1296            report.contains("Ship it"),
1297            "Missing Balthasar recommendation"
1298        );
1299    }
1300
1301    /// Agent display falls back to AgentName methods when not in config.
1302    #[test]
1303    fn test_agent_display_fallback_to_agent_name_methods() {
1304        let config = ReportConfig {
1305            banner_width: 52,
1306            agent_titles: BTreeMap::new(),
1307        };
1308        let formatter = ReportFormatter::with_config(config).unwrap();
1309
1310        let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
1311        let agents = vec![m.clone()];
1312        let consensus = make_consensus("GO (1-0)", Verdict::Approve, 0.9, 1.0, &[&m]);
1313
1314        let banner = formatter.format_banner(&agents, &consensus);
1315        assert!(
1316            banner.contains("Melchior"),
1317            "Should use AgentName::display_name()"
1318        );
1319    }
1320
1321    // -- MagiReport tests --
1322
1323    /// MagiReport serializes to JSON.
1324    #[test]
1325    fn test_magi_report_serializes_to_json() {
1326        let m = make_agent(
1327            AgentName::Melchior,
1328            Verdict::Approve,
1329            0.9,
1330            "Good",
1331            "R",
1332            "Merge",
1333        );
1334        let agents = vec![m.clone()];
1335        let consensus = make_consensus("GO (1-0)", Verdict::Approve, 0.9, 1.0, &[&m]);
1336
1337        let report = MagiReport {
1338            agents,
1339            consensus,
1340            banner: "banner".to_string(),
1341            report: "report".to_string(),
1342            degraded: false,
1343            failed_agents: BTreeMap::new(),
1344        };
1345
1346        let json = serde_json::to_string(&report).expect("serialize");
1347        assert!(json.contains("\"consensus\""));
1348        assert!(json.contains("\"agents\""));
1349        assert!(json.contains("\"degraded\""));
1350    }
1351
1352    /// degraded=false when all 3 agents succeed.
1353    #[test]
1354    fn test_magi_report_not_degraded_with_three_agents() {
1355        let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
1356        let b = make_agent(
1357            AgentName::Balthasar,
1358            Verdict::Approve,
1359            0.85,
1360            "S",
1361            "R",
1362            "Rec",
1363        );
1364        let c = make_agent(AgentName::Caspar, Verdict::Approve, 0.95, "S", "R", "Rec");
1365        let agents = vec![m.clone(), b.clone(), c.clone()];
1366        let consensus = make_consensus("STRONG GO", Verdict::Approve, 0.9, 1.0, &[&m, &b, &c]);
1367
1368        let report = MagiReport {
1369            agents,
1370            consensus,
1371            banner: String::new(),
1372            report: String::new(),
1373            degraded: false,
1374            failed_agents: BTreeMap::new(),
1375        };
1376
1377        assert!(!report.degraded);
1378        assert!(report.failed_agents.is_empty());
1379    }
1380
1381    /// degraded=true with failed_agents populated when agent fails.
1382    #[test]
1383    fn test_magi_report_degraded_with_failed_agents() {
1384        let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
1385        let b = make_agent(
1386            AgentName::Balthasar,
1387            Verdict::Approve,
1388            0.85,
1389            "S",
1390            "R",
1391            "Rec",
1392        );
1393        let agents = vec![m.clone(), b.clone()];
1394        let consensus = make_consensus("GO (2-0)", Verdict::Approve, 0.9, 1.0, &[&m, &b]);
1395
1396        let report = MagiReport {
1397            agents,
1398            consensus,
1399            banner: String::new(),
1400            report: String::new(),
1401            degraded: true,
1402            failed_agents: BTreeMap::from([(AgentName::Caspar, "timeout".to_string())]),
1403        };
1404
1405        assert!(report.degraded);
1406        assert_eq!(report.failed_agents.len(), 1);
1407        assert!(report.failed_agents.contains_key(&AgentName::Caspar));
1408    }
1409
1410    // -- S07: Dissent rendered as single line (summary-only) --
1411
1412    /// Two dissenters produce exactly two `**Name (Title)**:` header lines.
1413    #[test]
1414    fn test_dissent_shows_one_line_per_dissenter() {
1415        let formatter = ReportFormatter::new();
1416        let dissent = vec![
1417            Dissent {
1418                agent: AgentName::Caspar,
1419                summary: "Summary for Caspar".to_string(),
1420                reasoning: "Reasoning for Caspar that is long and detailed".to_string(),
1421            },
1422            Dissent {
1423                agent: AgentName::Balthasar,
1424                summary: "Summary for Balthasar".to_string(),
1425                reasoning: "Reasoning for Balthasar that is very lengthy".to_string(),
1426            },
1427        ];
1428
1429        let output = formatter.format_dissent(&dissent);
1430
1431        // Count lines matching the "**Name (Title)**:" pattern
1432        let header_lines: Vec<&str> = output
1433            .lines()
1434            .filter(|l| l.starts_with("**") && l.contains(")**:"))
1435            .collect();
1436        assert_eq!(
1437            header_lines.len(),
1438            2,
1439            "Expected exactly 2 dissenter header lines, got {}: {:?}",
1440            header_lines.len(),
1441            header_lines
1442        );
1443    }
1444
1445    /// Each dissenter line contains the summary text but NOT the reasoning text.
1446    #[test]
1447    fn test_dissent_line_contains_summary_not_reasoning() {
1448        let formatter = ReportFormatter::new();
1449        let dissent = vec![Dissent {
1450            agent: AgentName::Caspar,
1451            summary: "Unique summary text here".to_string(),
1452            reasoning: "Unique reasoning text should not appear".to_string(),
1453        }];
1454
1455        let output = formatter.format_dissent(&dissent);
1456
1457        assert!(
1458            output.contains("Unique summary text here"),
1459            "Output must contain the summary"
1460        );
1461        assert!(
1462            !output.contains("Unique reasoning text should not appear"),
1463            "Output must NOT contain the reasoning"
1464        );
1465    }
1466
1467    /// The dissent section ends with a blank line after the last dissenter's line.
1468    #[test]
1469    fn test_dissent_section_has_blank_line_after() {
1470        let formatter = ReportFormatter::new();
1471        let dissent = vec![Dissent {
1472            agent: AgentName::Caspar,
1473            summary: "Some summary".to_string(),
1474            reasoning: "Some reasoning".to_string(),
1475        }];
1476
1477        let output = formatter.format_dissent(&dissent);
1478
1479        // The output must end with "\n\n" (dissenter line + trailing blank line)
1480        assert!(
1481            output.ends_with("\n\n"),
1482            "Dissent section must end with a blank line (\\n\\n), got: {:?}",
1483            output
1484        );
1485    }
1486
1487    // -- S08: Finding line layout (single line, fixed-width columns, no detail) --
1488
1489    /// Finding detail text must not appear in the rendered findings section.
1490    ///
1491    /// The detail field is preserved in the JSON output (`DedupFinding::detail`)
1492    /// but is intentionally excluded from the markdown report.
1493    #[test]
1494    fn test_findings_line_does_not_contain_detail_text() {
1495        let formatter = ReportFormatter::new();
1496        let findings = vec![DedupFinding {
1497            severity: Severity::Critical,
1498            title: "SQL injection in query builder".to_string(),
1499            detail: "UNIQUE_DETAIL_SENTINEL_XYZ".to_string(),
1500            sources: vec![AgentName::Melchior],
1501        }];
1502
1503        let output = formatter.format_findings(&findings);
1504
1505        assert!(
1506            !output.contains("UNIQUE_DETAIL_SENTINEL_XYZ"),
1507            "Detail text must not appear in the markdown findings output"
1508        );
1509    }
1510
1511    /// Marker column is exactly 5 characters, left-justified.
1512    ///
1513    /// The first token before the separator space must be exactly 5 bytes
1514    /// (the icon padded to `FINDING_MARKER_WIDTH`).
1515    #[test]
1516    fn test_findings_line_marker_column_is_5_chars_left_justified() {
1517        let formatter = ReportFormatter::new();
1518        let findings = vec![
1519            DedupFinding {
1520                severity: Severity::Critical,
1521                title: "Critical finding".to_string(),
1522                detail: "detail".to_string(),
1523                sources: vec![AgentName::Melchior],
1524            },
1525            DedupFinding {
1526                severity: Severity::Warning,
1527                title: "Warning finding".to_string(),
1528                detail: "detail".to_string(),
1529                sources: vec![AgentName::Balthasar],
1530            },
1531            DedupFinding {
1532                severity: Severity::Info,
1533                title: "Info finding".to_string(),
1534                detail: "detail".to_string(),
1535                sources: vec![AgentName::Caspar],
1536            },
1537        ];
1538
1539        let output = formatter.format_findings(&findings);
1540
1541        for line in output.lines() {
1542            if line.starts_with('[') {
1543                // The marker column occupies positions 0..5 (5 chars), then a space at index 5.
1544                let marker_col = &line[..5];
1545                assert_eq!(
1546                    marker_col.len(),
1547                    5,
1548                    "Marker column must be 5 chars; got {:?} in line {:?}",
1549                    marker_col,
1550                    line
1551                );
1552                assert_eq!(
1553                    line.chars().nth(5),
1554                    Some(' '),
1555                    "Column 5 must be a space separator; got {:?} in line {:?}",
1556                    line.chars().nth(5),
1557                    line
1558                );
1559            }
1560        }
1561    }
1562
1563    /// Severity label column is exactly 14 characters wide (padded with trailing spaces).
1564    ///
1565    /// The severity label token (chars 6..20) must be exactly 14 bytes,
1566    /// with the markdown-decorated label left-justified inside that width.
1567    #[test]
1568    fn test_findings_line_severity_label_column_is_14_chars_left_justified() {
1569        let formatter = ReportFormatter::new();
1570        let findings = vec![
1571            DedupFinding {
1572                severity: Severity::Critical,
1573                title: "A".to_string(),
1574                detail: "d".to_string(),
1575                sources: vec![AgentName::Melchior],
1576            },
1577            DedupFinding {
1578                severity: Severity::Warning,
1579                title: "B".to_string(),
1580                detail: "d".to_string(),
1581                sources: vec![AgentName::Balthasar],
1582            },
1583            DedupFinding {
1584                severity: Severity::Info,
1585                title: "C".to_string(),
1586                detail: "d".to_string(),
1587                sources: vec![AgentName::Caspar],
1588            },
1589        ];
1590
1591        let output = formatter.format_findings(&findings);
1592
1593        for line in output.lines() {
1594            if line.starts_with('[') {
1595                // Layout: [marker:5] [space] [severity_label:14] [space] ...
1596                // Positions: 0-4 = marker (5 chars), 5 = space, 6-19 = severity (14 chars), 20 = space
1597                assert!(
1598                    line.len() >= 21,
1599                    "Line too short to contain marker+severity columns: {:?}",
1600                    line
1601                );
1602                let severity_col = &line[6..20];
1603                assert_eq!(
1604                    severity_col.len(),
1605                    14,
1606                    "Severity label column must be 14 chars; got {:?} in line {:?}",
1607                    severity_col,
1608                    line
1609                );
1610                assert_eq!(
1611                    line.chars().nth(20),
1612                    Some(' '),
1613                    "Column 20 must be a space separator after severity; got {:?} in line {:?}",
1614                    line.chars().nth(20),
1615                    line
1616                );
1617            }
1618        }
1619    }
1620
1621    /// Full finding line matches the Python MAGI reference layout byte-for-byte.
1622    ///
1623    /// Format: `{marker:<5} {severity_label:<14} {title} _(from {sources})_`
1624    #[test]
1625    fn test_findings_line_matches_python_layout_exactly() {
1626        let formatter = ReportFormatter::new();
1627        let findings = vec![
1628            DedupFinding {
1629                severity: Severity::Critical,
1630                title: "Test title".to_string(),
1631                detail: "ignored detail".to_string(),
1632                sources: vec![AgentName::Melchior, AgentName::Caspar],
1633            },
1634            DedupFinding {
1635                severity: Severity::Warning,
1636                title: "Missing retry logic".to_string(),
1637                detail: "ignored detail".to_string(),
1638                sources: vec![AgentName::Balthasar],
1639            },
1640            DedupFinding {
1641                severity: Severity::Info,
1642                title: "Consider timeout".to_string(),
1643                detail: "ignored detail".to_string(),
1644                sources: vec![AgentName::Caspar],
1645            },
1646        ];
1647
1648        let output = formatter.format_findings(&findings);
1649
1650        // Exact expected lines per Python MAGI reference layout
1651        let expected_critical = "[!!!] **[CRITICAL]** Test title _(from Melchior, Caspar)_";
1652        let expected_warning = "[!!]  **[WARNING]**  Missing retry logic _(from Balthasar)_";
1653        let expected_info = "[i]   **[INFO]**     Consider timeout _(from Caspar)_";
1654
1655        assert!(
1656            output.contains(expected_critical),
1657            "Critical line does not match Python layout.\nExpected: {:?}\nOutput:\n{}",
1658            expected_critical,
1659            output
1660        );
1661        assert!(
1662            output.contains(expected_warning),
1663            "Warning line does not match Python layout.\nExpected: {:?}\nOutput:\n{}",
1664            expected_warning,
1665            output
1666        );
1667        assert!(
1668            output.contains(expected_info),
1669            "Info line does not match Python layout.\nExpected: {:?}\nOutput:\n{}",
1670            expected_info,
1671            output
1672        );
1673    }
1674
1675    // -- S10: fit_content helper --
1676
1677    /// fit_content returns input unchanged when input length <= width.
1678    #[test]
1679    fn test_fit_content_returns_input_when_shorter_than_width() {
1680        assert_eq!(fit_content("hello", 10, ""), "hello");
1681        assert_eq!(fit_content("hi", 10, "lo"), "hi");
1682    }
1683
1684    /// fit_content returns input unchanged when input length exactly equals width.
1685    #[test]
1686    fn test_fit_content_returns_input_when_exactly_width() {
1687        assert_eq!(fit_content("hello", 5, ""), "hello");
1688        assert_eq!(fit_content("abcde", 5, "lo"), "abcde");
1689    }
1690
1691    /// fit_content preserves the suffix and ellipsizes the prefix when prefix overflows.
1692    #[test]
1693    fn test_fit_content_preserves_suffix_when_prefix_overflows() {
1694        // content = "abcdefghij" (10), width = 8, preserve_suffix = "hij"
1695        // prefix_budget = 8 - 3 - 3 = 2
1696        // prefix_source = "abcdefg" (content minus last 3 chars)
1697        // prefix_source[..2] = "ab"
1698        // result = "ab...hij" (8 chars)
1699        assert_eq!(fit_content("abcdefghij", 8, "hij"), "ab...hij");
1700    }
1701
1702    /// fit_content falls back to tail-cut when no suffix is given.
1703    #[test]
1704    fn test_fit_content_falls_back_to_tail_cut_when_no_suffix() {
1705        // content = "abcdefghij" (10), width = 6, preserve_suffix = ""
1706        // fallback: cutoff = max(1, 6-3) = 3, result = "abc..."
1707        assert_eq!(fit_content("abcdefghij", 6, ""), "abc...");
1708    }
1709
1710    /// fit_content falls back to tail-cut when suffix + ellipsis >= width.
1711    #[test]
1712    fn test_fit_content_falls_back_to_tail_cut_when_suffix_plus_ellipsis_exceeds_width() {
1713        // preserve_suffix = "xy" (2), ELLIPSIS = 3 bytes, total = 5 = width
1714        // condition: len(preserve_suffix) + 3 >= width  →  2 + 3 >= 5  → true → fallback
1715        // cutoff = max(1, 5-3) = 2, result = "ab..."
1716        assert_eq!(fit_content("abcdefghij", 5, "xy"), "ab...");
1717    }
1718
1719    /// fit_content ellipsis is exactly three dots.
1720    #[test]
1721    fn test_fit_content_ellipsis_is_exactly_three_dots() {
1722        let result = fit_content("abcdefghij", 6, "");
1723        assert!(
1724            result.ends_with("..."),
1725            "Expected ellipsis '...', got: {:?}",
1726            result
1727        );
1728        let ellipsis_start = result.len() - 3;
1729        assert_eq!(&result[ellipsis_start..], "...");
1730    }
1731
1732    /// fit_content resulting byte length equals width when truncation occurs.
1733    #[test]
1734    fn test_fit_content_resulting_length_equals_width_when_truncated() {
1735        // All cases where content.len() > width must produce exactly width bytes
1736        // (except width < 4 edge case documented below)
1737        for w in 4..=20usize {
1738            let content = "a".repeat(w + 5);
1739            let result = fit_content(&content, w, "");
1740            assert_eq!(
1741                result.len(),
1742                w,
1743                "Expected result length {w} for width={w}, got {} from {:?}",
1744                result.len(),
1745                result
1746            );
1747        }
1748        // With suffix
1749        let result = fit_content("abcdefghij", 8, "hij");
1750        assert_eq!(
1751            result.len(),
1752            8,
1753            "Expected 8, got {}: {:?}",
1754            result.len(),
1755            result
1756        );
1757    }
1758
1759    /// fit_content boundary at width=1: Python-literal fallback produces "a..." (4 bytes).
1760    ///
1761    /// For width < 4, the result length exceeds `width` (cannot fit ellipsis + 1 char).
1762    /// This is an accepted edge case documented in the spec — a literal port of Python behavior.
1763    /// Callers should ensure `width >= 4` for sensible truncation.
1764    ///
1765    /// This test is skipped in debug builds because `fit_content` fires a `debug_assert!(width >= 4)`
1766    /// to catch unintended callers in dev/test environments. The width=1 path is only reachable in
1767    /// release mode where the assert is compiled out.
1768    #[test]
1769    #[cfg(not(debug_assertions))]
1770    fn test_fit_content_boundary_width_1() {
1771        // width=1: cutoff = max(1, 1.saturating_sub(3)) = max(1, 0) = 1
1772        // result = "a..." (4 bytes, exceeds width — documented edge case)
1773        let result = fit_content("abc", 1, "");
1774        assert_eq!(
1775            result, "a...",
1776            "Expected 'a...' for width=1, got: {:?}",
1777            result
1778        );
1779    }
1780
1781    // -- S10: Banner column alignment --
1782
1783    /// All agent labels are left-aligned to the same column width (max_label_len).
1784    #[test]
1785    fn test_banner_labels_are_column_aligned_to_max_label_len() {
1786        // Default config: Melchior (Scientist): = 21, Balthasar (Pragmatist): = 23, Caspar (Critic): = 17
1787        // max_label_len = 23
1788        // After alignment: all verdict suffixes start at the same column
1789        let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
1790        let b = make_agent(
1791            AgentName::Balthasar,
1792            Verdict::Approve,
1793            0.85,
1794            "S",
1795            "R",
1796            "Rec",
1797        );
1798        let c = make_agent(AgentName::Caspar, Verdict::Approve, 0.78, "S", "R", "Rec");
1799        let agents = vec![m.clone(), b.clone(), c.clone()];
1800        let consensus = make_consensus("STRONG GO", Verdict::Approve, 0.9, 1.0, &[&m, &b, &c]);
1801
1802        let formatter = ReportFormatter::new();
1803        let banner = formatter.format_banner(&agents, &consensus);
1804
1805        // Find the agent content lines (not separator, not header, not consensus)
1806        // Each agent line starts with "|  " and contains a verdict.
1807        // The verdict suffix " APPROVE (NN%)" must start at the same column in all lines.
1808        let agent_lines: Vec<&str> = banner
1809            .lines()
1810            .filter(|l| l.starts_with('|') && l.contains("APPROVE") && !l.contains("CONSENSUS"))
1811            .collect();
1812
1813        assert_eq!(agent_lines.len(), 3, "Expected 3 agent lines");
1814
1815        // Find position of " APPROVE" in each line — must be the same for all
1816        let verdict_positions: Vec<usize> = agent_lines
1817            .iter()
1818            .map(|l| l.find(" APPROVE").expect("APPROVE not found"))
1819            .collect();
1820
1821        let first_pos = verdict_positions[0];
1822        for (i, &pos) in verdict_positions.iter().enumerate() {
1823            assert_eq!(
1824                pos, first_pos,
1825                "Agent line {i} has APPROVE at column {pos}, expected {first_pos}\nLines: {agent_lines:?}"
1826            );
1827        }
1828    }
1829
1830    /// When a label is so long that the rendered line would overflow, the label is ellipsized
1831    /// but the verdict suffix is preserved intact.
1832    #[test]
1833    fn test_banner_verdict_preserved_when_label_exceeds_width() {
1834        // Use a very long title that would push the line over banner_inner (50 chars)
1835        let mut config = ReportConfig::default();
1836        config.agent_titles.insert(
1837            AgentName::Balthasar,
1838            (
1839                "Balthasar".to_string(),
1840                "Very Long Pragmatist Title Indeed Here".to_string(),
1841            ),
1842        );
1843        let formatter = ReportFormatter::with_config(config).unwrap();
1844
1845        let b = make_agent(
1846            AgentName::Balthasar,
1847            Verdict::Approve,
1848            0.85,
1849            "S",
1850            "R",
1851            "Rec",
1852        );
1853        let agents = vec![b.clone()];
1854        let consensus = make_consensus("GO (1-0)", Verdict::Approve, 0.85, 1.0, &[&b]);
1855
1856        let banner = formatter.format_banner(&agents, &consensus);
1857
1858        // All lines must still be exactly 52 chars
1859        for line in banner.lines() {
1860            if !line.is_empty() {
1861                assert_eq!(line.len(), 52, "Line is not 52 chars: {:?}", line);
1862            }
1863        }
1864
1865        // The verdict suffix must appear intact in the banner
1866        let verdict_suffix = " APPROVE (85%)";
1867        assert!(
1868            banner.contains(verdict_suffix),
1869            "Verdict suffix {:?} must be preserved in banner:\n{}",
1870            verdict_suffix,
1871            banner
1872        );
1873    }
1874
1875    /// The consensus line for GO WITH CAVEATS includes the split count (e.g., "GO WITH CAVEATS (2-1)").
1876    #[test]
1877    fn test_banner_consensus_line_includes_split_for_go_with_caveats() {
1878        let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
1879        let b = make_agent(AgentName::Balthasar, Verdict::Approve, 0.8, "S", "R", "Rec");
1880        let c = make_agent(AgentName::Caspar, Verdict::Reject, 0.75, "S", "R", "Rec");
1881        let agents = vec![m.clone(), b.clone(), c.clone()];
1882        // The consensus label must include the split — this is produced by the consensus engine (S05)
1883        let consensus = make_consensus(
1884            "GO WITH CAVEATS (2-1)",
1885            Verdict::Approve,
1886            0.8,
1887            0.33,
1888            &[&m, &b, &c],
1889        );
1890
1891        let formatter = ReportFormatter::new();
1892        let banner = formatter.format_banner(&agents, &consensus);
1893
1894        assert!(
1895            banner.contains("GO WITH CAVEATS (2-1)"),
1896            "Banner consensus line must include the split count: {banner}"
1897        );
1898
1899        // All lines must be exactly 52 chars
1900        for line in banner.lines() {
1901            if !line.is_empty() {
1902                assert_eq!(line.len(), 52, "Line is not 52 chars: {:?}", line);
1903            }
1904        }
1905    }
1906
1907    /// All lines of format_banner output are exactly banner_width (52) bytes.
1908    ///
1909    /// Re-verifies the existing invariant after alignment changes.
1910    #[test]
1911    fn test_banner_all_lines_are_exactly_banner_width() {
1912        let m = make_agent(
1913            AgentName::Melchior,
1914            Verdict::Approve,
1915            0.9,
1916            "Good",
1917            "R",
1918            "Rec",
1919        );
1920        let b = make_agent(
1921            AgentName::Balthasar,
1922            Verdict::Conditional,
1923            0.85,
1924            "Ok",
1925            "R",
1926            "Rec",
1927        );
1928        let c = make_agent(AgentName::Caspar, Verdict::Reject, 0.78, "Bad", "R", "Rec");
1929        let agents = vec![m.clone(), b.clone(), c.clone()];
1930        let consensus = make_consensus(
1931            "GO WITH CAVEATS (2-1)",
1932            Verdict::Approve,
1933            0.85,
1934            0.33,
1935            &[&m, &b, &c],
1936        );
1937
1938        let formatter = ReportFormatter::new();
1939        let banner = formatter.format_banner(&agents, &consensus);
1940
1941        for line in banner.lines() {
1942            if !line.is_empty() {
1943                assert_eq!(line.len(), 52, "Line is not 52 chars: {:?}", line);
1944            }
1945        }
1946    }
1947
1948    /// Agent names in JSON are lowercase.
1949    #[test]
1950    fn test_magi_report_json_agent_names_lowercase() {
1951        let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
1952        let agents = vec![m.clone()];
1953        let consensus = make_consensus("GO (1-0)", Verdict::Approve, 0.9, 1.0, &[&m]);
1954
1955        let report = MagiReport {
1956            agents,
1957            consensus,
1958            banner: String::new(),
1959            report: String::new(),
1960            degraded: false,
1961            failed_agents: BTreeMap::new(),
1962        };
1963
1964        let json = serde_json::to_string(&report).expect("serialize");
1965        assert!(
1966            json.contains("\"melchior\""),
1967            "Agent name should be lowercase in JSON"
1968        );
1969        assert!(
1970            !json.contains("\"Melchior\""),
1971            "Agent name should NOT be capitalized in JSON"
1972        );
1973    }
1974
1975    /// consensus.confidence is rounded to 2 decimals.
1976    #[test]
1977    fn test_magi_report_confidence_rounded() {
1978        let m = make_agent(AgentName::Melchior, Verdict::Approve, 0.9, "S", "R", "Rec");
1979        let agents = vec![m.clone()];
1980        let mut consensus = make_consensus("GO (1-0)", Verdict::Approve, 0.86, 1.0, &[&m]);
1981        consensus.confidence = 0.8567;
1982
1983        let report = MagiReport {
1984            agents,
1985            consensus,
1986            banner: String::new(),
1987            report: String::new(),
1988            degraded: false,
1989            failed_agents: BTreeMap::new(),
1990        };
1991
1992        // Confidence rounding is done by the consensus engine, not by MagiReport.
1993        // Here we verify the field value is preserved as-is during serialization.
1994        let json = serde_json::to_string(&report).expect("serialize");
1995        assert!(
1996            json.contains("0.8567"),
1997            "Confidence should be serialized as-is (rounding is consensus engine's job)"
1998        );
1999    }
2000
2001    // -- ReportConfig::new_checked tests --
2002
2003    /// ReportConfig::new_checked accepts all ASCII titles.
2004    #[test]
2005    fn test_new_checked_accepts_all_ascii_titles() {
2006        let mut agent_titles = BTreeMap::new();
2007        agent_titles.insert(
2008            AgentName::Melchior,
2009            ("Melchior".to_string(), "Scientist".to_string()),
2010        );
2011        agent_titles.insert(
2012            AgentName::Balthasar,
2013            ("Balthasar".to_string(), "Pragmatist".to_string()),
2014        );
2015        agent_titles.insert(
2016            AgentName::Caspar,
2017            ("Caspar".to_string(), "Critic".to_string()),
2018        );
2019
2020        let result = ReportConfig::new_checked(52, agent_titles);
2021        assert!(result.is_ok(), "Should accept all ASCII titles");
2022        let config = result.unwrap();
2023        assert_eq!(config.banner_width, 52);
2024    }
2025
2026    /// ReportConfig::new_checked rejects non-ASCII display_name.
2027    #[test]
2028    fn test_new_checked_rejects_non_ascii_display_name() {
2029        let mut agent_titles = BTreeMap::new();
2030        agent_titles.insert(
2031            AgentName::Melchior,
2032            ("Mélchior".to_string(), "Scientist".to_string()),
2033        );
2034
2035        let result = ReportConfig::new_checked(52, agent_titles);
2036        assert!(result.is_err(), "Should reject non-ASCII display_name");
2037        let err = result.unwrap_err();
2038        let ReportError::NonAsciiTitle {
2039            agent,
2040            field,
2041            value,
2042            ..
2043        } = err
2044        else {
2045            panic!("expected NonAsciiTitle, got {err:?}");
2046        };
2047        assert_eq!(agent, AgentName::Melchior);
2048        assert_eq!(field, "display_name");
2049        assert_eq!(value, "Mélchior");
2050    }
2051
2052    /// ReportConfig::new_checked rejects non-ASCII title field.
2053    #[test]
2054    fn test_new_checked_rejects_non_ascii_title_field() {
2055        let mut agent_titles = BTreeMap::new();
2056        agent_titles.insert(
2057            AgentName::Balthasar,
2058            ("Balthasar".to_string(), "Pragmátist".to_string()),
2059        );
2060
2061        let result = ReportConfig::new_checked(52, agent_titles);
2062        assert!(result.is_err(), "Should reject non-ASCII title");
2063        let err = result.unwrap_err();
2064        let ReportError::NonAsciiTitle {
2065            agent,
2066            field,
2067            value,
2068            ..
2069        } = err
2070        else {
2071            panic!("expected NonAsciiTitle, got {err:?}");
2072        };
2073        assert_eq!(agent, AgentName::Balthasar);
2074        assert_eq!(field, "title");
2075        assert_eq!(value, "Pragmátist");
2076    }
2077
2078    // -- ReportConfig::new_checked rejects banner_width too small --
2079
2080    #[test]
2081    fn test_new_checked_rejects_banner_width_too_small() {
2082        let titles = BTreeMap::new();
2083        for width in [0usize, 1, 4, 7] {
2084            let result = ReportConfig::new_checked(width, titles.clone());
2085            assert!(result.is_err(), "banner_width={width} should be rejected");
2086            assert_eq!(
2087                result.unwrap_err(),
2088                ReportError::BannerTooSmall {
2089                    requested: width,
2090                    minimum: ReportConfig::MIN_BANNER_WIDTH,
2091                },
2092                "wrong error variant for banner_width={width}"
2093            );
2094        }
2095    }
2096
2097    #[test]
2098    fn test_new_checked_accepts_banner_width_at_minimum() {
2099        let titles = BTreeMap::new();
2100        assert!(
2101            ReportConfig::new_checked(ReportConfig::MIN_BANNER_WIDTH, titles).is_ok(),
2102            "banner_width == MIN_BANNER_WIDTH should be accepted"
2103        );
2104    }
2105
2106    // -- ReportFormatter::with_config re-validation tests (Fix 1) --
2107
2108    /// with_config rejects banner_width below minimum even when set via field mutation.
2109    #[test]
2110    fn test_with_config_rejects_banner_width_too_small() {
2111        // Construct directly to bypass new_checked validation and test that with_config
2112        // catches the invalid value at formatter construction time.
2113        let cfg = ReportConfig {
2114            banner_width: 1,
2115            ..ReportConfig::default()
2116        };
2117        match ReportFormatter::with_config(cfg) {
2118            Err(ReportError::BannerTooSmall { requested, minimum }) => {
2119                assert_eq!(requested, 1);
2120                assert_eq!(minimum, ReportConfig::MIN_BANNER_WIDTH);
2121            }
2122            Err(other) => panic!("expected BannerTooSmall, got {other:?}"),
2123            Ok(_) => panic!("with_config must re-validate banner_width"),
2124        }
2125    }
2126
2127    /// with_config rejects non-ASCII agent title set after default construction.
2128    #[test]
2129    fn test_with_config_rejects_non_ascii_agent_title() {
2130        let mut titles = BTreeMap::new();
2131        titles.insert(
2132            AgentName::Melchior,
2133            ("Ménagère".to_string(), "Scientist".to_string()),
2134        );
2135        let cfg = ReportConfig {
2136            banner_width: 52,
2137            agent_titles: titles,
2138        };
2139        match ReportFormatter::with_config(cfg) {
2140            Err(ReportError::NonAsciiTitle { agent, field, .. }) => {
2141                assert_eq!(agent, AgentName::Melchior);
2142                assert_eq!(field, "display_name");
2143            }
2144            Err(other) => panic!("expected NonAsciiTitle, got {other:?}"),
2145            Ok(_) => panic!("with_config must reject non-ASCII titles"),
2146        }
2147    }
2148}