1use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct Finding {
10 pub kind: String,
12 pub severity: Severity,
13 pub title: String,
14 pub detail: String,
15 pub location: Option<Location>,
17 #[serde(default)]
19 pub evidence: Vec<String>,
20 #[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 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 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}