1use 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#[derive(Debug, Clone, PartialEq, Eq)]
15#[non_exhaustive]
16pub enum ReportError {
17 #[non_exhaustive]
22 NonAsciiTitle {
23 agent: AgentName,
25 field: &'static str,
27 value: String,
29 },
30 #[non_exhaustive]
34 BannerTooSmall {
35 requested: usize,
37 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
62pub const BANNER_WIDTH: usize = 52;
66
67pub const BANNER_INNER: usize = BANNER_WIDTH - 2;
71
72const FINDING_MARKER_WIDTH: usize = 5;
74
75fn 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 if content.len() <= width {
129 return content.to_string();
130 }
131
132 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 debug_assert!(content.ends_with(preserve_suffix));
142 let prefix_budget = width - ELLIPSIS.len() - preserve_suffix.len();
143 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
154const FINDING_SEVERITY_WIDTH: usize = 14;
160
161#[non_exhaustive]
174#[derive(Debug, Clone)]
175pub struct ReportConfig {
176 pub banner_width: usize,
179 pub agent_titles: BTreeMap<AgentName, (String, String)>,
182}
183
184pub struct ReportFormatter {
190 config: ReportConfig,
191 banner_inner: usize,
192}
193
194#[derive(Debug, Clone, Serialize)]
199pub struct MagiReport {
200 pub agents: Vec<AgentOutput>,
202 pub consensus: ConsensusResult,
204 pub banner: String,
206 pub report: String,
208 pub degraded: bool,
210 pub failed_agents: BTreeMap<AgentName, String>,
213}
214
215impl ReportConfig {
216 pub const MIN_BANNER_WIDTH: usize = 8;
220
221 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 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 pub fn new() -> Self {
307 Self::from_valid_config(ReportConfig::default())
308 }
309
310 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 pub fn format_banner(&self, agents: &[AgentOutput], consensus: &ConsensusResult) -> String {
375 let mut out = String::new();
376 let sep = self.format_separator();
377
378 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 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 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 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 pub fn format_report(&self, agents: &[AgentOutput], consensus: &ConsensusResult) -> String {
466 let mut out = String::new();
467
468 out.push_str(&self.format_banner(agents, consensus));
470 out.push('\n');
471
472 if !consensus.findings.is_empty() {
474 out.push_str(&self.format_findings(&consensus.findings));
475 }
476
477 if !consensus.dissent.is_empty() {
479 out.push_str(&self.format_dissent(&consensus.dissent));
480 }
481
482 if !consensus.conditions.is_empty() {
484 out.push_str(&self.format_conditions(&consensus.conditions));
485 }
486
487 out.push_str(&self.format_recommendations(&consensus.recommendations));
489
490 out
491 }
492
493 fn format_separator(&self) -> String {
495 format!("+{}+", "=".repeat(self.banner_inner))
496 }
497
498 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 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 assert!(!report.contains("## Consensus Summary"));
908
909 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 assert!(banner.contains("Melchior (Scientist): APPROVE (90%)"));
1144 assert!(banner.contains("Caspar (Critic): APPROVE (78%)"));
1147 }
1148
1149 #[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 assert!(
1181 !report.contains("User input not sanitized"),
1182 "Detail must not appear in markdown report"
1183 );
1184 }
1185
1186 #[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 assert!(
1224 !report.contains("The code has critical flaws"),
1225 "Dissent reasoning must not appear in the rendered report"
1226 );
1227 }
1228
1229 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 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 #[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 #[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 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 #[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 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 #[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 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 #[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 #[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 #[test]
1693 fn test_fit_content_preserves_suffix_when_prefix_overflows() {
1694 assert_eq!(fit_content("abcdefghij", 8, "hij"), "ab...hij");
1700 }
1701
1702 #[test]
1704 fn test_fit_content_falls_back_to_tail_cut_when_no_suffix() {
1705 assert_eq!(fit_content("abcdefghij", 6, ""), "abc...");
1708 }
1709
1710 #[test]
1712 fn test_fit_content_falls_back_to_tail_cut_when_suffix_plus_ellipsis_exceeds_width() {
1713 assert_eq!(fit_content("abcdefghij", 5, "xy"), "ab...");
1717 }
1718
1719 #[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 #[test]
1734 fn test_fit_content_resulting_length_equals_width_when_truncated() {
1735 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 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 #[test]
1769 #[cfg(not(debug_assertions))]
1770 fn test_fit_content_boundary_width_1() {
1771 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 #[test]
1785 fn test_banner_labels_are_column_aligned_to_max_label_len() {
1786 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 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 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 #[test]
1833 fn test_banner_verdict_preserved_when_label_exceeds_width() {
1834 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 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 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 #[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 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 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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[test]
2110 fn test_with_config_rejects_banner_width_too_small() {
2111 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 #[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}