1use regex::Regex;
6use serde::{Deserialize, Serialize};
7use std::cmp::Ordering;
8use std::fmt;
9use std::sync::LazyLock;
10
11#[deprecated(
23 since = "0.2.0",
24 note = "use `magi_core::validate::clean_title` for current behavior; \
25 `ZERO_WIDTH_PATTERN` covers a different character set and is retained \
26 for legacy callers only"
27)]
28pub static ZERO_WIDTH_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
29 Regex::new(
30 "[\u{00AD}\u{0600}-\u{0605}\u{061C}\u{06DD}\u{070F}\u{08E2}\u{180E}\
31 \u{200B}-\u{200F}\u{202A}-\u{202E}\u{2060}-\u{2064}\u{2066}-\u{206F}\
32 \u{FEFF}\u{FFF9}-\u{FFFB}]",
33 )
34 .expect("zero-width regex is valid")
35});
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
42#[serde(rename_all = "lowercase")]
43pub enum Verdict {
44 Approve,
46 Reject,
48 Conditional,
50}
51
52impl Verdict {
53 pub fn weight(&self) -> f64 {
59 match self {
60 Verdict::Approve => 1.0,
61 Verdict::Reject => -1.0,
62 Verdict::Conditional => 0.5,
63 }
64 }
65
66 pub fn effective(&self) -> Verdict {
70 match self {
71 Verdict::Approve | Verdict::Conditional => Verdict::Approve,
72 Verdict::Reject => Verdict::Reject,
73 }
74 }
75}
76
77impl fmt::Display for Verdict {
78 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79 match self {
80 Verdict::Approve => write!(f, "APPROVE"),
81 Verdict::Reject => write!(f, "REJECT"),
82 Verdict::Conditional => write!(f, "CONDITIONAL"),
83 }
84 }
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
92#[serde(rename_all = "lowercase")]
93pub enum Severity {
94 Critical,
96 Warning,
98 Info,
100}
101
102impl Severity {
103 pub fn icon(&self) -> &'static str {
109 match self {
110 Severity::Critical => "[!!!]",
111 Severity::Warning => "[!!]",
112 Severity::Info => "[i]",
113 }
114 }
115}
116
117impl fmt::Display for Severity {
118 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119 match self {
120 Severity::Critical => write!(f, "CRITICAL"),
121 Severity::Warning => write!(f, "WARNING"),
122 Severity::Info => write!(f, "INFO"),
123 }
124 }
125}
126
127impl PartialOrd for Severity {
128 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
129 Some(self.cmp(other))
130 }
131}
132
133impl Ord for Severity {
134 fn cmp(&self, other: &Self) -> Ordering {
135 fn rank(s: &Severity) -> u8 {
136 match s {
137 Severity::Info => 0,
138 Severity::Warning => 1,
139 Severity::Critical => 2,
140 }
141 }
142 rank(self).cmp(&rank(other))
143 }
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
150#[serde(rename_all = "kebab-case")]
151pub enum Mode {
152 CodeReview,
154 Design,
156 Analysis,
158}
159
160impl fmt::Display for Mode {
161 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162 match self {
163 Mode::CodeReview => write!(f, "code-review"),
164 Mode::Design => write!(f, "design"),
165 Mode::Analysis => write!(f, "analysis"),
166 }
167 }
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
176#[serde(rename_all = "lowercase")]
177pub enum AgentName {
178 Melchior,
180 Balthasar,
182 Caspar,
184}
185
186impl AgentName {
187 pub fn title(&self) -> &'static str {
193 match self {
194 AgentName::Melchior => "Scientist",
195 AgentName::Balthasar => "Pragmatist",
196 AgentName::Caspar => "Critic",
197 }
198 }
199
200 pub fn display_name(&self) -> &'static str {
202 match self {
203 AgentName::Melchior => "Melchior",
204 AgentName::Balthasar => "Balthasar",
205 AgentName::Caspar => "Caspar",
206 }
207 }
208}
209
210impl PartialOrd for AgentName {
211 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
212 Some(self.cmp(other))
213 }
214}
215
216impl Ord for AgentName {
217 fn cmp(&self, other: &Self) -> Ordering {
218 self.display_name().cmp(other.display_name())
219 }
220}
221
222#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
226pub struct Finding {
227 pub severity: Severity,
229 pub title: String,
231 pub detail: String,
233}
234
235impl Finding {
236 #[deprecated(
246 since = "0.2.0",
247 note = "use `magi_core::validate::clean_title` (applies full cleanup pipeline, \
248 not just zero-width strip)"
249 )]
250 pub fn stripped_title(&self) -> String {
251 crate::validate::clean_title(&self.title)
252 }
253}
254
255#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
260pub struct AgentOutput {
261 pub agent: AgentName,
263 pub verdict: Verdict,
265 pub confidence: f64,
267 pub summary: String,
269 pub reasoning: String,
271 pub findings: Vec<Finding>,
273 pub recommendation: String,
275}
276
277impl AgentOutput {
278 pub fn is_approving(&self) -> bool {
280 matches!(self.verdict, Verdict::Approve | Verdict::Conditional)
281 }
282
283 pub fn is_dissenting(&self, majority: Verdict) -> bool {
285 self.effective_verdict() != majority
286 }
287
288 pub fn effective_verdict(&self) -> Verdict {
290 self.verdict.effective()
291 }
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297 use std::collections::BTreeMap;
298
299 #[test]
302 fn test_verdict_approve_weight_is_positive_one() {
303 assert_eq!(Verdict::Approve.weight(), 1.0);
304 }
305
306 #[test]
307 fn test_verdict_reject_weight_is_negative_one() {
308 assert_eq!(Verdict::Reject.weight(), -1.0);
309 }
310
311 #[test]
312 fn test_verdict_conditional_weight_is_half() {
313 assert_eq!(Verdict::Conditional.weight(), 0.5);
314 }
315
316 #[test]
317 fn test_verdict_conditional_effective_maps_to_approve() {
318 assert_eq!(Verdict::Conditional.effective(), Verdict::Approve);
319 }
320
321 #[test]
322 fn test_verdict_approve_effective_is_identity() {
323 assert_eq!(Verdict::Approve.effective(), Verdict::Approve);
324 }
325
326 #[test]
327 fn test_verdict_reject_effective_is_identity() {
328 assert_eq!(Verdict::Reject.effective(), Verdict::Reject);
329 }
330
331 #[test]
332 fn test_verdict_display_outputs_uppercase() {
333 assert_eq!(format!("{}", Verdict::Approve), "APPROVE");
334 assert_eq!(format!("{}", Verdict::Reject), "REJECT");
335 assert_eq!(format!("{}", Verdict::Conditional), "CONDITIONAL");
336 }
337
338 #[test]
339 fn test_verdict_serializes_as_lowercase() {
340 assert_eq!(
341 serde_json::to_string(&Verdict::Approve).unwrap(),
342 "\"approve\""
343 );
344 assert_eq!(
345 serde_json::to_string(&Verdict::Reject).unwrap(),
346 "\"reject\""
347 );
348 assert_eq!(
349 serde_json::to_string(&Verdict::Conditional).unwrap(),
350 "\"conditional\""
351 );
352 }
353
354 #[test]
355 fn test_verdict_deserializes_from_lowercase() {
356 assert_eq!(
357 serde_json::from_str::<Verdict>("\"approve\"").unwrap(),
358 Verdict::Approve
359 );
360 assert_eq!(
361 serde_json::from_str::<Verdict>("\"reject\"").unwrap(),
362 Verdict::Reject
363 );
364 assert_eq!(
365 serde_json::from_str::<Verdict>("\"conditional\"").unwrap(),
366 Verdict::Conditional
367 );
368 }
369
370 #[test]
371 fn test_verdict_deserialization_rejects_invalid() {
372 assert!(serde_json::from_str::<Verdict>("\"invalid\"").is_err());
373 }
374
375 #[test]
378 fn test_severity_ordering_critical_greater_than_warning_greater_than_info() {
379 assert!(Severity::Critical > Severity::Warning);
380 assert!(Severity::Warning > Severity::Info);
381 assert!(Severity::Critical > Severity::Info);
382 }
383
384 #[test]
385 fn test_severity_icon_returns_correct_strings() {
386 assert_eq!(Severity::Critical.icon(), "[!!!]");
387 assert_eq!(Severity::Warning.icon(), "[!!]");
388 assert_eq!(Severity::Info.icon(), "[i]");
389 }
390
391 #[test]
392 fn test_severity_display_outputs_uppercase() {
393 assert_eq!(format!("{}", Severity::Critical), "CRITICAL");
394 assert_eq!(format!("{}", Severity::Warning), "WARNING");
395 assert_eq!(format!("{}", Severity::Info), "INFO");
396 }
397
398 #[test]
399 fn test_severity_serializes_as_lowercase() {
400 assert_eq!(
401 serde_json::to_string(&Severity::Critical).unwrap(),
402 "\"critical\""
403 );
404 assert_eq!(
405 serde_json::to_string(&Severity::Warning).unwrap(),
406 "\"warning\""
407 );
408 assert_eq!(serde_json::to_string(&Severity::Info).unwrap(), "\"info\"");
409 }
410
411 #[test]
412 fn test_severity_deserializes_from_lowercase() {
413 assert_eq!(
414 serde_json::from_str::<Severity>("\"critical\"").unwrap(),
415 Severity::Critical
416 );
417 }
418
419 #[test]
420 fn test_severity_deserialization_rejects_invalid() {
421 assert!(serde_json::from_str::<Severity>("\"invalid\"").is_err());
422 }
423
424 #[test]
427 fn test_mode_display_outputs_hyphenated_lowercase() {
428 assert_eq!(format!("{}", Mode::CodeReview), "code-review");
429 assert_eq!(format!("{}", Mode::Design), "design");
430 assert_eq!(format!("{}", Mode::Analysis), "analysis");
431 }
432
433 #[test]
434 fn test_mode_serializes_as_lowercase_with_hyphens() {
435 assert_eq!(
436 serde_json::to_string(&Mode::CodeReview).unwrap(),
437 "\"code-review\""
438 );
439 assert_eq!(serde_json::to_string(&Mode::Design).unwrap(), "\"design\"");
440 assert_eq!(
441 serde_json::to_string(&Mode::Analysis).unwrap(),
442 "\"analysis\""
443 );
444 }
445
446 #[test]
447 fn test_mode_deserializes_from_lowercase_with_hyphens() {
448 assert_eq!(
449 serde_json::from_str::<Mode>("\"code-review\"").unwrap(),
450 Mode::CodeReview
451 );
452 assert_eq!(
453 serde_json::from_str::<Mode>("\"design\"").unwrap(),
454 Mode::Design
455 );
456 assert_eq!(
457 serde_json::from_str::<Mode>("\"analysis\"").unwrap(),
458 Mode::Analysis
459 );
460 }
461
462 #[test]
463 fn test_mode_deserialization_rejects_invalid() {
464 assert!(serde_json::from_str::<Mode>("\"invalid\"").is_err());
465 }
466
467 #[test]
470 fn test_agent_name_title_returns_role() {
471 assert_eq!(AgentName::Melchior.title(), "Scientist");
472 assert_eq!(AgentName::Balthasar.title(), "Pragmatist");
473 assert_eq!(AgentName::Caspar.title(), "Critic");
474 }
475
476 #[test]
477 fn test_agent_name_display_name_returns_name() {
478 assert_eq!(AgentName::Melchior.display_name(), "Melchior");
479 assert_eq!(AgentName::Balthasar.display_name(), "Balthasar");
480 assert_eq!(AgentName::Caspar.display_name(), "Caspar");
481 }
482
483 #[test]
484 fn test_agent_name_ord_is_alphabetical() {
485 assert!(AgentName::Balthasar < AgentName::Caspar);
486 assert!(AgentName::Caspar < AgentName::Melchior);
487 assert!(AgentName::Balthasar < AgentName::Melchior);
488 }
489
490 #[test]
491 fn test_agent_name_serializes_as_lowercase() {
492 assert_eq!(
493 serde_json::to_string(&AgentName::Melchior).unwrap(),
494 "\"melchior\""
495 );
496 assert_eq!(
497 serde_json::to_string(&AgentName::Balthasar).unwrap(),
498 "\"balthasar\""
499 );
500 assert_eq!(
501 serde_json::to_string(&AgentName::Caspar).unwrap(),
502 "\"caspar\""
503 );
504 }
505
506 #[test]
507 fn test_agent_name_deserializes_from_lowercase() {
508 assert_eq!(
509 serde_json::from_str::<AgentName>("\"melchior\"").unwrap(),
510 AgentName::Melchior
511 );
512 }
513
514 #[test]
515 fn test_agent_name_usable_as_btreemap_key() {
516 let mut map = BTreeMap::new();
517 map.insert(AgentName::Melchior, "scientist");
518 map.insert(AgentName::Balthasar, "pragmatist");
519 map.insert(AgentName::Caspar, "critic");
520 assert_eq!(map.get(&AgentName::Melchior), Some(&"scientist"));
521 assert_eq!(map.get(&AgentName::Balthasar), Some(&"pragmatist"));
522 assert_eq!(map.get(&AgentName::Caspar), Some(&"critic"));
523 }
524
525 #[allow(deprecated)]
528 #[test]
529 fn test_finding_stripped_title_removes_zero_width_characters() {
530 let finding = Finding {
531 severity: Severity::Warning,
532 title: "Hello\u{200B}World\u{FEFF}Test\u{200C}End".to_string(),
533 detail: "detail".to_string(),
534 };
535 assert_eq!(finding.stripped_title(), "HelloWorldTestEnd");
536 }
537
538 #[allow(deprecated)]
539 #[test]
540 fn test_finding_stripped_title_preserves_normal_text() {
541 let finding = Finding {
542 severity: Severity::Info,
543 title: "Normal title".to_string(),
544 detail: "detail".to_string(),
545 };
546 assert_eq!(finding.stripped_title(), "Normal title");
547 }
548
549 #[test]
550 fn test_finding_serde_roundtrip() {
551 let finding = Finding {
552 severity: Severity::Critical,
553 title: "Security issue".to_string(),
554 detail: "SQL injection vulnerability".to_string(),
555 };
556 let json = serde_json::to_string(&finding).unwrap();
557 let deserialized: Finding = serde_json::from_str(&json).unwrap();
558 assert_eq!(finding, deserialized);
559 }
560
561 fn make_output(verdict: Verdict) -> AgentOutput {
564 AgentOutput {
565 agent: AgentName::Melchior,
566 verdict,
567 confidence: 0.9,
568 summary: "summary".to_string(),
569 reasoning: "reasoning".to_string(),
570 findings: vec![],
571 recommendation: "recommendation".to_string(),
572 }
573 }
574
575 #[test]
576 fn test_agent_output_is_approving_true_for_approve() {
577 assert!(make_output(Verdict::Approve).is_approving());
578 }
579
580 #[test]
581 fn test_agent_output_is_approving_true_for_conditional() {
582 assert!(make_output(Verdict::Conditional).is_approving());
583 }
584
585 #[test]
586 fn test_agent_output_is_approving_false_for_reject() {
587 assert!(!make_output(Verdict::Reject).is_approving());
588 }
589
590 #[test]
591 fn test_agent_output_is_dissenting_when_verdict_differs_from_majority() {
592 let output = make_output(Verdict::Reject);
593 assert!(output.is_dissenting(Verdict::Approve));
594 }
595
596 #[test]
597 fn test_agent_output_is_not_dissenting_when_verdict_matches_majority() {
598 let output = make_output(Verdict::Approve);
599 assert!(!output.is_dissenting(Verdict::Approve));
600 }
601
602 #[test]
603 fn test_agent_output_conditional_is_not_dissenting_from_approve_majority() {
604 let output = make_output(Verdict::Conditional);
605 assert!(!output.is_dissenting(Verdict::Approve));
606 }
607
608 #[test]
609 fn test_agent_output_effective_verdict_maps_conditional_to_approve() {
610 let output = make_output(Verdict::Conditional);
611 assert_eq!(output.effective_verdict(), Verdict::Approve);
612 }
613
614 #[test]
615 fn test_agent_output_serde_roundtrip() {
616 let output = AgentOutput {
617 agent: AgentName::Balthasar,
618 verdict: Verdict::Conditional,
619 confidence: 0.75,
620 summary: "looks okay".to_string(),
621 reasoning: "mostly good".to_string(),
622 findings: vec![Finding {
623 severity: Severity::Warning,
624 title: "Minor issue".to_string(),
625 detail: "Could improve naming".to_string(),
626 }],
627 recommendation: "approve with changes".to_string(),
628 };
629 let json = serde_json::to_string(&output).unwrap();
630 let deserialized: AgentOutput = serde_json::from_str(&json).unwrap();
631 assert_eq!(output, deserialized);
632 }
633
634 #[test]
635 fn test_agent_output_empty_findings_valid() {
636 let output = make_output(Verdict::Approve);
637 assert!(output.findings.is_empty());
638 let json = serde_json::to_string(&output).unwrap();
639 let deserialized: AgentOutput = serde_json::from_str(&json).unwrap();
640 assert_eq!(output, deserialized);
641 }
642
643 #[test]
644 fn test_agent_output_ignores_unknown_fields() {
645 let json = r#"{
646 "agent": "caspar",
647 "verdict": "reject",
648 "confidence": 0.3,
649 "summary": "bad",
650 "reasoning": "terrible",
651 "findings": [],
652 "recommendation": "reject",
653 "unknown_field": "should be ignored"
654 }"#;
655 let output: AgentOutput = serde_json::from_str(json).unwrap();
656 assert_eq!(output.agent, AgentName::Caspar);
657 assert_eq!(output.verdict, Verdict::Reject);
658 }
659}