use serde::Serialize;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Tier {
Proven,
Likely,
Possible,
Unknown,
}
impl Tier {
pub fn rank(self) -> u8 {
match self {
Self::Proven => 3,
Self::Likely => 2,
Self::Possible => 1,
Self::Unknown => 0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum SeverityClass {
High,
Medium,
Low,
Unknown,
}
impl SeverityClass {
pub fn as_label(self) -> &'static str {
match self {
Self::High => "HIGH",
Self::Medium => "MEDIUM",
Self::Low => "LOW",
Self::Unknown => "UNKNOWN",
}
}
pub fn icon(self) -> &'static str {
match self {
Self::High => "🔴",
Self::Medium => "🟡",
Self::Low => "🔵",
Self::Unknown => "⚪",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Location {
pub file: PathBuf,
pub symbol: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum FindingKind {
TestReference {
test: Location,
matched_symbols: Vec<String>,
},
TraitImpl {
trait_name: String,
impl_for: String,
impl_site: Location,
},
DerivedTraitImpl {
trait_name: String,
impl_for: String,
derive_site: Location,
},
DynDispatch { trait_name: String, site: Location },
DocDriftLink {
symbol: String,
doc: Location,
line: u32,
},
DocDriftKeyword {
symbol: String,
doc: Location,
line: u32,
},
FfiSignatureChange {
symbol: String,
file: PathBuf,
change: &'static str,
},
BuildScriptChanged { file: PathBuf },
SemverCheck { level: String, details: String },
ResolvedReference {
source_symbol: String,
target: Location,
},
RuntimeSurface {
framework: String,
identifier: String,
site: Location,
},
TraitDefinitionChange {
trait_name: String,
file: PathBuf,
method: Option<String>,
change: TraitChange,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TraitChange {
RequiredMethodAdded,
DefaultMethodAdded,
MethodRemoved,
RequiredMethodSignatureChanged,
DefaultMethodBodyChanged,
SupertraitOrBoundChanged,
}
impl TraitChange {
pub fn severity(self) -> SeverityClass {
match self {
Self::RequiredMethodAdded
| Self::RequiredMethodSignatureChanged
| Self::MethodRemoved => SeverityClass::High,
Self::SupertraitOrBoundChanged => SeverityClass::Medium,
Self::DefaultMethodAdded | Self::DefaultMethodBodyChanged => SeverityClass::Low,
}
}
pub fn tier(self) -> Tier {
match self {
Self::RequiredMethodAdded
| Self::RequiredMethodSignatureChanged
| Self::MethodRemoved
| Self::SupertraitOrBoundChanged => Tier::Likely,
Self::DefaultMethodAdded | Self::DefaultMethodBodyChanged => Tier::Possible,
}
}
pub fn confidence(self) -> f64 {
match self {
Self::RequiredMethodAdded | Self::RequiredMethodSignatureChanged => 0.95,
Self::MethodRemoved => 0.90,
Self::SupertraitOrBoundChanged => 0.75,
Self::DefaultMethodBodyChanged => 0.55,
Self::DefaultMethodAdded => 0.40,
}
}
pub fn phrase(self) -> &'static str {
match self {
Self::RequiredMethodAdded => "required method added",
Self::DefaultMethodAdded => "default method added",
Self::MethodRemoved => "method removed",
Self::RequiredMethodSignatureChanged => "required method signature changed",
Self::DefaultMethodBodyChanged => "default method body changed",
Self::SupertraitOrBoundChanged => "supertraits or generic bounds changed",
}
}
}
impl FindingKind {
pub fn default_severity(&self) -> SeverityClass {
match self {
Self::TraitImpl { .. }
| Self::DerivedTraitImpl { .. }
| Self::FfiSignatureChange { .. }
| Self::BuildScriptChanged { .. }
| Self::RuntimeSurface { .. } => SeverityClass::High,
Self::TestReference { .. }
| Self::DynDispatch { .. }
| Self::ResolvedReference { .. } => SeverityClass::Medium,
Self::DocDriftLink { .. } | Self::DocDriftKeyword { .. } => SeverityClass::Low,
Self::SemverCheck { level, .. } => match level.as_str() {
"breaking" => SeverityClass::High,
"minor" | "patch" => SeverityClass::Medium,
_ => SeverityClass::Unknown,
},
Self::TraitDefinitionChange { change, .. } => change.severity(),
}
}
pub fn primary_path(&self) -> Option<&Path> {
match self {
Self::TestReference { test, .. } => Some(test.file.as_path()),
Self::TraitImpl { impl_site, .. } => Some(impl_site.file.as_path()),
Self::DerivedTraitImpl { derive_site, .. } => Some(derive_site.file.as_path()),
Self::DynDispatch { site, .. } => Some(site.file.as_path()),
Self::DocDriftLink { doc, .. } => Some(doc.file.as_path()),
Self::DocDriftKeyword { doc, .. } => Some(doc.file.as_path()),
Self::FfiSignatureChange { file, .. } => Some(file.as_path()),
Self::BuildScriptChanged { file, .. } => Some(file.as_path()),
Self::ResolvedReference { target, .. } => Some(target.file.as_path()),
Self::TraitDefinitionChange { file, .. } => Some(file.as_path()),
Self::RuntimeSurface { site, .. } => Some(site.file.as_path()),
Self::SemverCheck { .. } => None,
}
}
pub fn tag(&self) -> &'static str {
match self {
Self::TestReference { .. } => "test_reference",
Self::TraitImpl { .. } => "trait_impl",
Self::DerivedTraitImpl { .. } => "derived_trait_impl",
Self::DynDispatch { .. } => "dyn_dispatch",
Self::DocDriftLink { .. } => "doc_drift_link",
Self::DocDriftKeyword { .. } => "doc_drift_keyword",
Self::FfiSignatureChange { .. } => "ffi_signature_change",
Self::BuildScriptChanged { .. } => "build_script_changed",
Self::SemverCheck { .. } => "semver_check",
Self::TraitDefinitionChange { .. } => "trait_definition_change",
Self::ResolvedReference { .. } => "resolved_reference",
Self::RuntimeSurface { .. } => "runtime_surface",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Finding {
pub id: String,
pub severity: SeverityClass,
pub tier: Tier,
pub confidence: f64,
#[serde(flatten)]
pub kind: FindingKind,
pub evidence: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub suggested_action: Option<String>,
}
impl Eq for Finding {}
impl Finding {
pub fn new(
id: impl Into<String>,
tier: Tier,
confidence: f64,
kind: FindingKind,
evidence: impl Into<String>,
) -> Self {
let severity = kind.default_severity();
Self {
id: id.into(),
severity,
tier,
confidence: confidence.clamp(0.0, 1.0),
kind,
evidence: evidence.into(),
suggested_action: None,
}
}
pub fn content_id(&self) -> String {
let mut hasher = DefaultHasher::new();
self.kind.tag().hash(&mut hasher);
self.evidence.hash(&mut hasher);
if let Ok(payload) = serde_json::to_string(&self.kind) {
payload.hash(&mut hasher);
}
format!("f-{:016x}", hasher.finish())
}
pub fn with_severity(mut self, severity: SeverityClass) -> Self {
self.severity = severity;
self
}
pub fn with_suggested_action(mut self, action: impl Into<String>) -> Self {
self.suggested_action = Some(action.into());
self
}
pub fn primary_path(&self) -> Option<&Path> {
self.kind.primary_path()
}
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct TierSummary {
pub proven: u32,
pub likely: u32,
pub possible: u32,
pub unknown: u32,
}
impl TierSummary {
pub fn from_findings(findings: &[Finding]) -> Self {
let mut s = Self::default();
for f in findings {
match f.tier {
Tier::Proven => s.proven += 1,
Tier::Likely => s.likely += 1,
Tier::Possible => s.possible += 1,
Tier::Unknown => s.unknown += 1,
}
}
s
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_kind() -> FindingKind {
FindingKind::TestReference {
test: Location {
file: PathBuf::from("tests/t.rs"),
symbol: "smoke".into(),
},
matched_symbols: vec!["login".into()],
}
}
#[test]
fn confidence_clamped_to_unit_interval() {
let f = Finding::new("f-0001", Tier::Likely, 1.5, sample_kind(), "e");
assert_eq!(f.confidence, 1.0);
let f = Finding::new("f-0001", Tier::Likely, -0.5, sample_kind(), "e");
assert_eq!(f.confidence, 0.0);
}
#[test]
fn default_severity_by_kind() {
let f = Finding::new("x", Tier::Likely, 0.5, sample_kind(), "e");
assert_eq!(f.severity, SeverityClass::Medium);
}
#[test]
fn tier_summary_tallies_correctly() {
let mk = |tier: Tier, id: &str| Finding::new(id, tier, 0.5, sample_kind(), "e");
let findings = vec![
mk(Tier::Likely, "a"),
mk(Tier::Likely, "b"),
mk(Tier::Possible, "c"),
mk(Tier::Unknown, "d"),
];
let s = TierSummary::from_findings(&findings);
assert_eq!(s.proven, 0);
assert_eq!(s.likely, 2);
assert_eq!(s.possible, 1);
assert_eq!(s.unknown, 1);
}
#[test]
fn json_shape_uses_kind_tag() {
let f = Finding::new("f-0001", Tier::Likely, 0.85, sample_kind(), "direct ref");
let v: serde_json::Value = serde_json::to_value(&f).unwrap();
assert_eq!(v["kind"], "test_reference");
assert_eq!(v["tier"], "likely");
assert_eq!(v["severity"], "medium");
assert_eq!(v["confidence"], 0.85);
assert!(v["test"].is_object());
}
}