use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::core::hash::{fnv1a_64_chunked, fnv1a_hex};
pub use crate::core::severity::Severity;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Location {
pub file: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub symbol: Option<String>,
}
impl Location {
#[must_use]
pub fn file(file: PathBuf) -> Self {
Self {
file,
line: None,
symbol: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Finding {
pub id: String,
pub metric: String,
#[serde(default)]
pub severity: Severity,
#[serde(default)]
pub hotspot: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace: Option<String>,
pub location: Location,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub locations: Vec<Location>,
pub summary: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fix_hint: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub accepted: bool,
}
#[inline]
#[allow(clippy::trivially_copy_pass_by_ref)]
pub(crate) fn is_false(b: &bool) -> bool {
!*b
}
impl Finding {
#[must_use]
pub fn new(metric: &str, location: Location, summary: String, content_seed: &str) -> Self {
let id = Self::make_id(metric, &location, content_seed);
Self {
id,
metric: metric.to_owned(),
severity: Severity::Ok,
hotspot: false,
workspace: None,
location,
locations: Vec::new(),
summary,
fix_hint: None,
accepted: false,
}
}
#[must_use]
pub fn with_locations(mut self, extras: Vec<Location>) -> Self {
self.locations = extras;
self
}
pub const METRIC_CHANGE_COUPLING: &str = "change_coupling";
pub const METRIC_CHANGE_COUPLING_SYMMETRIC: &str = "change_coupling.symmetric";
pub const METRIC_CHANGE_COUPLING_CROSS_WORKSPACE: &str = "change_coupling.cross_workspace";
pub const METRIC_CHANGE_COUPLING_EXPECTED: &str = "change_coupling.expected";
#[must_use]
pub fn short_label(&self) -> String {
match self.metric.as_str() {
"ccn" => extract_leading_number(&self.summary, "CCN=")
.map_or_else(|| "CCN".to_owned(), |v| format!("CCN={v}")),
"cognitive" => extract_leading_number(&self.summary, "Cognitive=")
.map_or_else(|| "Cognitive".to_owned(), |v| format!("Cognitive={v}")),
"duplication" => "duplication".to_owned(),
Self::METRIC_CHANGE_COUPLING => "coupled".to_owned(),
Self::METRIC_CHANGE_COUPLING_SYMMETRIC => "coupled (sym)".to_owned(),
Self::METRIC_CHANGE_COUPLING_CROSS_WORKSPACE => "coupled (cross-ws)".to_owned(),
Self::METRIC_CHANGE_COUPLING_EXPECTED => "coupled (expected)".to_owned(),
"hotspot" => "hotspot".to_owned(),
"lcom" => extract_leading_number(&self.summary, "LCOM=")
.map_or_else(|| "LCOM".to_owned(), |v| format!("LCOM={v}")),
other => other.to_owned(),
}
}
#[must_use]
pub fn metric_value(&self) -> Option<f64> {
let prefix = match self.metric.as_str() {
"ccn" => "CCN=",
"cognitive" => "Cognitive=",
"lcom" => "LCOM=",
_ => return None,
};
extract_leading_number(&self.summary, prefix)?.parse().ok()
}
#[must_use]
pub fn make_id(metric: &str, location: &Location, content_seed: &str) -> String {
let path = location.file.to_string_lossy();
let symbol = location.symbol.as_deref().unwrap_or("*");
let h = fnv1a_64_chunked(&[
metric.as_bytes(),
path.as_bytes(),
symbol.as_bytes(),
content_seed.as_bytes(),
]);
format!("{metric}:{path}:{symbol}:{}", fnv1a_hex(h))
}
}
fn extract_leading_number(summary: &str, prefix: &str) -> Option<String> {
let after = summary.strip_prefix(prefix)?;
let value: String = after.chars().take_while(char::is_ascii_digit).collect();
if value.is_empty() {
None
} else {
Some(value)
}
}
pub trait IntoFindings {
#[allow(clippy::wrong_self_convention)]
fn into_findings(&self) -> Vec<Finding>;
}
#[cfg(test)]
mod tests {
use super::*;
fn loc(file: &str, symbol: Option<&str>, line: Option<u32>) -> Location {
Location {
file: PathBuf::from(file),
line,
symbol: symbol.map(str::to_owned),
}
}
#[test]
fn make_id_is_stable_for_identical_input() {
let l = loc("src/foo.rs", Some("bar"), Some(10));
let a = Finding::make_id("ccn", &l, "seed-1");
let b = Finding::make_id("ccn", &l, "seed-1");
assert_eq!(a, b);
assert!(a.starts_with("ccn:src/foo.rs:bar:"));
}
#[test]
fn make_id_differs_when_any_component_differs() {
let l = loc("src/foo.rs", Some("bar"), None);
let base = Finding::make_id("ccn", &l, "");
assert_ne!(base, Finding::make_id("cognitive", &l, ""));
assert_ne!(
base,
Finding::make_id("ccn", &loc("src/baz.rs", Some("bar"), None), "")
);
assert_ne!(
base,
Finding::make_id("ccn", &loc("src/foo.rs", Some("baz"), None), "")
);
assert_ne!(base, Finding::make_id("ccn", &l, "extra"));
}
#[test]
fn make_id_avoids_concatenation_collisions() {
let a = Finding::make_id("ab", &loc("c", None, None), "");
let b = Finding::make_id("a", &loc("bc", None, None), "");
assert_ne!(a, b);
}
#[test]
fn make_id_uses_star_when_symbol_missing() {
let l = loc("src/foo.rs", None, None);
let id = Finding::make_id("hotspot", &l, "");
assert!(id.starts_with("hotspot:src/foo.rs:*:"));
}
#[test]
fn short_label_extracts_metric_number_or_falls_back() {
let mut ccn = Finding::new(
"ccn",
loc("src/foo.rs", Some("bar"), Some(10)),
"CCN=28 bar (rust)".into(),
"seed",
);
ccn.severity = Severity::Critical;
assert_eq!(ccn.short_label(), "CCN=28");
let cog = Finding::new(
"cognitive",
loc("src/foo.rs", Some("bar"), Some(10)),
"Cognitive=42 bar (rust)".into(),
"seed",
);
assert_eq!(cog.short_label(), "Cognitive=42");
let bare = Finding::new(
"ccn",
loc("src/foo.rs", Some("bar"), Some(10)),
"no number here".into(),
"seed",
);
assert_eq!(bare.short_label(), "CCN");
let dup = Finding::new(
"duplication",
loc("src/foo.rs", None, None),
"anything".into(),
"",
);
assert_eq!(dup.short_label(), "duplication");
}
#[test]
fn finding_serialises_without_empty_locations_or_fix_hint() {
let f = Finding {
id: "x".into(),
metric: "ccn".into(),
severity: Severity::Ok,
hotspot: false,
workspace: None,
location: loc("src/foo.rs", Some("bar"), Some(1)),
locations: vec![],
summary: "hi".into(),
fix_hint: None,
accepted: false,
};
let json = serde_json::to_string(&f).unwrap();
assert!(!json.contains("locations"));
assert!(!json.contains("fix_hint"));
assert!(!json.contains("workspace"));
assert!(!json.contains("accepted"));
}
}