Skip to main content

cargo_impact/
finding.rs

1//! Core finding types.
2//!
3//! A [`Finding`] is the unit of output: one thing the developer should verify.
4//! Each one carries a confidence tier ([`Tier`]), a numeric score, a severity
5//! class ([`SeverityClass`]), and a `kind`-specific payload explaining why it
6//! was flagged. Serialized identically whether emitted as JSON or rendered
7//! into the markdown/text reports.
8
9use serde::Serialize;
10use std::collections::hash_map::DefaultHasher;
11use std::hash::{Hash, Hasher};
12use std::path::{Path, PathBuf};
13
14/// Confidence tier per README ยง3F.
15///
16/// v0.2 ships without resolved call-graph analysis (rust-analyzer integration
17/// arrives in v0.3), so no finding reaches `Proven` in this release โ€” syn-only
18/// analysis is honestly at most `Likely`.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
20#[serde(rename_all = "lowercase")]
21pub enum Tier {
22    Proven,
23    Likely,
24    Possible,
25    Unknown,
26}
27
28impl Tier {
29    /// Rank for filtering (`--confidence-min` clamps by score; this is used
30    /// only for stable ordering).
31    pub fn rank(self) -> u8 {
32        match self {
33            Self::Proven => 3,
34            Self::Likely => 2,
35            Self::Possible => 1,
36            Self::Unknown => 0,
37        }
38    }
39}
40
41/// Severity bucket used for `--fail-on` and human-facing grouping.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
43#[serde(rename_all = "lowercase")]
44pub enum SeverityClass {
45    High,
46    Medium,
47    Low,
48    Unknown,
49}
50
51impl SeverityClass {
52    pub fn as_label(self) -> &'static str {
53        match self {
54            Self::High => "HIGH",
55            Self::Medium => "MEDIUM",
56            Self::Low => "LOW",
57            Self::Unknown => "UNKNOWN",
58        }
59    }
60
61    /// Emoji column used in text/markdown. Matches README ยง4.
62    pub fn icon(self) -> &'static str {
63        match self {
64            Self::High => "๐Ÿ”ด",
65            Self::Medium => "๐ŸŸก",
66            Self::Low => "๐Ÿ”ต",
67            Self::Unknown => "โšช",
68        }
69    }
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
73pub struct Location {
74    pub file: PathBuf,
75    pub symbol: String,
76}
77
78/// Reason a specific finding was emitted. Variants carry the analysis-kind
79/// payload; cross-cutting fields (tier, confidence, severity) live on
80/// [`Finding`].
81#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
82#[serde(tag = "kind", rename_all = "snake_case")]
83pub enum FindingKind {
84    /// A test function whose body syntactically references a changed symbol.
85    TestReference {
86        test: Location,
87        matched_symbols: Vec<String>,
88    },
89    /// An `impl TraitName for T` block in the workspace where `TraitName`
90    /// was defined in a changed file.
91    TraitImpl {
92        trait_name: String,
93        impl_for: String,
94        impl_site: Location,
95    },
96    /// A `#[derive(TraitName)]` attribute on a struct, enum, or union where
97    /// `TraitName` was defined in a changed file. Treated as an implicit
98    /// impl site โ€” the derive will expand to one at compile time โ€” but
99    /// distinguished from `TraitImpl` so consumers can filter.
100    DerivedTraitImpl {
101        trait_name: String,
102        impl_for: String,
103        derive_site: Location,
104    },
105    /// A `dyn TraitName` type reference for a trait whose definition changed.
106    DynDispatch { trait_name: String, site: Location },
107    /// An intra-doc link like `[`Symbol`]` in a markdown file or `///` comment
108    /// referencing a changed symbol.
109    DocDriftLink {
110        symbol: String,
111        doc: Location,
112        line: u32,
113    },
114    /// A plain identifier match inside a doc comment or markdown file โ€” weaker
115    /// signal than an intra-doc link, emitted at `Possible` tier only.
116    DocDriftKeyword {
117        symbol: String,
118        doc: Location,
119        line: u32,
120    },
121    /// An `extern "C"` signature or `#[no_mangle]` function was added,
122    /// removed, or modified. Signatures cross the Rust/native boundary โ€”
123    /// downstream consumers outside Rust cannot be analyzed by us, so these
124    /// are always surfaced at `High` severity.
125    FfiSignatureChange {
126        symbol: String,
127        file: PathBuf,
128        /// `"added"`, `"removed"`, or `"modified"`.
129        change: &'static str,
130    },
131    /// A `build.rs` script file changed. Build scripts can invalidate
132    /// downstream compilation in non-obvious ways (env vars, rerun-if-*,
133    /// generated code, linker flags).
134    BuildScriptChanged { file: PathBuf },
135    /// Outcome of a `cargo-semver-checks` run. `level` is one of
136    /// `"breaking"` (the only currently-emitted value) or a finer-grained
137    /// classification in a future release. `details` carries the tool's
138    /// own output verbatim so consumers can surface it without a
139    /// re-invocation.
140    SemverCheck { level: String, details: String },
141    /// A name-resolved reference to a changed symbol, emitted by the
142    /// rust-analyzer LSP integration. These are the *only* findings that
143    /// legitimately reach the `Proven` tier in this release โ€” the syn-only
144    /// analyzers (TestReference, TraitImpl, DerivedTraitImpl, etc.) top out
145    /// at `Likely` because they can't prove name resolution without a
146    /// compiler front-end.
147    ResolvedReference {
148        source_symbol: String,
149        target: Location,
150    },
151    /// A runtime-surface handler (HTTP route, CLI subcommand, etc.)
152    /// implicated by a changed symbol. Emitted by framework-specific
153    /// adapters (axum, clap โ€” see `src/adapters.rs`). `framework`
154    /// names the adapter that produced it; `identifier` is the
155    /// framework-specific surface identity (route path, subcommand
156    /// name); `site` points at the Rust source defining the handler.
157    RuntimeSurface {
158        framework: String,
159        identifier: String,
160        site: Location,
161    },
162    /// A specific, per-method change inside a trait definition. Complements
163    /// `TraitImpl` (which flags every impl of a changed trait at blanket
164    /// precision) by explaining *what* about the trait changed โ€” required
165    /// vs default method, added/removed, signature vs body. Severity and
166    /// confidence derive from `change` per README ยง3B.
167    TraitDefinitionChange {
168        trait_name: String,
169        file: PathBuf,
170        /// Specific method name when the change is method-scoped; `None`
171        /// for trait-level changes (supertraits, generic bounds).
172        method: Option<String>,
173        /// Machine-readable classification; renderers map this to evidence
174        /// text and severity.
175        change: TraitChange,
176    },
177}
178
179/// Per-method or trait-level change classification. One-to-one with the
180/// bullets in README ยง3B. Confidence floor for anything requiring
181/// resolution (actual impl bodies) stays at `Likely` in v0.2 โ€” we cannot
182/// prove which impls delegate vs override without rust-analyzer.
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
184#[serde(rename_all = "snake_case")]
185pub enum TraitChange {
186    /// A new method was added *without* a default body. Every impl that
187    /// does not supply it will fail to compile.
188    RequiredMethodAdded,
189    /// A new method was added *with* a default body. Rarely breaking, but
190    /// can shadow same-named methods on implementing types.
191    DefaultMethodAdded,
192    /// A method was removed. Breaks any caller that referenced it and any
193    /// impl that still tries to define it.
194    MethodRemoved,
195    /// A required-method signature (args, return type, generics, where
196    /// clause) changed. Impls with the old signature break at compile time.
197    RequiredMethodSignatureChanged,
198    /// Only the body of a default method changed. Runtime behavior shifts
199    /// for impls that rely on the default; impls that override are
200    /// unaffected. We cannot tell which is which without name resolution.
201    DefaultMethodBodyChanged,
202    /// The trait's supertrait list or generic bounds changed. Downstream
203    /// generic code constrained by the trait may stop compiling.
204    SupertraitOrBoundChanged,
205}
206
207impl TraitChange {
208    /// Severity class per README ยง3B. Required-side changes and removals
209    /// are compile breaks on downstream impls; default-body changes are
210    /// runtime-only and narrower; bound changes sit in the middle.
211    pub fn severity(self) -> SeverityClass {
212        match self {
213            Self::RequiredMethodAdded
214            | Self::RequiredMethodSignatureChanged
215            | Self::MethodRemoved => SeverityClass::High,
216            Self::SupertraitOrBoundChanged => SeverityClass::Medium,
217            Self::DefaultMethodAdded | Self::DefaultMethodBodyChanged => SeverityClass::Low,
218        }
219    }
220
221    /// Confidence tier. All classifications stay at `Likely` or `Possible`
222    /// in v0.2 โ€” proving which impls actually delegate vs override needs
223    /// resolved name lookup, which arrives with rust-analyzer in v0.3.
224    pub fn tier(self) -> Tier {
225        match self {
226            Self::RequiredMethodAdded
227            | Self::RequiredMethodSignatureChanged
228            | Self::MethodRemoved
229            | Self::SupertraitOrBoundChanged => Tier::Likely,
230            Self::DefaultMethodAdded | Self::DefaultMethodBodyChanged => Tier::Possible,
231        }
232    }
233
234    /// Numeric confidence score. Higher for changes that unambiguously
235    /// break downstream compilation; lower for runtime-only or
236    /// defaulted-only changes where impact depends on resolution.
237    pub fn confidence(self) -> f64 {
238        match self {
239            Self::RequiredMethodAdded | Self::RequiredMethodSignatureChanged => 0.95,
240            Self::MethodRemoved => 0.90,
241            Self::SupertraitOrBoundChanged => 0.75,
242            Self::DefaultMethodBodyChanged => 0.55,
243            Self::DefaultMethodAdded => 0.40,
244        }
245    }
246
247    /// Short human phrase for evidence/summary rendering. Callers are
248    /// expected to prepend the trait and method names.
249    pub fn phrase(self) -> &'static str {
250        match self {
251            Self::RequiredMethodAdded => "required method added",
252            Self::DefaultMethodAdded => "default method added",
253            Self::MethodRemoved => "method removed",
254            Self::RequiredMethodSignatureChanged => "required method signature changed",
255            Self::DefaultMethodBodyChanged => "default method body changed",
256            Self::SupertraitOrBoundChanged => "supertraits or generic bounds changed",
257        }
258    }
259}
260
261impl FindingKind {
262    /// Default severity for this kind โ€” callers can override but rarely need to.
263    pub fn default_severity(&self) -> SeverityClass {
264        match self {
265            Self::TraitImpl { .. }
266            | Self::DerivedTraitImpl { .. }
267            | Self::FfiSignatureChange { .. }
268            | Self::BuildScriptChanged { .. }
269            | Self::RuntimeSurface { .. } => SeverityClass::High,
270            Self::TestReference { .. }
271            | Self::DynDispatch { .. }
272            | Self::ResolvedReference { .. } => SeverityClass::Medium,
273            Self::DocDriftLink { .. } | Self::DocDriftKeyword { .. } => SeverityClass::Low,
274            Self::SemverCheck { level, .. } => match level.as_str() {
275                "breaking" => SeverityClass::High,
276                "minor" | "patch" => SeverityClass::Medium,
277                _ => SeverityClass::Unknown,
278            },
279            Self::TraitDefinitionChange { change, .. } => change.severity(),
280        }
281    }
282
283    /// The primary file path this finding is about, for ignore-filtering
284    /// and UI "go to file" affordances. Returns `None` for global findings
285    /// that don't name a specific path (e.g. `SemverCheck`, which reports
286    /// on the whole public API surface).
287    pub fn primary_path(&self) -> Option<&Path> {
288        match self {
289            Self::TestReference { test, .. } => Some(test.file.as_path()),
290            Self::TraitImpl { impl_site, .. } => Some(impl_site.file.as_path()),
291            Self::DerivedTraitImpl { derive_site, .. } => Some(derive_site.file.as_path()),
292            Self::DynDispatch { site, .. } => Some(site.file.as_path()),
293            Self::DocDriftLink { doc, .. } => Some(doc.file.as_path()),
294            Self::DocDriftKeyword { doc, .. } => Some(doc.file.as_path()),
295            Self::FfiSignatureChange { file, .. } => Some(file.as_path()),
296            Self::BuildScriptChanged { file, .. } => Some(file.as_path()),
297            Self::ResolvedReference { target, .. } => Some(target.file.as_path()),
298            Self::TraitDefinitionChange { file, .. } => Some(file.as_path()),
299            Self::RuntimeSurface { site, .. } => Some(site.file.as_path()),
300            Self::SemverCheck { .. } => None,
301        }
302    }
303
304    /// Every possible value [`Self::tag`] can return โ€” useful for schema
305    /// generators (SARIF rules list, MCP tool descriptions) that need
306    /// to enumerate kinds without having a runtime instance.
307    pub fn all_tags() -> &'static [&'static str] {
308        &[
309            "test_reference",
310            "trait_impl",
311            "derived_trait_impl",
312            "dyn_dispatch",
313            "doc_drift_link",
314            "doc_drift_keyword",
315            "ffi_signature_change",
316            "build_script_changed",
317            "semver_check",
318            "trait_definition_change",
319            "resolved_reference",
320            "runtime_surface",
321        ]
322    }
323
324    /// Tag used for sorting/grouping and the JSON `kind` field's value.
325    pub fn tag(&self) -> &'static str {
326        match self {
327            Self::TestReference { .. } => "test_reference",
328            Self::TraitImpl { .. } => "trait_impl",
329            Self::DerivedTraitImpl { .. } => "derived_trait_impl",
330            Self::DynDispatch { .. } => "dyn_dispatch",
331            Self::DocDriftLink { .. } => "doc_drift_link",
332            Self::DocDriftKeyword { .. } => "doc_drift_keyword",
333            Self::FfiSignatureChange { .. } => "ffi_signature_change",
334            Self::BuildScriptChanged { .. } => "build_script_changed",
335            Self::SemverCheck { .. } => "semver_check",
336            Self::TraitDefinitionChange { .. } => "trait_definition_change",
337            Self::ResolvedReference { .. } => "resolved_reference",
338            Self::RuntimeSurface { .. } => "runtime_surface",
339        }
340    }
341}
342
343/// Single unit of analysis output.
344///
345/// Construct via [`Finding::new`] so the severity/tier/confidence invariants
346/// are enforced (confidence clamped to [0, 1]; severity default derived from
347/// the kind unless overridden).
348#[derive(Debug, Clone, PartialEq, Serialize)]
349pub struct Finding {
350    pub id: String,
351    pub severity: SeverityClass,
352    pub tier: Tier,
353    pub confidence: f64,
354    #[serde(flatten)]
355    pub kind: FindingKind,
356    pub evidence: String,
357    #[serde(skip_serializing_if = "Option::is_none")]
358    pub suggested_action: Option<String>,
359}
360
361impl Eq for Finding {}
362
363impl Finding {
364    pub fn new(
365        id: impl Into<String>,
366        tier: Tier,
367        confidence: f64,
368        kind: FindingKind,
369        evidence: impl Into<String>,
370    ) -> Self {
371        let severity = kind.default_severity();
372        Self {
373            id: id.into(),
374            severity,
375            tier,
376            confidence: confidence.clamp(0.0, 1.0),
377            kind,
378            evidence: evidence.into(),
379            suggested_action: None,
380        }
381    }
382
383    /// Stable, deterministic ID derived from the finding's content. Same
384    /// finding across two runs produces the same ID โ€” this is what lets
385    /// `impact_explain` round-trip. Call after the finding's final
386    /// kind/evidence are set but before the ID is assigned.
387    ///
388    /// Hash inputs: kind tag + evidence + the kind payload (formatted via
389    /// `{:?}` on the serde-derived Debug). `DefaultHasher` is non-
390    /// cryptographic but that's fine โ€” we're deduping, not proving
391    /// non-existence.
392    pub fn content_id(&self) -> String {
393        let mut hasher = DefaultHasher::new();
394        self.kind.tag().hash(&mut hasher);
395        self.evidence.hash(&mut hasher);
396        // serde_json serialization is stable across runs for our data and
397        // captures kind-specific fields (trait_name, file, etc.) without
398        // needing per-variant hand-plumbing.
399        if let Ok(payload) = serde_json::to_string(&self.kind) {
400            payload.hash(&mut hasher);
401        }
402        format!("f-{:016x}", hasher.finish())
403    }
404
405    pub fn with_severity(mut self, severity: SeverityClass) -> Self {
406        self.severity = severity;
407        self
408    }
409
410    pub fn with_suggested_action(mut self, action: impl Into<String>) -> Self {
411        self.suggested_action = Some(action.into());
412        self
413    }
414
415    /// Delegates to [`FindingKind::primary_path`]. Convenience shortcut
416    /// so callers don't have to reach through `.kind` for a near-ubiquitous
417    /// operation.
418    pub fn primary_path(&self) -> Option<&Path> {
419        self.kind.primary_path()
420    }
421}
422
423/// Counts by tier โ€” exposed in the JSON envelope and the text footer.
424#[derive(Debug, Clone, Default, Serialize)]
425pub struct TierSummary {
426    pub proven: u32,
427    pub likely: u32,
428    pub possible: u32,
429    pub unknown: u32,
430}
431
432impl TierSummary {
433    pub fn from_findings(findings: &[Finding]) -> Self {
434        let mut s = Self::default();
435        for f in findings {
436            match f.tier {
437                Tier::Proven => s.proven += 1,
438                Tier::Likely => s.likely += 1,
439                Tier::Possible => s.possible += 1,
440                Tier::Unknown => s.unknown += 1,
441            }
442        }
443        s
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    fn sample_kind() -> FindingKind {
452        FindingKind::TestReference {
453            test: Location {
454                file: PathBuf::from("tests/t.rs"),
455                symbol: "smoke".into(),
456            },
457            matched_symbols: vec!["login".into()],
458        }
459    }
460
461    #[test]
462    fn confidence_clamped_to_unit_interval() {
463        let f = Finding::new("f-0001", Tier::Likely, 1.5, sample_kind(), "e");
464        assert_eq!(f.confidence, 1.0);
465        let f = Finding::new("f-0001", Tier::Likely, -0.5, sample_kind(), "e");
466        assert_eq!(f.confidence, 0.0);
467    }
468
469    #[test]
470    fn default_severity_by_kind() {
471        let f = Finding::new("x", Tier::Likely, 0.5, sample_kind(), "e");
472        assert_eq!(f.severity, SeverityClass::Medium);
473    }
474
475    #[test]
476    fn tier_summary_tallies_correctly() {
477        let mk = |tier: Tier, id: &str| Finding::new(id, tier, 0.5, sample_kind(), "e");
478        let findings = vec![
479            mk(Tier::Likely, "a"),
480            mk(Tier::Likely, "b"),
481            mk(Tier::Possible, "c"),
482            mk(Tier::Unknown, "d"),
483        ];
484        let s = TierSummary::from_findings(&findings);
485        assert_eq!(s.proven, 0);
486        assert_eq!(s.likely, 2);
487        assert_eq!(s.possible, 1);
488        assert_eq!(s.unknown, 1);
489    }
490
491    #[test]
492    fn json_shape_uses_kind_tag() {
493        let f = Finding::new("f-0001", Tier::Likely, 0.85, sample_kind(), "direct ref");
494        let v: serde_json::Value = serde_json::to_value(&f).unwrap();
495        assert_eq!(v["kind"], "test_reference");
496        assert_eq!(v["tier"], "likely");
497        assert_eq!(v["severity"], "medium");
498        assert_eq!(v["confidence"], 0.85);
499        assert!(v["test"].is_object());
500    }
501}