1use std::process::ExitCode;
9
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "lowercase")]
17pub enum Severity {
18 Info,
19 Warn,
20 Error,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct Finding {
27 pub rule: String,
28 pub message: String,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub file: Option<String>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub line: Option<u32>,
37 pub severity: Severity,
38}
39
40#[derive(Debug, Clone)]
43pub enum Verdict {
44 Pass,
45 Warn {
46 findings: Vec<Finding>,
47 message: Option<String>,
48 },
49 Fail {
50 findings: Vec<Finding>,
51 message: String,
52 },
53}
54
55impl Verdict {
56 pub fn is_blocking(&self) -> bool {
59 matches!(self, Verdict::Fail { .. })
60 }
61
62 pub fn exit_code(&self) -> ExitCode {
65 if self.is_blocking() {
66 ExitCode::from(2)
67 } else {
68 ExitCode::SUCCESS
69 }
70 }
71
72 pub fn merge(verdicts: Vec<Verdict>, policy: VerdictPolicy) -> Verdict {
80 match policy {
81 VerdictPolicy::AnyFail => merge_any_fail(verdicts),
82 VerdictPolicy::AllFail => merge_all_fail(verdicts),
83 VerdictPolicy::MajorityFail => merge_majority_fail(verdicts),
84 }
85 }
86}
87
88fn merge_any_fail(verdicts: Vec<Verdict>) -> Verdict {
89 let mut fail_findings: Vec<Finding> = Vec::new();
90 let mut fail_messages: Vec<String> = Vec::new();
91 let mut warn_findings: Vec<Finding> = Vec::new();
92 let mut warn_messages: Vec<String> = Vec::new();
93
94 for verdict in verdicts {
95 match verdict {
96 Verdict::Pass => {}
97 Verdict::Warn { findings, message } => {
98 warn_findings.extend(findings);
99 if let Some(m) = message {
100 warn_messages.push(m);
101 }
102 }
103 Verdict::Fail { findings, message } => {
104 fail_findings.extend(findings);
105 fail_messages.push(message);
106 }
107 }
108 }
109
110 if !fail_messages.is_empty() {
111 Verdict::Fail {
112 findings: fail_findings,
113 message: fail_messages.join("\n"),
114 }
115 } else if !warn_findings.is_empty() || !warn_messages.is_empty() {
116 Verdict::Warn {
117 findings: warn_findings,
118 message: if warn_messages.is_empty() {
119 None
120 } else {
121 Some(warn_messages.join("\n"))
122 },
123 }
124 } else {
125 Verdict::Pass
126 }
127}
128
129struct Partition {
133 fail_count: usize,
134 pass_count: usize,
135 fail_findings: Vec<Finding>,
136 fail_messages: Vec<String>,
137 warn_findings: Vec<Finding>,
138 warn_messages: Vec<String>,
139}
140
141fn partition(verdicts: Vec<Verdict>) -> Partition {
142 let mut p = Partition {
143 fail_count: 0,
144 pass_count: 0,
145 fail_findings: Vec::new(),
146 fail_messages: Vec::new(),
147 warn_findings: Vec::new(),
148 warn_messages: Vec::new(),
149 };
150 for v in verdicts {
151 match v {
152 Verdict::Pass => p.pass_count += 1,
153 Verdict::Warn { findings, message } => {
154 p.warn_findings.extend(findings);
155 if let Some(m) = message {
156 p.warn_messages.push(m);
157 }
158 }
159 Verdict::Fail { findings, message } => {
160 p.fail_count += 1;
161 p.fail_findings.extend(findings);
162 p.fail_messages.push(message);
163 }
164 }
165 }
166 p
167}
168
169fn downgrade_to_warn_or_pass(p: Partition) -> Verdict {
175 if !p.fail_messages.is_empty() {
176 let mut findings = p.fail_findings;
178 findings.extend(p.warn_findings);
179 let mut messages = p.fail_messages;
180 messages.extend(p.warn_messages);
181 Verdict::Warn {
182 findings,
183 message: Some(messages.join("\n")),
184 }
185 } else if !p.warn_findings.is_empty() || !p.warn_messages.is_empty() {
186 Verdict::Warn {
187 findings: p.warn_findings,
188 message: if p.warn_messages.is_empty() {
189 None
190 } else {
191 Some(p.warn_messages.join("\n"))
192 },
193 }
194 } else {
195 Verdict::Pass
196 }
197}
198
199fn merge_all_fail(verdicts: Vec<Verdict>) -> Verdict {
203 let p = partition(verdicts);
204 if p.fail_count > 0 && p.pass_count == 0 {
205 Verdict::Fail {
206 findings: p.fail_findings,
207 message: p.fail_messages.join("\n"),
208 }
209 } else {
210 downgrade_to_warn_or_pass(p)
211 }
212}
213
214fn merge_majority_fail(verdicts: Vec<Verdict>) -> Verdict {
218 let p = partition(verdicts);
219 let total_decisive = p.fail_count + p.pass_count;
220 if total_decisive > 0 && p.fail_count * 2 > total_decisive {
221 Verdict::Fail {
222 findings: p.fail_findings,
223 message: p.fail_messages.join("\n"),
224 }
225 } else {
226 downgrade_to_warn_or_pass(p)
227 }
228}
229
230#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
244#[serde(rename_all = "snake_case")]
245pub enum VerdictPolicy {
246 #[default]
247 AnyFail,
248 AllFail,
250 MajorityFail,
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 fn finding(rule: &str, severity: Severity) -> Finding {
259 Finding {
260 rule: rule.into(),
261 message: "msg".into(),
262 file: None,
263 line: None,
264 severity,
265 }
266 }
267
268 #[test]
269 fn pass_is_not_blocking() {
270 assert!(!Verdict::Pass.is_blocking());
271 }
272
273 #[test]
274 fn warn_is_not_blocking() {
275 let v = Verdict::Warn {
276 findings: vec![finding("r", Severity::Warn)],
277 message: None,
278 };
279 assert!(!v.is_blocking());
280 }
281
282 #[test]
283 fn fail_is_blocking() {
284 let v = Verdict::Fail {
285 findings: vec![],
286 message: "boom".into(),
287 };
288 assert!(v.is_blocking());
289 }
290
291 #[test]
292 fn merge_empty_is_pass() {
293 let v = Verdict::merge(vec![], VerdictPolicy::AnyFail);
294 assert!(matches!(v, Verdict::Pass));
295 }
296
297 #[test]
298 fn merge_all_pass_is_pass() {
299 let v = Verdict::merge(
300 vec![Verdict::Pass, Verdict::Pass, Verdict::Pass],
301 VerdictPolicy::AnyFail,
302 );
303 assert!(matches!(v, Verdict::Pass));
304 }
305
306 #[test]
307 fn merge_warn_among_pass_is_warn() {
308 let v = Verdict::merge(
309 vec![
310 Verdict::Pass,
311 Verdict::Warn {
312 findings: vec![finding("a", Severity::Warn)],
313 message: Some("notice".into()),
314 },
315 Verdict::Pass,
316 ],
317 VerdictPolicy::AnyFail,
318 );
319 match v {
320 Verdict::Warn { findings, message } => {
321 assert_eq!(findings.len(), 1);
322 assert_eq!(message.as_deref(), Some("notice"));
323 }
324 other => panic!("expected Warn, got {other:?}"),
325 }
326 }
327
328 #[test]
329 fn merge_any_fail_is_fail() {
330 let v = Verdict::merge(
331 vec![
332 Verdict::Pass,
333 Verdict::Warn {
334 findings: vec![finding("w", Severity::Warn)],
335 message: None,
336 },
337 Verdict::Fail {
338 findings: vec![finding("f", Severity::Error)],
339 message: "broken".into(),
340 },
341 ],
342 VerdictPolicy::AnyFail,
343 );
344 match v {
345 Verdict::Fail { findings, message } => {
346 assert_eq!(findings.len(), 1);
347 assert_eq!(message, "broken");
348 }
349 other => panic!("expected Fail, got {other:?}"),
350 }
351 }
352
353 fn fail(msg: &str) -> Verdict {
356 Verdict::Fail {
357 findings: vec![finding(msg, Severity::Error)],
358 message: msg.into(),
359 }
360 }
361
362 fn warn_v() -> Verdict {
363 Verdict::Warn {
364 findings: vec![finding("w", Severity::Warn)],
365 message: Some("warn".into()),
366 }
367 }
368
369 #[test]
370 fn all_fail_empty_is_pass() {
371 assert!(matches!(
372 Verdict::merge(vec![], VerdictPolicy::AllFail),
373 Verdict::Pass
374 ));
375 }
376
377 #[test]
378 fn all_fail_all_pass_is_pass() {
379 let v = Verdict::merge(
380 vec![Verdict::Pass, Verdict::Pass, Verdict::Pass],
381 VerdictPolicy::AllFail,
382 );
383 assert!(matches!(v, Verdict::Pass));
384 }
385
386 #[test]
387 fn all_fail_all_fail_is_fail() {
388 let v = Verdict::merge(
390 vec![fail("a"), fail("b"), fail("c")],
391 VerdictPolicy::AllFail,
392 );
393 assert!(v.is_blocking(), "expected Fail (blocking), got {v:?}");
394 match v {
395 Verdict::Fail { findings, .. } => assert_eq!(findings.len(), 3),
396 other => panic!("expected Fail, got {other:?}"),
397 }
398 }
399
400 #[test]
401 fn all_fail_mixed_pass_and_fail_is_warn() {
402 let v = Verdict::merge(
404 vec![Verdict::Pass, fail("x"), fail("y")],
405 VerdictPolicy::AllFail,
406 );
407 assert!(!v.is_blocking(), "expected non-blocking, got {v:?}");
408 assert!(matches!(v, Verdict::Warn { .. }));
409 }
410
411 #[test]
412 fn all_fail_warn_only_is_warn() {
413 let v = Verdict::merge(vec![warn_v(), Verdict::Pass], VerdictPolicy::AllFail);
415 assert!(!v.is_blocking());
416 assert!(matches!(v, Verdict::Warn { .. }));
417 }
418
419 #[test]
420 fn all_fail_only_warns_no_pass_no_fail_is_warn() {
421 let v = Verdict::merge(vec![warn_v(), warn_v()], VerdictPolicy::AllFail);
423 assert!(!v.is_blocking());
424 assert!(matches!(v, Verdict::Warn { .. }));
425 }
426
427 #[test]
428 fn all_fail_fail_with_warn_no_pass_is_fail() {
429 let v = Verdict::merge(vec![fail("a"), fail("b"), warn_v()], VerdictPolicy::AllFail);
432 assert!(v.is_blocking(), "expected Fail, got {v:?}");
433 }
434
435 #[test]
436 fn all_fail_single_fail_single_pass_is_warn() {
437 let v = Verdict::merge(vec![fail("f"), Verdict::Pass], VerdictPolicy::AllFail);
439 assert!(!v.is_blocking());
440 assert!(matches!(v, Verdict::Warn { .. }));
441 }
442
443 #[test]
446 fn majority_fail_empty_is_pass() {
447 assert!(matches!(
448 Verdict::merge(vec![], VerdictPolicy::MajorityFail),
449 Verdict::Pass
450 ));
451 }
452
453 #[test]
454 fn majority_fail_all_pass_is_pass() {
455 let v = Verdict::merge(
456 vec![Verdict::Pass, Verdict::Pass, Verdict::Pass],
457 VerdictPolicy::MajorityFail,
458 );
459 assert!(matches!(v, Verdict::Pass));
460 }
461
462 #[test]
463 fn majority_fail_all_fail_is_fail() {
464 let v = Verdict::merge(
466 vec![fail("a"), fail("b"), fail("c")],
467 VerdictPolicy::MajorityFail,
468 );
469 assert!(v.is_blocking(), "expected Fail, got {v:?}");
470 }
471
472 #[test]
473 fn majority_fail_strict_majority_3p_0f() {
474 let v = Verdict::merge(
476 vec![Verdict::Pass, Verdict::Pass, Verdict::Pass],
477 VerdictPolicy::MajorityFail,
478 );
479 assert!(matches!(v, Verdict::Pass));
480 }
481
482 #[test]
483 fn majority_fail_strict_majority_1p_2f() {
484 let v = Verdict::merge(
486 vec![Verdict::Pass, fail("x"), fail("y")],
487 VerdictPolicy::MajorityFail,
488 );
489 assert!(v.is_blocking(), "expected Fail, got {v:?}");
490 }
491
492 #[test]
493 fn majority_fail_tie_2p_2f_is_warn() {
494 let v = Verdict::merge(
496 vec![Verdict::Pass, Verdict::Pass, fail("a"), fail("b")],
497 VerdictPolicy::MajorityFail,
498 );
499 assert!(!v.is_blocking(), "expected non-blocking on tie, got {v:?}");
500 assert!(matches!(v, Verdict::Warn { .. }));
501 }
502
503 #[test]
504 fn majority_fail_2p_1f_is_warn() {
505 let v = Verdict::merge(
507 vec![Verdict::Pass, Verdict::Pass, fail("f")],
508 VerdictPolicy::MajorityFail,
509 );
510 assert!(!v.is_blocking());
511 assert!(matches!(v, Verdict::Warn { .. }));
512 }
513
514 #[test]
515 fn majority_fail_warns_ignored_in_count() {
516 let v = Verdict::merge(
518 vec![Verdict::Pass, fail("f"), warn_v(), warn_v()],
519 VerdictPolicy::MajorityFail,
520 );
521 assert!(!v.is_blocking(), "expected non-blocking, got {v:?}");
522 assert!(matches!(v, Verdict::Warn { .. }));
523 }
524
525 #[test]
526 fn majority_fail_0p_3f_with_warns_is_fail() {
527 let v = Verdict::merge(
529 vec![fail("a"), fail("b"), fail("c"), warn_v()],
530 VerdictPolicy::MajorityFail,
531 );
532 assert!(v.is_blocking(), "expected Fail, got {v:?}");
533 }
534
535 #[test]
538 fn unknown_policy_rejected_at_config_parse() {
539 use crate::config::ConfigV1;
540 let toml = r#"
541 version = 1
542 [gate]
543 agents = ["claude_code"]
544 policy = "made_up"
545 "#;
546 let err = ConfigV1::parse(toml).expect_err("unknown policy should fail at parse");
547 use crate::error::KlaspError;
549 assert!(
550 matches!(err, KlaspError::ConfigParse(_)),
551 "expected ConfigParse, got {err:?}"
552 );
553 }
554
555 #[test]
556 fn all_three_policy_values_parse_in_config() {
557 use crate::config::ConfigV1;
558 for policy_str in &["any_fail", "all_fail", "majority_fail"] {
559 let toml = format!(
560 r#"
561 version = 1
562 [gate]
563 agents = ["claude_code"]
564 policy = "{policy_str}"
565 "#
566 );
567 let cfg = ConfigV1::parse(&toml)
568 .unwrap_or_else(|e| panic!("policy '{policy_str}' should parse, got: {e:?}"));
569 let expected = match *policy_str {
570 "any_fail" => VerdictPolicy::AnyFail,
571 "all_fail" => VerdictPolicy::AllFail,
572 "majority_fail" => VerdictPolicy::MajorityFail,
573 _ => unreachable!(),
574 };
575 assert_eq!(cfg.gate.policy, expected, "policy '{policy_str}' mismatch");
576 }
577 }
578}