Skip to main content

luft_core/contract/
finding.rs

1//! Structured finding schema (§1.3) — the data-plane output contract.
2
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5
6/// A structured finding reported by an agent via MCP `report_finding`.
7/// The schema *is* the contract — agents emit these instead of free text.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct Finding {
10    /// Category, e.g. "missing_auth" / "source".
11    pub kind: String,
12    pub severity: Severity,
13    pub title: String,
14    pub detail: String,
15    /// Optional file:line locator.
16    pub location: Option<Location>,
17    /// Supporting evidence / citations.
18    #[serde(default)]
19    pub evidence: Vec<String>,
20    /// Free-form structured extension.
21    #[serde(default)]
22    pub data: serde_json::Value,
23}
24
25#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
26#[serde(rename_all = "lowercase")]
27pub enum Severity {
28    Info,
29    Low,
30    Medium,
31    High,
32    Critical,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct Location {
37    pub file: PathBuf,
38    pub line: Option<u32>,
39}
40
41#[cfg(test)]
42mod tests {
43    use super::*;
44    use serde_json::json;
45
46    fn sample_finding() -> Finding {
47        Finding {
48            kind: "missing_auth".into(),
49            severity: Severity::High,
50            title: "Endpoint lacks auth".into(),
51            detail: "POST /admin has no authentication middleware".into(),
52            location: Some(Location {
53                file: PathBuf::from("src/api.rs"),
54                line: Some(42),
55            }),
56            evidence: vec!["grep -n auth src/api.rs".into()],
57            data: json!({"cwe": "CWE-306"}),
58        }
59    }
60
61    #[test]
62    fn finding_serialize_roundtrip() {
63        let f = sample_finding();
64        let json = serde_json::to_string(&f).unwrap();
65        let back: Finding = serde_json::from_str(&json).unwrap();
66        assert_eq!(back.kind, f.kind);
67        assert_eq!(back.severity, f.severity);
68        assert_eq!(back.title, f.title);
69        assert_eq!(back.detail, f.detail);
70        assert_eq!(
71            serde_json::to_value(back.location.clone()).unwrap(),
72            serde_json::to_value(f.location.clone()).unwrap()
73        );
74        assert_eq!(back.evidence, f.evidence);
75        assert_eq!(back.data, f.data);
76    }
77
78    #[test]
79    fn finding_minimal_optional_fields_default() {
80        let json = json!({
81            "kind": "source",
82            "severity": "info",
83            "title": "note",
84            "detail": "free text"
85        });
86        let f: Finding = serde_json::from_value(json).unwrap();
87        assert_eq!(f.kind, "source");
88        assert_eq!(f.severity, Severity::Info);
89        assert!(f.location.is_none());
90        assert!(f.evidence.is_empty());
91        assert_eq!(f.data, serde_json::Value::Null);
92    }
93
94    #[test]
95    fn severity_serializes_as_lowercase() {
96        for (s, expected) in [
97            (Severity::Info, "\"info\""),
98            (Severity::Low, "\"low\""),
99            (Severity::Medium, "\"medium\""),
100            (Severity::High, "\"high\""),
101            (Severity::Critical, "\"critical\""),
102        ] {
103            assert_eq!(serde_json::to_string(&s).unwrap(), expected);
104        }
105    }
106
107    #[test]
108    fn severity_deserializes_from_lowercase() {
109        for (raw, expected) in [
110            ("\"info\"", Severity::Info),
111            ("\"low\"", Severity::Low),
112            ("\"medium\"", Severity::Medium),
113            ("\"high\"", Severity::High),
114            ("\"critical\"", Severity::Critical),
115        ] {
116            let s: Severity = serde_json::from_str(raw).unwrap();
117            assert_eq!(s, expected);
118        }
119    }
120
121    #[test]
122    fn severity_ordering_is_strict() {
123        assert!(Severity::Info < Severity::Low);
124        assert!(Severity::Low < Severity::Medium);
125        assert!(Severity::Medium < Severity::High);
126        assert!(Severity::High < Severity::Critical);
127        // Ord consistency: sorted vec round-trips.
128        let mut v = vec![
129            Severity::Critical,
130            Severity::Info,
131            Severity::High,
132            Severity::Low,
133            Severity::Medium,
134        ];
135        v.sort();
136        assert_eq!(
137            v,
138            vec![
139                Severity::Info,
140                Severity::Low,
141                Severity::Medium,
142                Severity::High,
143                Severity::Critical
144            ]
145        );
146    }
147
148    #[test]
149    fn location_with_line_roundtrip() {
150        let loc = Location {
151            file: PathBuf::from("/tmp/foo/bar.rs"),
152            line: Some(128),
153        };
154        let json = serde_json::to_string(&loc).unwrap();
155        let back: Location = serde_json::from_str(&json).unwrap();
156        assert_eq!(
157            serde_json::to_value(&back).unwrap(),
158            serde_json::to_value(&loc).unwrap()
159        );
160    }
161
162    #[test]
163    fn location_without_line_roundtrip() {
164        let loc = Location {
165            file: PathBuf::from("README.md"),
166            line: None,
167        };
168        let json = serde_json::to_string(&loc).unwrap();
169        let back: Location = serde_json::from_str(&json).unwrap();
170        assert_eq!(
171            serde_json::to_value(&back).unwrap(),
172            serde_json::to_value(&loc).unwrap()
173        );
174        assert!(back.line.is_none());
175    }
176
177    #[test]
178    fn finding_clone_preserves_all_fields() {
179        let f = sample_finding();
180        let cloned = f.clone();
181        assert_eq!(cloned.kind, f.kind);
182        assert_eq!(cloned.severity, f.severity);
183        assert_eq!(cloned.title, f.title);
184        assert_eq!(cloned.detail, f.detail);
185        assert_eq!(
186            serde_json::to_value(cloned.location.clone()).unwrap(),
187            serde_json::to_value(f.location.clone()).unwrap()
188        );
189        assert_eq!(cloned.evidence, f.evidence);
190        assert_eq!(cloned.data, f.data);
191    }
192
193    #[test]
194    fn finding_debug_includes_kind_and_severity() {
195        let f = sample_finding();
196        let dbg = format!("{:?}", f);
197        assert!(dbg.contains("missing_auth"));
198        assert!(dbg.contains("High"));
199    }
200
201    #[test]
202    fn finding_with_empty_evidence_and_null_data_roundtrip() {
203        let f = Finding {
204            kind: "x".into(),
205            severity: Severity::Low,
206            title: "t".into(),
207            detail: "d".into(),
208            location: None,
209            evidence: vec![],
210            data: serde_json::Value::Null,
211        };
212        let json = serde_json::to_string(&f).unwrap();
213        // Optional fields with serde(default) emit `"evidence":[]` and `"data":null`
214        assert!(json.contains("\"evidence\":[]"));
215        let back: Finding = serde_json::from_str(&json).unwrap();
216        assert_eq!(
217            serde_json::to_value(&back).unwrap(),
218            serde_json::to_value(&f).unwrap()
219        );
220    }
221}