Skip to main content

klasp_core/
verdict.rs

1//! `Verdict` — three-tier outcome of a check run, plus aggregation policies.
2//!
3//! Design: [docs/design.md §3.4]. The Warn tier is the staged-rollout gradient
4//! that lets new checks land without immediately blocking. `Finding` derives
5//! `Clone` because verdicts are merged across multiple `CheckResult`s — the
6//! aggregation in `Verdict::merge` requires copies of the input findings.
7
8use std::process::ExitCode;
9
10use serde::{Deserialize, Serialize};
11
12/// Severity attached to an individual `Finding`. Verdict tier is decided by
13/// the runtime aggregating findings; severity here is informational metadata
14/// for the rendered block message.
15#[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/// A single actionable item produced by a check (a lint hit, a failed test,
24/// a rule violation). Renders into the agent-visible block message.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct Finding {
27    pub rule: String,
28    pub message: String,
29    /// File path the finding refers to, formatted via `Path::to_string_lossy()`
30    /// at construction time. Stored as `String` (not `PathBuf`) so JSON
31    /// serialisation on Windows can't fail on non-UTF-8 paths — findings are
32    /// human-rendered anyway, lossy display is acceptable.
33    #[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/// Three-tier verdict. `Pass` and `Warn` are non-blocking (exit 0); `Fail` is
41/// blocking (exit 2 — the Claude Code convention for "deny the tool call").
42#[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    /// Only `Fail` blocks the tool call. `Warn` renders a notice but allows
57    /// the agent to proceed.
58    pub fn is_blocking(&self) -> bool {
59        matches!(self, Verdict::Fail { .. })
60    }
61
62    /// Map a verdict to the `klasp gate` process exit code.
63    /// Pass / Warn → 0, Fail → 2.
64    pub fn exit_code(&self) -> ExitCode {
65        if self.is_blocking() {
66            ExitCode::from(2)
67        } else {
68            ExitCode::SUCCESS
69        }
70    }
71
72    /// Aggregate per-check verdicts into a single final verdict.
73    /// v0.1 only ships [`VerdictPolicy::AnyFail`].
74    pub fn merge(verdicts: Vec<Verdict>, policy: VerdictPolicy) -> Verdict {
75        match policy {
76            VerdictPolicy::AnyFail => merge_any_fail(verdicts),
77        }
78    }
79}
80
81fn merge_any_fail(verdicts: Vec<Verdict>) -> Verdict {
82    let mut fail_findings: Vec<Finding> = Vec::new();
83    let mut fail_messages: Vec<String> = Vec::new();
84    let mut warn_findings: Vec<Finding> = Vec::new();
85    let mut warn_messages: Vec<String> = Vec::new();
86
87    for verdict in verdicts {
88        match verdict {
89            Verdict::Pass => {}
90            Verdict::Warn { findings, message } => {
91                warn_findings.extend(findings);
92                if let Some(m) = message {
93                    warn_messages.push(m);
94                }
95            }
96            Verdict::Fail { findings, message } => {
97                fail_findings.extend(findings);
98                fail_messages.push(message);
99            }
100        }
101    }
102
103    if !fail_messages.is_empty() {
104        Verdict::Fail {
105            findings: fail_findings,
106            message: fail_messages.join("\n"),
107        }
108    } else if !warn_findings.is_empty() || !warn_messages.is_empty() {
109        Verdict::Warn {
110            findings: warn_findings,
111            message: if warn_messages.is_empty() {
112                None
113            } else {
114                Some(warn_messages.join("\n"))
115            },
116        }
117    } else {
118        Verdict::Pass
119    }
120}
121
122/// Policy for combining multiple per-check verdicts.
123///
124/// v0.1 ships only `AnyFail` (any failing check fails the gate). v0.2.5 adds
125/// `AllFail` and `MajorityFail` per [docs/roadmap.md].
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
127#[serde(rename_all = "snake_case")]
128pub enum VerdictPolicy {
129    #[default]
130    AnyFail,
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    fn finding(rule: &str, severity: Severity) -> Finding {
138        Finding {
139            rule: rule.into(),
140            message: "msg".into(),
141            file: None,
142            line: None,
143            severity,
144        }
145    }
146
147    #[test]
148    fn pass_is_not_blocking() {
149        assert!(!Verdict::Pass.is_blocking());
150    }
151
152    #[test]
153    fn warn_is_not_blocking() {
154        let v = Verdict::Warn {
155            findings: vec![finding("r", Severity::Warn)],
156            message: None,
157        };
158        assert!(!v.is_blocking());
159    }
160
161    #[test]
162    fn fail_is_blocking() {
163        let v = Verdict::Fail {
164            findings: vec![],
165            message: "boom".into(),
166        };
167        assert!(v.is_blocking());
168    }
169
170    #[test]
171    fn merge_empty_is_pass() {
172        let v = Verdict::merge(vec![], VerdictPolicy::AnyFail);
173        assert!(matches!(v, Verdict::Pass));
174    }
175
176    #[test]
177    fn merge_all_pass_is_pass() {
178        let v = Verdict::merge(
179            vec![Verdict::Pass, Verdict::Pass, Verdict::Pass],
180            VerdictPolicy::AnyFail,
181        );
182        assert!(matches!(v, Verdict::Pass));
183    }
184
185    #[test]
186    fn merge_warn_among_pass_is_warn() {
187        let v = Verdict::merge(
188            vec![
189                Verdict::Pass,
190                Verdict::Warn {
191                    findings: vec![finding("a", Severity::Warn)],
192                    message: Some("notice".into()),
193                },
194                Verdict::Pass,
195            ],
196            VerdictPolicy::AnyFail,
197        );
198        match v {
199            Verdict::Warn { findings, message } => {
200                assert_eq!(findings.len(), 1);
201                assert_eq!(message.as_deref(), Some("notice"));
202            }
203            other => panic!("expected Warn, got {other:?}"),
204        }
205    }
206
207    #[test]
208    fn merge_any_fail_is_fail() {
209        let v = Verdict::merge(
210            vec![
211                Verdict::Pass,
212                Verdict::Warn {
213                    findings: vec![finding("w", Severity::Warn)],
214                    message: None,
215                },
216                Verdict::Fail {
217                    findings: vec![finding("f", Severity::Error)],
218                    message: "broken".into(),
219                },
220            ],
221            VerdictPolicy::AnyFail,
222        );
223        match v {
224            Verdict::Fail { findings, message } => {
225                assert_eq!(findings.len(), 1);
226                assert_eq!(message, "broken");
227            }
228            other => panic!("expected Fail, got {other:?}"),
229        }
230    }
231}