Skip to main content

cargo_context_core/
impact.rs

1//! `cargo-impact` envelope parsing and finding filters.
2//!
3//! `cargo-impact --format=json` emits a stable JSON envelope whose
4//! `findings[]` entries describe analyzer hits — each with a primary source
5//! path, a content-hashed id, a kind, and a confidence score. This module
6//! parses that envelope into a typed [`Finding`] list, tolerating schema
7//! drift by pulling known fields with `Option` semantics.
8//!
9//! The full schema is tracked upstream at
10//! <https://github.com/asmuelle/cargo-impact>; we only depend on the
11//! subset that drives the Scoped Files section.
12
13use std::path::PathBuf;
14
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18/// One finding in a `cargo-impact` envelope.
19///
20/// Every field except `primary_path` is optional. Upstream schema additions
21/// are ignored; removed fields decay to `None` without breaking the parse.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct Finding {
24    /// Content-hashed id (e.g. `f-abcd1234...`). Stable across runs for the
25    /// same finding — consumers can use it to dedupe or exclude.
26    pub id: Option<String>,
27    /// Repo-relative path to the primary source file for this finding.
28    pub primary_path: PathBuf,
29    /// Discriminant name (e.g. `trait_impl`, `doc_drift_link`). When
30    /// upstream serializes `kind` as an internally-tagged object, we keep
31    /// the tag name; when it's a bare string, we keep the string.
32    pub kind: Option<String>,
33    /// Confidence in `[0.0, 1.0]`. `None` when upstream omits it — treat
34    /// that as "unknown", not as zero.
35    pub confidence: Option<f64>,
36    /// `"high"` / `"medium"` / `"low"` / `"unknown"`.
37    pub severity: Option<String>,
38    /// `"proven"` / `"likely"` / `"possible"` / `"unknown"`.
39    pub tier: Option<String>,
40    /// Human-readable justification. Free-form UTF-8.
41    pub evidence: Option<String>,
42    /// Optional shell command hint (e.g. `cargo nextest run -E ...`).
43    pub suggested_action: Option<String>,
44}
45
46impl Finding {
47    /// Language hint for the fenced block that renders this finding's
48    /// primary file.
49    ///
50    /// Kind-aware overrides win over extension-based detection — a
51    /// `doc_drift_link` finding always renders as markdown even when the
52    /// file has no extension. For every other kind we fall back to the
53    /// shared extension map so `.rs` → `rust`, `.toml` → `toml`, etc.
54    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    /// Short descriptor for section headers: `kind` + severity/tier +
62    /// confidence, whichever pieces are present.
63    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
81/// Parse a `cargo-impact --format=json` envelope into a list of findings.
82///
83/// Path discovery is forgiving: each finding supplies its path via one of
84/// `primary_path`, `impact_surface.primary_path`, any nested `primary_path`,
85/// or `path` — whichever lands first wins. Findings without a discoverable
86/// path are silently skipped.
87///
88/// Other fields (`id`, `kind`, `confidence`, `severity`, `tier`, `evidence`,
89/// `suggested_action`) are pulled when present. Unknown top-level or
90/// per-finding fields are ignored, so an upstream schema bump doesn't
91/// brick downstream parsing.
92pub 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
121/// `kind` may serialize as either a bare string (`"trait_impl"`) or an
122/// internally-tagged object (`{"trait_impl": { ... }}`). Extract the tag
123/// name in both shapes; anything else (null/number/array) yields `None`.
124fn 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
178/// Filter-and-sort pipeline for a list of findings.
179///
180/// 1. Drop any finding whose `id` matches an entry in `exclude_ids`.
181/// 2. Drop any finding whose `confidence` is present and below
182///    `min_confidence`. Findings with no confidence are kept — we don't
183///    know enough to drop them.
184/// 3. Sort remaining findings by confidence descending (unknown
185///    confidence sorts last), ties broken by primary path for
186///    determinism.
187pub 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
221/// Collapse findings into a deduped list of paths while preserving the
222/// filter-and-sort order. When several findings share a `primary_path`,
223/// the first occurrence (highest-confidence) wins.
224pub 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        // kind-as-object → first key becomes the discriminant name.
260        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        // 0.9 pair sorts by path (a before b), then 0.5, then None last.
378        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        // Non-doc kind falls through to extension map.
401        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}