1use regex::Regex;
6use serde::{Deserialize, Serialize};
7use std::cmp::Ordering;
8use std::fmt;
9use std::sync::LazyLock;
10
11pub static ZERO_WIDTH_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
17 Regex::new(
18 "[\u{00AD}\u{0600}-\u{0605}\u{061C}\u{06DD}\u{070F}\u{08E2}\u{180E}\
19 \u{200B}-\u{200F}\u{202A}-\u{202E}\u{2060}-\u{2064}\u{2066}-\u{206F}\
20 \u{FEFF}\u{FFF9}-\u{FFFB}]",
21 )
22 .expect("zero-width regex is valid")
23});
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
30#[serde(rename_all = "lowercase")]
31pub enum Verdict {
32 Approve,
34 Reject,
36 Conditional,
38}
39
40impl Verdict {
41 pub fn weight(&self) -> f64 {
47 match self {
48 Verdict::Approve => 1.0,
49 Verdict::Reject => -1.0,
50 Verdict::Conditional => 0.5,
51 }
52 }
53
54 pub fn effective(&self) -> Verdict {
58 match self {
59 Verdict::Approve | Verdict::Conditional => Verdict::Approve,
60 Verdict::Reject => Verdict::Reject,
61 }
62 }
63}
64
65impl fmt::Display for Verdict {
66 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67 match self {
68 Verdict::Approve => write!(f, "APPROVE"),
69 Verdict::Reject => write!(f, "REJECT"),
70 Verdict::Conditional => write!(f, "CONDITIONAL"),
71 }
72 }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
80#[serde(rename_all = "lowercase")]
81pub enum Severity {
82 Critical,
84 Warning,
86 Info,
88}
89
90impl Severity {
91 pub fn icon(&self) -> &'static str {
97 match self {
98 Severity::Critical => "[!!!]",
99 Severity::Warning => "[!!]",
100 Severity::Info => "[i]",
101 }
102 }
103}
104
105impl fmt::Display for Severity {
106 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107 match self {
108 Severity::Critical => write!(f, "CRITICAL"),
109 Severity::Warning => write!(f, "WARNING"),
110 Severity::Info => write!(f, "INFO"),
111 }
112 }
113}
114
115impl PartialOrd for Severity {
116 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
117 Some(self.cmp(other))
118 }
119}
120
121impl Ord for Severity {
122 fn cmp(&self, other: &Self) -> Ordering {
123 fn rank(s: &Severity) -> u8 {
124 match s {
125 Severity::Info => 0,
126 Severity::Warning => 1,
127 Severity::Critical => 2,
128 }
129 }
130 rank(self).cmp(&rank(other))
131 }
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
138#[serde(rename_all = "kebab-case")]
139pub enum Mode {
140 CodeReview,
142 Design,
144 Analysis,
146}
147
148impl fmt::Display for Mode {
149 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150 match self {
151 Mode::CodeReview => write!(f, "code-review"),
152 Mode::Design => write!(f, "design"),
153 Mode::Analysis => write!(f, "analysis"),
154 }
155 }
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
164#[serde(rename_all = "lowercase")]
165pub enum AgentName {
166 Melchior,
168 Balthasar,
170 Caspar,
172}
173
174impl AgentName {
175 pub fn title(&self) -> &'static str {
181 match self {
182 AgentName::Melchior => "Scientist",
183 AgentName::Balthasar => "Pragmatist",
184 AgentName::Caspar => "Critic",
185 }
186 }
187
188 pub fn display_name(&self) -> &'static str {
190 match self {
191 AgentName::Melchior => "Melchior",
192 AgentName::Balthasar => "Balthasar",
193 AgentName::Caspar => "Caspar",
194 }
195 }
196}
197
198impl PartialOrd for AgentName {
199 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
200 Some(self.cmp(other))
201 }
202}
203
204impl Ord for AgentName {
205 fn cmp(&self, other: &Self) -> Ordering {
206 self.display_name().cmp(other.display_name())
207 }
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
214pub struct Finding {
215 pub severity: Severity,
217 pub title: String,
219 pub detail: String,
221}
222
223impl Finding {
224 pub fn stripped_title(&self) -> String {
230 ZERO_WIDTH_PATTERN.replace_all(&self.title, "").into_owned()
231 }
232}
233
234#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
239pub struct AgentOutput {
240 pub agent: AgentName,
242 pub verdict: Verdict,
244 pub confidence: f64,
246 pub summary: String,
248 pub reasoning: String,
250 pub findings: Vec<Finding>,
252 pub recommendation: String,
254}
255
256impl AgentOutput {
257 pub fn is_approving(&self) -> bool {
259 matches!(self.verdict, Verdict::Approve | Verdict::Conditional)
260 }
261
262 pub fn is_dissenting(&self, majority: Verdict) -> bool {
264 self.effective_verdict() != majority
265 }
266
267 pub fn effective_verdict(&self) -> Verdict {
269 self.verdict.effective()
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276 use std::collections::BTreeMap;
277
278 #[test]
281 fn test_verdict_approve_weight_is_positive_one() {
282 assert_eq!(Verdict::Approve.weight(), 1.0);
283 }
284
285 #[test]
286 fn test_verdict_reject_weight_is_negative_one() {
287 assert_eq!(Verdict::Reject.weight(), -1.0);
288 }
289
290 #[test]
291 fn test_verdict_conditional_weight_is_half() {
292 assert_eq!(Verdict::Conditional.weight(), 0.5);
293 }
294
295 #[test]
296 fn test_verdict_conditional_effective_maps_to_approve() {
297 assert_eq!(Verdict::Conditional.effective(), Verdict::Approve);
298 }
299
300 #[test]
301 fn test_verdict_approve_effective_is_identity() {
302 assert_eq!(Verdict::Approve.effective(), Verdict::Approve);
303 }
304
305 #[test]
306 fn test_verdict_reject_effective_is_identity() {
307 assert_eq!(Verdict::Reject.effective(), Verdict::Reject);
308 }
309
310 #[test]
311 fn test_verdict_display_outputs_uppercase() {
312 assert_eq!(format!("{}", Verdict::Approve), "APPROVE");
313 assert_eq!(format!("{}", Verdict::Reject), "REJECT");
314 assert_eq!(format!("{}", Verdict::Conditional), "CONDITIONAL");
315 }
316
317 #[test]
318 fn test_verdict_serializes_as_lowercase() {
319 assert_eq!(
320 serde_json::to_string(&Verdict::Approve).unwrap(),
321 "\"approve\""
322 );
323 assert_eq!(
324 serde_json::to_string(&Verdict::Reject).unwrap(),
325 "\"reject\""
326 );
327 assert_eq!(
328 serde_json::to_string(&Verdict::Conditional).unwrap(),
329 "\"conditional\""
330 );
331 }
332
333 #[test]
334 fn test_verdict_deserializes_from_lowercase() {
335 assert_eq!(
336 serde_json::from_str::<Verdict>("\"approve\"").unwrap(),
337 Verdict::Approve
338 );
339 assert_eq!(
340 serde_json::from_str::<Verdict>("\"reject\"").unwrap(),
341 Verdict::Reject
342 );
343 assert_eq!(
344 serde_json::from_str::<Verdict>("\"conditional\"").unwrap(),
345 Verdict::Conditional
346 );
347 }
348
349 #[test]
350 fn test_verdict_deserialization_rejects_invalid() {
351 assert!(serde_json::from_str::<Verdict>("\"invalid\"").is_err());
352 }
353
354 #[test]
357 fn test_severity_ordering_critical_greater_than_warning_greater_than_info() {
358 assert!(Severity::Critical > Severity::Warning);
359 assert!(Severity::Warning > Severity::Info);
360 assert!(Severity::Critical > Severity::Info);
361 }
362
363 #[test]
364 fn test_severity_icon_returns_correct_strings() {
365 assert_eq!(Severity::Critical.icon(), "[!!!]");
366 assert_eq!(Severity::Warning.icon(), "[!!]");
367 assert_eq!(Severity::Info.icon(), "[i]");
368 }
369
370 #[test]
371 fn test_severity_display_outputs_uppercase() {
372 assert_eq!(format!("{}", Severity::Critical), "CRITICAL");
373 assert_eq!(format!("{}", Severity::Warning), "WARNING");
374 assert_eq!(format!("{}", Severity::Info), "INFO");
375 }
376
377 #[test]
378 fn test_severity_serializes_as_lowercase() {
379 assert_eq!(
380 serde_json::to_string(&Severity::Critical).unwrap(),
381 "\"critical\""
382 );
383 assert_eq!(
384 serde_json::to_string(&Severity::Warning).unwrap(),
385 "\"warning\""
386 );
387 assert_eq!(serde_json::to_string(&Severity::Info).unwrap(), "\"info\"");
388 }
389
390 #[test]
391 fn test_severity_deserializes_from_lowercase() {
392 assert_eq!(
393 serde_json::from_str::<Severity>("\"critical\"").unwrap(),
394 Severity::Critical
395 );
396 }
397
398 #[test]
399 fn test_severity_deserialization_rejects_invalid() {
400 assert!(serde_json::from_str::<Severity>("\"invalid\"").is_err());
401 }
402
403 #[test]
406 fn test_mode_display_outputs_hyphenated_lowercase() {
407 assert_eq!(format!("{}", Mode::CodeReview), "code-review");
408 assert_eq!(format!("{}", Mode::Design), "design");
409 assert_eq!(format!("{}", Mode::Analysis), "analysis");
410 }
411
412 #[test]
413 fn test_mode_serializes_as_lowercase_with_hyphens() {
414 assert_eq!(
415 serde_json::to_string(&Mode::CodeReview).unwrap(),
416 "\"code-review\""
417 );
418 assert_eq!(serde_json::to_string(&Mode::Design).unwrap(), "\"design\"");
419 assert_eq!(
420 serde_json::to_string(&Mode::Analysis).unwrap(),
421 "\"analysis\""
422 );
423 }
424
425 #[test]
426 fn test_mode_deserializes_from_lowercase_with_hyphens() {
427 assert_eq!(
428 serde_json::from_str::<Mode>("\"code-review\"").unwrap(),
429 Mode::CodeReview
430 );
431 assert_eq!(
432 serde_json::from_str::<Mode>("\"design\"").unwrap(),
433 Mode::Design
434 );
435 assert_eq!(
436 serde_json::from_str::<Mode>("\"analysis\"").unwrap(),
437 Mode::Analysis
438 );
439 }
440
441 #[test]
442 fn test_mode_deserialization_rejects_invalid() {
443 assert!(serde_json::from_str::<Mode>("\"invalid\"").is_err());
444 }
445
446 #[test]
449 fn test_agent_name_title_returns_role() {
450 assert_eq!(AgentName::Melchior.title(), "Scientist");
451 assert_eq!(AgentName::Balthasar.title(), "Pragmatist");
452 assert_eq!(AgentName::Caspar.title(), "Critic");
453 }
454
455 #[test]
456 fn test_agent_name_display_name_returns_name() {
457 assert_eq!(AgentName::Melchior.display_name(), "Melchior");
458 assert_eq!(AgentName::Balthasar.display_name(), "Balthasar");
459 assert_eq!(AgentName::Caspar.display_name(), "Caspar");
460 }
461
462 #[test]
463 fn test_agent_name_ord_is_alphabetical() {
464 assert!(AgentName::Balthasar < AgentName::Caspar);
465 assert!(AgentName::Caspar < AgentName::Melchior);
466 assert!(AgentName::Balthasar < AgentName::Melchior);
467 }
468
469 #[test]
470 fn test_agent_name_serializes_as_lowercase() {
471 assert_eq!(
472 serde_json::to_string(&AgentName::Melchior).unwrap(),
473 "\"melchior\""
474 );
475 assert_eq!(
476 serde_json::to_string(&AgentName::Balthasar).unwrap(),
477 "\"balthasar\""
478 );
479 assert_eq!(
480 serde_json::to_string(&AgentName::Caspar).unwrap(),
481 "\"caspar\""
482 );
483 }
484
485 #[test]
486 fn test_agent_name_deserializes_from_lowercase() {
487 assert_eq!(
488 serde_json::from_str::<AgentName>("\"melchior\"").unwrap(),
489 AgentName::Melchior
490 );
491 }
492
493 #[test]
494 fn test_agent_name_usable_as_btreemap_key() {
495 let mut map = BTreeMap::new();
496 map.insert(AgentName::Melchior, "scientist");
497 map.insert(AgentName::Balthasar, "pragmatist");
498 map.insert(AgentName::Caspar, "critic");
499 assert_eq!(map.get(&AgentName::Melchior), Some(&"scientist"));
500 assert_eq!(map.get(&AgentName::Balthasar), Some(&"pragmatist"));
501 assert_eq!(map.get(&AgentName::Caspar), Some(&"critic"));
502 }
503
504 #[test]
507 fn test_finding_stripped_title_removes_zero_width_characters() {
508 let finding = Finding {
509 severity: Severity::Warning,
510 title: "Hello\u{200B}World\u{FEFF}Test\u{200C}End".to_string(),
511 detail: "detail".to_string(),
512 };
513 assert_eq!(finding.stripped_title(), "HelloWorldTestEnd");
514 }
515
516 #[test]
517 fn test_finding_stripped_title_preserves_normal_text() {
518 let finding = Finding {
519 severity: Severity::Info,
520 title: "Normal title".to_string(),
521 detail: "detail".to_string(),
522 };
523 assert_eq!(finding.stripped_title(), "Normal title");
524 }
525
526 #[test]
527 fn test_finding_serde_roundtrip() {
528 let finding = Finding {
529 severity: Severity::Critical,
530 title: "Security issue".to_string(),
531 detail: "SQL injection vulnerability".to_string(),
532 };
533 let json = serde_json::to_string(&finding).unwrap();
534 let deserialized: Finding = serde_json::from_str(&json).unwrap();
535 assert_eq!(finding, deserialized);
536 }
537
538 fn make_output(verdict: Verdict) -> AgentOutput {
541 AgentOutput {
542 agent: AgentName::Melchior,
543 verdict,
544 confidence: 0.9,
545 summary: "summary".to_string(),
546 reasoning: "reasoning".to_string(),
547 findings: vec![],
548 recommendation: "recommendation".to_string(),
549 }
550 }
551
552 #[test]
553 fn test_agent_output_is_approving_true_for_approve() {
554 assert!(make_output(Verdict::Approve).is_approving());
555 }
556
557 #[test]
558 fn test_agent_output_is_approving_true_for_conditional() {
559 assert!(make_output(Verdict::Conditional).is_approving());
560 }
561
562 #[test]
563 fn test_agent_output_is_approving_false_for_reject() {
564 assert!(!make_output(Verdict::Reject).is_approving());
565 }
566
567 #[test]
568 fn test_agent_output_is_dissenting_when_verdict_differs_from_majority() {
569 let output = make_output(Verdict::Reject);
570 assert!(output.is_dissenting(Verdict::Approve));
571 }
572
573 #[test]
574 fn test_agent_output_is_not_dissenting_when_verdict_matches_majority() {
575 let output = make_output(Verdict::Approve);
576 assert!(!output.is_dissenting(Verdict::Approve));
577 }
578
579 #[test]
580 fn test_agent_output_conditional_is_not_dissenting_from_approve_majority() {
581 let output = make_output(Verdict::Conditional);
582 assert!(!output.is_dissenting(Verdict::Approve));
583 }
584
585 #[test]
586 fn test_agent_output_effective_verdict_maps_conditional_to_approve() {
587 let output = make_output(Verdict::Conditional);
588 assert_eq!(output.effective_verdict(), Verdict::Approve);
589 }
590
591 #[test]
592 fn test_agent_output_serde_roundtrip() {
593 let output = AgentOutput {
594 agent: AgentName::Balthasar,
595 verdict: Verdict::Conditional,
596 confidence: 0.75,
597 summary: "looks okay".to_string(),
598 reasoning: "mostly good".to_string(),
599 findings: vec![Finding {
600 severity: Severity::Warning,
601 title: "Minor issue".to_string(),
602 detail: "Could improve naming".to_string(),
603 }],
604 recommendation: "approve with changes".to_string(),
605 };
606 let json = serde_json::to_string(&output).unwrap();
607 let deserialized: AgentOutput = serde_json::from_str(&json).unwrap();
608 assert_eq!(output, deserialized);
609 }
610
611 #[test]
612 fn test_agent_output_empty_findings_valid() {
613 let output = make_output(Verdict::Approve);
614 assert!(output.findings.is_empty());
615 let json = serde_json::to_string(&output).unwrap();
616 let deserialized: AgentOutput = serde_json::from_str(&json).unwrap();
617 assert_eq!(output, deserialized);
618 }
619
620 #[test]
621 fn test_agent_output_ignores_unknown_fields() {
622 let json = r#"{
623 "agent": "caspar",
624 "verdict": "reject",
625 "confidence": 0.3,
626 "summary": "bad",
627 "reasoning": "terrible",
628 "findings": [],
629 "recommendation": "reject",
630 "unknown_field": "should be ignored"
631 }"#;
632 let output: AgentOutput = serde_json::from_str(json).unwrap();
633 assert_eq!(output.agent, AgentName::Caspar);
634 assert_eq!(output.verdict, Verdict::Reject);
635 }
636}