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}