1use std::path::PathBuf;
14
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct Finding {
24 pub id: Option<String>,
27 pub primary_path: PathBuf,
29 pub kind: Option<String>,
33 pub confidence: Option<f64>,
36 pub severity: Option<String>,
38 pub tier: Option<String>,
40 pub evidence: Option<String>,
42 pub suggested_action: Option<String>,
44}
45
46impl Finding {
47 pub fn language_hint(&self) -> &'static str {
55 match self.kind.as_deref() {
56 Some("doc_drift_link") | Some("doc_drift_keyword") => "markdown",
57 _ => crate::pack::lang_for_path(&self.primary_path),
58 }
59 }
60
61 pub fn descriptor(&self) -> String {
64 let mut parts: Vec<String> = Vec::new();
65 if let Some(k) = &self.kind {
66 parts.push(k.clone());
67 }
68 match (self.severity.as_deref(), self.tier.as_deref()) {
69 (Some(s), Some(t)) => parts.push(format!("{s}/{t}")),
70 (Some(s), None) => parts.push(s.into()),
71 (None, Some(t)) => parts.push(t.into()),
72 (None, None) => {}
73 }
74 if let Some(c) = self.confidence {
75 parts.push(format!("conf={c:.2}"));
76 }
77 parts.join(", ")
78 }
79}
80
81pub fn parse_envelope(raw: &str) -> serde_json::Result<Vec<Finding>> {
93 let envelope: Value = serde_json::from_str(raw)?;
94 let findings = match envelope.get("findings").and_then(|v| v.as_array()) {
95 Some(a) => a,
96 None => return Ok(Vec::new()),
97 };
98
99 let mut out = Vec::with_capacity(findings.len());
100 for f in findings {
101 let Some(path) = pluck_primary_path(f) else {
102 continue;
103 };
104 out.push(Finding {
105 id: f.get("id").and_then(|v| v.as_str()).map(String::from),
106 primary_path: PathBuf::from(path),
107 kind: pluck_kind(f),
108 confidence: f.get("confidence").and_then(|v| v.as_f64()),
109 severity: f.get("severity").and_then(|v| v.as_str()).map(String::from),
110 tier: f.get("tier").and_then(|v| v.as_str()).map(String::from),
111 evidence: f.get("evidence").and_then(|v| v.as_str()).map(String::from),
112 suggested_action: f
113 .get("suggested_action")
114 .and_then(|v| v.as_str())
115 .map(String::from),
116 });
117 }
118 Ok(out)
119}
120
121fn pluck_kind(f: &Value) -> Option<String> {
125 match f.get("kind")? {
126 Value::String(s) => Some(s.clone()),
127 Value::Object(obj) => obj.keys().next().cloned(),
128 _ => None,
129 }
130}
131
132fn pluck_primary_path(finding: &Value) -> Option<String> {
133 if let Some(s) = finding.get("primary_path").and_then(|v| v.as_str()) {
134 return Some(s.to_string());
135 }
136 if let Some(s) = finding
137 .pointer("/impact_surface/primary_path")
138 .and_then(|v| v.as_str())
139 {
140 return Some(s.to_string());
141 }
142 if let Some(found) = walk_for_key(finding, "primary_path") {
143 return Some(found);
144 }
145 if let Some(s) = finding.get("path").and_then(|v| v.as_str()) {
146 return Some(s.to_string());
147 }
148 None
149}
150
151fn walk_for_key(v: &Value, key: &str) -> Option<String> {
152 match v {
153 Value::Object(map) => {
154 if let Some(val) = map.get(key)
155 && let Some(s) = val.as_str()
156 {
157 return Some(s.to_string());
158 }
159 for child in map.values() {
160 if let Some(found) = walk_for_key(child, key) {
161 return Some(found);
162 }
163 }
164 None
165 }
166 Value::Array(items) => {
167 for item in items {
168 if let Some(found) = walk_for_key(item, key) {
169 return Some(found);
170 }
171 }
172 None
173 }
174 _ => None,
175 }
176}
177
178pub fn filter_and_sort(
188 mut findings: Vec<Finding>,
189 min_confidence: Option<f64>,
190 exclude_ids: &[String],
191) -> Vec<Finding> {
192 use std::collections::HashSet;
193 let excluded: HashSet<&str> = exclude_ids.iter().map(String::as_str).collect();
194
195 findings.retain(|f| {
196 if let Some(id) = &f.id
197 && excluded.contains(id.as_str())
198 {
199 return false;
200 }
201 if let Some(min) = min_confidence
202 && let Some(c) = f.confidence
203 && c < min
204 {
205 return false;
206 }
207 true
208 });
209
210 findings.sort_by(|a, b| {
211 let ac = a.confidence.unwrap_or(f64::NEG_INFINITY);
212 let bc = b.confidence.unwrap_or(f64::NEG_INFINITY);
213 bc.partial_cmp(&ac)
214 .unwrap_or(std::cmp::Ordering::Equal)
215 .then_with(|| a.primary_path.cmp(&b.primary_path))
216 });
217
218 findings
219}
220
221pub fn unique_paths(findings: &[Finding]) -> Vec<PathBuf> {
225 let mut seen = std::collections::HashSet::new();
226 let mut out = Vec::with_capacity(findings.len());
227 for f in findings {
228 if seen.insert(f.primary_path.clone()) {
229 out.push(f.primary_path.clone());
230 }
231 }
232 out
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238
239 #[test]
240 fn parses_top_level_primary_path() {
241 let raw = r#"{"findings":[{"primary_path":"src/foo.rs"}]}"#;
242 let fs = parse_envelope(raw).unwrap();
243 assert_eq!(fs.len(), 1);
244 assert_eq!(fs[0].primary_path, PathBuf::from("src/foo.rs"));
245 }
246
247 #[test]
248 fn parses_impact_surface_primary_path() {
249 let raw = r#"{"findings":[{"impact_surface":{"primary_path":"src/bar.rs"}}]}"#;
250 let fs = parse_envelope(raw).unwrap();
251 assert_eq!(fs[0].primary_path, PathBuf::from("src/bar.rs"));
252 }
253
254 #[test]
255 fn parses_kind_payload_primary_path() {
256 let raw = r#"{"findings":[{"kind":{"unsafe":{"primary_path":"src/ffi.rs"}}}]}"#;
257 let fs = parse_envelope(raw).unwrap();
258 assert_eq!(fs[0].primary_path, PathBuf::from("src/ffi.rs"));
259 assert_eq!(fs[0].kind.as_deref(), Some("unsafe"));
261 }
262
263 #[test]
264 fn parses_kind_as_string() {
265 let raw = r#"{"findings":[{"primary_path":"a.rs","kind":"trait_impl"}]}"#;
266 let fs = parse_envelope(raw).unwrap();
267 assert_eq!(fs[0].kind.as_deref(), Some("trait_impl"));
268 }
269
270 #[test]
271 fn parses_full_metadata() {
272 let raw = r#"{"findings":[{
273 "id":"f-abcd1234",
274 "primary_path":"src/foo.rs",
275 "kind":"trait_impl",
276 "confidence":0.85,
277 "severity":"high",
278 "tier":"likely",
279 "evidence":"Trait impl affects 3 downstream callers",
280 "suggested_action":"cargo nextest run -E 'test(foo)'"
281 }]}"#;
282 let fs = parse_envelope(raw).unwrap();
283 let f = &fs[0];
284 assert_eq!(f.id.as_deref(), Some("f-abcd1234"));
285 assert_eq!(f.kind.as_deref(), Some("trait_impl"));
286 assert_eq!(f.confidence, Some(0.85));
287 assert_eq!(f.severity.as_deref(), Some("high"));
288 assert_eq!(f.tier.as_deref(), Some("likely"));
289 assert!(
290 f.evidence
291 .as_deref()
292 .unwrap()
293 .starts_with("Trait impl affects")
294 );
295 assert!(f.suggested_action.as_deref().unwrap().contains("nextest"));
296 }
297
298 #[test]
299 fn skips_findings_without_path_silently() {
300 let raw = r#"{"findings":[
301 {"primary_path":"keep.rs"},
302 {"kind":"some_other_thing","tier":"low"},
303 {"primary_path":"also_keep.rs"}
304 ]}"#;
305 let fs = parse_envelope(raw).unwrap();
306 assert_eq!(fs.len(), 2);
307 assert_eq!(fs[0].primary_path, PathBuf::from("keep.rs"));
308 assert_eq!(fs[1].primary_path, PathBuf::from("also_keep.rs"));
309 }
310
311 #[test]
312 fn empty_envelope_returns_empty() {
313 assert!(parse_envelope(r#"{}"#).unwrap().is_empty());
314 assert!(parse_envelope(r#"{"findings":[]}"#).unwrap().is_empty());
315 }
316
317 #[test]
318 fn malformed_json_errors() {
319 assert!(parse_envelope("{ not json").is_err());
320 }
321
322 #[test]
323 fn ignores_unknown_top_level_and_per_finding_fields() {
324 let raw = r#"{
325 "version":"0.3.0",
326 "summary":{"total":1},
327 "future_field":{"nested":true},
328 "findings":[{
329 "primary_path":"a.rs",
330 "new_field_in_v0_4":"ignored"
331 }]
332 }"#;
333 let fs = parse_envelope(raw).unwrap();
334 assert_eq!(fs.len(), 1);
335 assert_eq!(fs[0].primary_path, PathBuf::from("a.rs"));
336 }
337
338 #[test]
339 fn filter_drops_below_min_confidence_keeps_unknown() {
340 let findings = vec![
341 mk_finding("a.rs", Some("f1"), Some(0.95)),
342 mk_finding("b.rs", Some("f2"), Some(0.40)),
343 mk_finding("c.rs", Some("f3"), None),
344 ];
345 let out = filter_and_sort(findings, Some(0.8), &[]);
346 let ids: Vec<_> = out.iter().map(|f| f.id.clone().unwrap()).collect();
347 assert!(ids.contains(&"f1".to_string()));
348 assert!(!ids.contains(&"f2".to_string()));
349 assert!(
350 ids.contains(&"f3".to_string()),
351 "unknown-confidence finding should survive: {ids:?}"
352 );
353 }
354
355 #[test]
356 fn filter_drops_excluded_ids() {
357 let findings = vec![
358 mk_finding("a.rs", Some("f1"), Some(0.95)),
359 mk_finding("b.rs", Some("f2"), Some(0.90)),
360 mk_finding("c.rs", Some("f3"), Some(0.90)),
361 ];
362 let out = filter_and_sort(findings, None, &["f2".into(), "f3".into()]);
363 assert_eq!(out.len(), 1);
364 assert_eq!(out[0].id.as_deref(), Some("f1"));
365 }
366
367 #[test]
368 fn sort_by_confidence_desc_with_stable_tiebreak() {
369 let findings = vec![
370 mk_finding("c.rs", Some("c"), Some(0.5)),
371 mk_finding("a.rs", Some("a"), Some(0.9)),
372 mk_finding("b.rs", Some("b"), Some(0.9)),
373 mk_finding("d.rs", Some("d"), None),
374 ];
375 let out = filter_and_sort(findings, None, &[]);
376 let ids: Vec<_> = out.iter().map(|f| f.id.clone().unwrap()).collect();
377 assert_eq!(ids, vec!["a", "b", "c", "d"]);
379 }
380
381 #[test]
382 fn unique_paths_dedupes_preserving_order() {
383 let findings = vec![
384 mk_finding("a.rs", Some("f1"), Some(0.9)),
385 mk_finding("b.rs", Some("f2"), Some(0.8)),
386 mk_finding("a.rs", Some("f3"), Some(0.7)),
387 ];
388 let paths = unique_paths(&findings);
389 assert_eq!(paths, vec![PathBuf::from("a.rs"), PathBuf::from("b.rs")]);
390 }
391
392 #[test]
393 fn language_hint_kind_aware_overrides_extension() {
394 let f = mk_kind("README", "doc_drift_link");
395 assert_eq!(f.language_hint(), "markdown");
396
397 let f = mk_kind("notes", "doc_drift_keyword");
398 assert_eq!(f.language_hint(), "markdown");
399
400 let f = mk_kind("src/foo.rs", "trait_impl");
402 assert_eq!(f.language_hint(), "rust");
403 }
404
405 #[test]
406 fn descriptor_combines_metadata() {
407 let mut f = mk_finding("a.rs", Some("f1"), Some(0.85));
408 f.kind = Some("trait_impl".into());
409 f.severity = Some("high".into());
410 f.tier = Some("likely".into());
411 assert_eq!(f.descriptor(), "trait_impl, high/likely, conf=0.85");
412 }
413
414 fn mk_finding(path: &str, id: Option<&str>, confidence: Option<f64>) -> Finding {
415 Finding {
416 id: id.map(String::from),
417 primary_path: PathBuf::from(path),
418 kind: None,
419 confidence,
420 severity: None,
421 tier: None,
422 evidence: None,
423 suggested_action: None,
424 }
425 }
426
427 fn mk_kind(path: &str, kind: &str) -> Finding {
428 Finding {
429 id: None,
430 primary_path: PathBuf::from(path),
431 kind: Some(kind.into()),
432 confidence: None,
433 severity: None,
434 tier: None,
435 evidence: None,
436 suggested_action: None,
437 }
438 }
439}