Skip to main content

magi_core/
validate.rs

1// Author: Julian Bolivar
2// Version: 1.0.0
3// Date: 2026-04-05
4
5use crate::error::MagiError;
6use crate::schema::{AgentOutput, Finding, ZERO_WIDTH_PATTERN};
7
8/// Configuration thresholds for agent output validation.
9#[non_exhaustive]
10#[derive(Debug, Clone)]
11pub struct ValidationLimits {
12    /// Maximum number of findings per agent output.
13    pub max_findings: usize,
14    /// Maximum character count for finding titles (Unicode scalar values, not bytes).
15    pub max_title_len: usize,
16    /// Maximum character count for finding details (Unicode scalar values, not bytes).
17    pub max_detail_len: usize,
18    /// Maximum character count for text fields — summary, reasoning, recommendation
19    /// (Unicode scalar values, not bytes).
20    pub max_text_len: usize,
21    /// Minimum valid confidence value, inclusive.
22    pub confidence_min: f64,
23    /// Maximum valid confidence value, inclusive.
24    pub confidence_max: f64,
25}
26
27impl Default for ValidationLimits {
28    fn default() -> Self {
29        Self {
30            max_findings: 100,
31            max_title_len: 500,
32            max_detail_len: 10_000,
33            max_text_len: 50_000,
34            confidence_min: 0.0,
35            confidence_max: 1.0,
36        }
37    }
38}
39
40/// Validates `AgentOutput` fields against configurable limits.
41///
42/// Uses [`ZERO_WIDTH_PATTERN`] from `schema` for stripping zero-width Unicode
43/// characters, and configurable limits for field lengths and counts.
44pub struct Validator {
45    /// Active validation limits.
46    pub limits: ValidationLimits,
47}
48
49impl Default for Validator {
50    fn default() -> Self {
51        Self::new()
52    }
53}
54
55impl Validator {
56    /// Creates a validator with default limits.
57    pub fn new() -> Self {
58        Self::with_limits(ValidationLimits::default())
59    }
60
61    /// Creates a validator with custom limits.
62    pub fn with_limits(limits: ValidationLimits) -> Self {
63        Self { limits }
64    }
65
66    /// Validates an `AgentOutput`, returning on first failure.
67    ///
68    /// Checks in order: confidence, summary, reasoning, recommendation, findings.
69    /// Returns `MagiError::Validation` with a descriptive message on failure.
70    pub fn validate(&self, output: &AgentOutput) -> Result<(), MagiError> {
71        self.validate_confidence(output.confidence)?;
72        self.validate_text_field("summary", &output.summary)?;
73        self.validate_text_field("reasoning", &output.reasoning)?;
74        self.validate_text_field("recommendation", &output.recommendation)?;
75        self.validate_findings(&output.findings)?;
76        Ok(())
77    }
78
79    fn validate_confidence(&self, confidence: f64) -> Result<(), MagiError> {
80        if !(confidence >= self.limits.confidence_min && confidence <= self.limits.confidence_max) {
81            return Err(MagiError::Validation(format!(
82                "confidence {} is out of range [{}, {}]",
83                confidence, self.limits.confidence_min, self.limits.confidence_max
84            )));
85        }
86        Ok(())
87    }
88
89    fn validate_text_field(&self, field_name: &str, value: &str) -> Result<(), MagiError> {
90        if value.chars().count() > self.limits.max_text_len {
91            return Err(MagiError::Validation(format!(
92                "{field_name} exceeds maximum length of {} characters",
93                self.limits.max_text_len
94            )));
95        }
96        Ok(())
97    }
98
99    fn validate_findings(&self, findings: &[Finding]) -> Result<(), MagiError> {
100        if findings.len() > self.limits.max_findings {
101            return Err(MagiError::Validation(format!(
102                "findings count {} exceeds maximum of {}",
103                findings.len(),
104                self.limits.max_findings
105            )));
106        }
107        for finding in findings {
108            self.validate_finding(finding)?;
109        }
110        Ok(())
111    }
112
113    fn validate_finding(&self, finding: &Finding) -> Result<(), MagiError> {
114        let stripped = self.strip_zero_width(&finding.title);
115        if stripped.is_empty() {
116            return Err(MagiError::Validation(
117                "finding title is empty after removing zero-width characters".to_string(),
118            ));
119        }
120        if stripped.chars().count() > self.limits.max_title_len {
121            return Err(MagiError::Validation(format!(
122                "finding title exceeds maximum length of {} characters",
123                self.limits.max_title_len
124            )));
125        }
126        if finding.detail.chars().count() > self.limits.max_detail_len {
127            return Err(MagiError::Validation(format!(
128                "finding detail exceeds maximum length of {} characters",
129                self.limits.max_detail_len
130            )));
131        }
132        Ok(())
133    }
134
135    fn strip_zero_width(&self, text: &str) -> String {
136        ZERO_WIDTH_PATTERN.replace_all(text, "").into_owned()
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::schema::*;
144
145    fn valid_agent_output() -> AgentOutput {
146        AgentOutput {
147            agent: AgentName::Melchior,
148            verdict: Verdict::Approve,
149            confidence: 0.9,
150            summary: "Good code".to_string(),
151            reasoning: "Well structured".to_string(),
152            findings: vec![],
153            recommendation: "Approve as-is".to_string(),
154        }
155    }
156
157    fn output_with_confidence(confidence: f64) -> AgentOutput {
158        AgentOutput {
159            confidence,
160            ..valid_agent_output()
161        }
162    }
163
164    fn output_with_findings(findings: Vec<Finding>) -> AgentOutput {
165        AgentOutput {
166            findings,
167            ..valid_agent_output()
168        }
169    }
170
171    // -- Constructor tests --
172
173    #[test]
174    fn test_validator_new_creates_with_default_limits() {
175        let v = Validator::new();
176        assert_eq!(v.limits.max_findings, 100);
177        assert_eq!(v.limits.max_title_len, 500);
178        assert_eq!(v.limits.max_detail_len, 10_000);
179        assert_eq!(v.limits.max_text_len, 50_000);
180        assert!((v.limits.confidence_min - 0.0).abs() < f64::EPSILON);
181        assert!((v.limits.confidence_max - 1.0).abs() < f64::EPSILON);
182    }
183
184    #[test]
185    fn test_validator_with_limits_uses_custom_limits() {
186        let custom = ValidationLimits {
187            max_findings: 5,
188            ..ValidationLimits::default()
189        };
190        let v = Validator::with_limits(custom);
191        assert_eq!(v.limits.max_findings, 5);
192    }
193
194    // -- BDD-10: confidence out of range --
195
196    #[test]
197    fn test_validate_rejects_confidence_above_one() {
198        let v = Validator::new();
199        let output = output_with_confidence(1.5);
200        let err = v.validate(&output).unwrap_err();
201        let msg = format!("{err}");
202        assert!(
203            msg.contains("confidence"),
204            "error should mention confidence: {msg}"
205        );
206    }
207
208    #[test]
209    fn test_validate_rejects_confidence_below_zero() {
210        let v = Validator::new();
211        let output = output_with_confidence(-0.1);
212        let err = v.validate(&output).unwrap_err();
213        let msg = format!("{err}");
214        assert!(
215            msg.contains("confidence"),
216            "error should mention confidence: {msg}"
217        );
218    }
219
220    #[test]
221    fn test_validate_accepts_confidence_at_boundaries() {
222        let v = Validator::new();
223        assert!(v.validate(&output_with_confidence(0.0)).is_ok());
224        assert!(v.validate(&output_with_confidence(1.0)).is_ok());
225    }
226
227    #[test]
228    fn test_validate_rejects_nan_confidence() {
229        let v = Validator::new();
230        let output = output_with_confidence(f64::NAN);
231        assert!(v.validate(&output).is_err());
232    }
233
234    #[test]
235    fn test_validate_rejects_infinity_confidence() {
236        let v = Validator::new();
237        assert!(v.validate(&output_with_confidence(f64::INFINITY)).is_err());
238        assert!(
239            v.validate(&output_with_confidence(f64::NEG_INFINITY))
240                .is_err()
241        );
242    }
243
244    // -- BDD-11: empty title after strip zero-width --
245
246    #[test]
247    fn test_validate_rejects_finding_with_only_zero_width_title() {
248        let v = Validator::new();
249        let output = output_with_findings(vec![Finding {
250            severity: Severity::Warning,
251            title: "\u{200B}\u{FEFF}\u{200C}".to_string(),
252            detail: "detail".to_string(),
253        }]);
254        let err = v.validate(&output).unwrap_err();
255        let msg = format!("{err}");
256        assert!(msg.contains("title"), "error should mention title: {msg}");
257    }
258
259    #[test]
260    fn test_validate_accepts_finding_with_normal_title() {
261        let v = Validator::new();
262        let output = output_with_findings(vec![Finding {
263            severity: Severity::Info,
264            title: "Security vulnerability".to_string(),
265            detail: "detail".to_string(),
266        }]);
267        assert!(v.validate(&output).is_ok());
268    }
269
270    // -- BDD-12: text field exceeds max_text_len --
271
272    #[test]
273    fn test_validate_rejects_reasoning_exceeding_max_text_len() {
274        let v = Validator::new();
275        let mut output = valid_agent_output();
276        output.reasoning = "x".repeat(50_001);
277        let err = v.validate(&output).unwrap_err();
278        let msg = format!("{err}");
279        assert!(
280            msg.contains("reasoning"),
281            "error should mention reasoning: {msg}"
282        );
283    }
284
285    #[test]
286    fn test_validate_rejects_summary_exceeding_max_text_len() {
287        let v = Validator::new();
288        let mut output = valid_agent_output();
289        output.summary = "x".repeat(50_001);
290        let err = v.validate(&output).unwrap_err();
291        let msg = format!("{err}");
292        assert!(
293            msg.contains("summary"),
294            "error should mention summary: {msg}"
295        );
296    }
297
298    #[test]
299    fn test_validate_rejects_recommendation_exceeding_max_text_len() {
300        let v = Validator::new();
301        let mut output = valid_agent_output();
302        output.recommendation = "x".repeat(50_001);
303        let err = v.validate(&output).unwrap_err();
304        let msg = format!("{err}");
305        assert!(
306            msg.contains("recommendation"),
307            "error should mention recommendation: {msg}"
308        );
309    }
310
311    // -- Findings count and field limits --
312
313    #[test]
314    fn test_validate_rejects_findings_count_exceeding_max_findings() {
315        let v = Validator::new();
316        let findings: Vec<Finding> = (0..101)
317            .map(|i| Finding {
318                severity: Severity::Info,
319                title: format!("Finding {i}"),
320                detail: "detail".to_string(),
321            })
322            .collect();
323        let output = output_with_findings(findings);
324        let err = v.validate(&output).unwrap_err();
325        let msg = format!("{err}");
326        assert!(
327            msg.contains("findings"),
328            "error should mention findings: {msg}"
329        );
330    }
331
332    #[test]
333    fn test_validate_rejects_finding_title_exceeding_max_title_len() {
334        let v = Validator::new();
335        let output = output_with_findings(vec![Finding {
336            severity: Severity::Warning,
337            title: "x".repeat(501),
338            detail: "detail".to_string(),
339        }]);
340        let err = v.validate(&output).unwrap_err();
341        let msg = format!("{err}");
342        assert!(msg.contains("title"), "error should mention title: {msg}");
343    }
344
345    #[test]
346    fn test_validate_rejects_finding_detail_exceeding_max_detail_len() {
347        let v = Validator::new();
348        let output = output_with_findings(vec![Finding {
349            severity: Severity::Info,
350            title: "Valid title".to_string(),
351            detail: "x".repeat(10_001),
352        }]);
353        let err = v.validate(&output).unwrap_err();
354        let msg = format!("{err}");
355        assert!(msg.contains("detail"), "error should mention detail: {msg}");
356    }
357
358    // -- Happy path --
359
360    #[test]
361    fn test_validate_accepts_valid_agent_output() {
362        let v = Validator::new();
363        assert!(v.validate(&valid_agent_output()).is_ok());
364    }
365
366    // -- strip_zero_width --
367
368    #[test]
369    fn test_strip_zero_width_removes_cf_category_characters() {
370        let v = Validator::new();
371        let input = "Hello\u{200B}World\u{FEFF}Test\u{200C}End";
372        let result = v.strip_zero_width(input);
373        assert_eq!(result, "HelloWorldTestEnd");
374    }
375
376    // -- Validation order --
377
378    #[test]
379    fn test_validation_order_confidence_checked_before_text_fields() {
380        let v = Validator::new();
381        let mut output = valid_agent_output();
382        output.confidence = 2.0;
383        output.summary = "x".repeat(50_001);
384        let err = v.validate(&output).unwrap_err();
385        let msg = format!("{err}");
386        assert!(
387            msg.contains("confidence"),
388            "confidence should be checked first, got: {msg}"
389        );
390    }
391
392    #[test]
393    fn test_validation_order_summary_checked_before_reasoning() {
394        let v = Validator::new();
395        let mut output = valid_agent_output();
396        output.summary = "x".repeat(50_001);
397        output.reasoning = "x".repeat(50_001);
398        let err = v.validate(&output).unwrap_err();
399        let msg = format!("{err}");
400        assert!(
401            msg.contains("summary"),
402            "summary should be checked before reasoning, got: {msg}"
403        );
404    }
405
406    #[test]
407    fn test_validation_order_recommendation_checked_before_findings() {
408        let v = Validator::new();
409        let mut output = valid_agent_output();
410        output.recommendation = "x".repeat(50_001);
411        output.findings = (0..101)
412            .map(|i| Finding {
413                severity: Severity::Info,
414                title: format!("Finding {i}"),
415                detail: "detail".to_string(),
416            })
417            .collect();
418        let err = v.validate(&output).unwrap_err();
419        let msg = format!("{err}");
420        assert!(
421            msg.contains("recommendation"),
422            "recommendation should be checked before findings, got: {msg}"
423        );
424    }
425
426    // -- Title length checked after strip --
427
428    #[test]
429    fn test_title_length_checked_after_strip_zero_width() {
430        let limits = ValidationLimits {
431            max_title_len: 5,
432            ..ValidationLimits::default()
433        };
434        let v = Validator::with_limits(limits);
435        // Title is 8 chars raw but 5 after stripping 3 zero-width chars => should pass
436        let output = output_with_findings(vec![Finding {
437            severity: Severity::Info,
438            title: "He\u{200B}l\u{FEFF}lo\u{200C}".to_string(),
439            detail: "detail".to_string(),
440        }]);
441        assert!(v.validate(&output).is_ok());
442    }
443}