1use crate::error::MagiError;
6use crate::schema::{AgentOutput, Finding, ZERO_WIDTH_PATTERN};
7
8#[non_exhaustive]
10#[derive(Debug, Clone)]
11pub struct ValidationLimits {
12 pub max_findings: usize,
14 pub max_title_len: usize,
16 pub max_detail_len: usize,
18 pub max_text_len: usize,
21 pub confidence_min: f64,
23 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
40pub struct Validator {
45 pub limits: ValidationLimits,
47}
48
49impl Default for Validator {
50 fn default() -> Self {
51 Self::new()
52 }
53}
54
55impl Validator {
56 pub fn new() -> Self {
58 Self::with_limits(ValidationLimits::default())
59 }
60
61 pub fn with_limits(limits: ValidationLimits) -> Self {
63 Self { limits }
64 }
65
66 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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}