use serde::{Deserialize, Serialize, Serializer};
use crate::metric_set::{Metric, MetricSet};
use crate::metrics::{
abc, cognitive, cyclomatic, halstead, loc, mi, nargs, nexits, nom, npa, npm, tokens, wmc,
};
use crate::spaces::SpaceKind;
use crate::suppression::SuppressionScope;
use crate::{function, ops};
mod metrics;
#[cfg(feature = "vcs-git")]
mod vcs;
pub use metrics::*;
#[cfg(feature = "vcs-git")]
pub use vcs::*;
fn nan_default() -> f64 {
f64::NAN
}
mod non_finite {
use serde::{Deserialize, Deserializer, Serializer};
#[allow(clippy::trivially_copy_pass_by_ref)]
pub(super) fn serialize<S: Serializer>(value: &f64, serializer: S) -> Result<S::Ok, S::Error> {
if value.is_finite() {
serializer.serialize_f64(*value)
} else {
serializer.serialize_none()
}
}
pub(super) fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<f64, D::Error> {
Ok(Option::<f64>::deserialize(deserializer)?.unwrap_or(f64::NAN))
}
}
#[cfg(feature = "vcs-git")]
fn latest_present_risk(points: &[Option<crate::vcs::Stats>]) -> f64 {
points
.iter()
.rev()
.find_map(|s| s.as_ref().map(|s| s.risk_score))
.unwrap_or(0.0)
}
#[cfg(all(test, feature = "vcs-git"))]
mod trend_wire_tests {
use super::*;
#[allow(clippy::float_cmp)]
fn risk(points: &[Option<f64>]) -> f64 {
let owned: Vec<Option<crate::vcs::Stats>> = points
.iter()
.map(|p| {
p.map(|risk_score| crate::vcs::Stats {
risk_score,
..Default::default()
})
})
.collect();
latest_present_risk(&owned)
}
#[test]
#[allow(clippy::float_cmp)]
fn latest_present_risk_picks_the_newest_present_point() {
assert_eq!(risk(&[Some(1.0), None, Some(3.0), None]), 3.0);
}
#[test]
#[allow(clippy::float_cmp)]
fn latest_present_risk_defaults_to_zero_when_all_absent() {
assert_eq!(risk(&[None, None]), 0.0);
assert_eq!(risk(&[]), 0.0);
}
fn sample_vcs() -> Vcs {
Vcs {
commits_long: 12,
commits_recent: 4,
churn_long: 340,
churn_recent: 90,
authors_long: 3,
authors_recent: 2,
ownership_top_share: 0.625,
burst: 0.333,
bug_fix_commits: 2,
security_fix_commits: 1,
revert_commits: 0,
age_days: 200,
last_modified_days: 5,
change_entropy_long: 1.5,
change_entropy_recent: 0.5,
cochange_entropy_long: 2.0,
cochange_entropy_recent: 0.25,
risk_score: 7.5,
hotspot_score: Some(3.25),
author_ids: Some(vec!["deadbeef".to_owned()]),
}
}
#[test]
fn vcs_non_finite_floats_round_trip_as_null() {
let mut row = sample_vcs();
row.risk_score = f64::NAN;
row.burst = f64::INFINITY;
row.cochange_entropy_recent = f64::NEG_INFINITY;
let json = serde_json::to_string(&row).expect("serialize Vcs with NaN to JSON");
assert!(json.contains("\"risk_score\":null"), "got {json}");
let from_json: Vcs = serde_json::from_str(&json).expect("parse Vcs from JSON");
assert!(from_json.risk_score.is_nan());
assert!(from_json.burst.is_nan());
assert!(from_json.cochange_entropy_recent.is_nan());
assert_eq!(from_json.commits_long, row.commits_long);
assert!((from_json.ownership_top_share - row.ownership_top_share).abs() < 1e-12);
let yaml = serde_yaml::to_string(&row).expect("serialize Vcs to YAML");
let from_yaml: Vcs = serde_yaml::from_str(&yaml).expect("parse Vcs from YAML");
assert!(from_yaml.risk_score.is_nan() && from_yaml.burst.is_nan());
let mut bytes = Vec::new();
ciborium::into_writer(&row, &mut bytes).expect("serialize Vcs to CBOR");
let from_cbor: Vcs = ciborium::from_reader(bytes.as_slice()).expect("parse Vcs from CBOR");
assert!(from_cbor.risk_score.is_nan() && from_cbor.cochange_entropy_recent.is_nan());
}
#[test]
fn vcs_trend_point_round_trips_through_yaml_and_cbor() {
let point = VcsTrendPoint {
as_of: 1_700_000_000,
vcs: sample_vcs(),
};
let yaml = serde_yaml::to_string(&point).expect("serialize VcsTrendPoint to YAML");
let from_yaml: VcsTrendPoint = serde_yaml::from_str(&yaml).expect("parse point from YAML");
assert_eq!(from_yaml, point);
let mut bytes = Vec::new();
ciborium::into_writer(&point, &mut bytes).expect("serialize VcsTrendPoint to CBOR");
let from_cbor: VcsTrendPoint =
ciborium::from_reader(bytes.as_slice()).expect("parse point from CBOR");
assert_eq!(from_cbor, point);
}
#[test]
fn vcs_trend_round_trips_through_yaml_and_cbor() {
let trend = VcsTrend {
trend_schema_version: 1,
vcs_schema_version: 2,
risk_score_version: 2,
long_window_days: 365,
recent_window_days: 90,
truncated_shallow_clone: false,
as_of_points: vec![1_699_000_000, 1_700_000_000],
files: std::collections::BTreeMap::from([(
"src/lib.rs".to_owned(),
vec![
None,
Some(VcsTrendPoint {
as_of: 1_700_000_000,
vcs: sample_vcs(),
}),
],
)]),
deltas: VcsTrendDeltas::default(),
};
let yaml = serde_yaml::to_string(&trend).expect("serialize VcsTrend to YAML");
let from_yaml: VcsTrend = serde_yaml::from_str(&yaml).expect("parse VcsTrend from YAML");
assert_eq!(from_yaml, trend);
let mut bytes = Vec::new();
ciborium::into_writer(&trend, &mut bytes).expect("serialize VcsTrend to CBOR");
let from_cbor: VcsTrend =
ciborium::from_reader(bytes.as_slice()).expect("parse VcsTrend from CBOR");
assert_eq!(from_cbor, trend);
}
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct CodeMetrics {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nargs: Option<Nargs>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nexits: Option<Nexits>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cognitive: Option<Cognitive>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cyclomatic: Option<Cyclomatic>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub halstead: Option<Halstead>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub loc: Option<Loc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nom: Option<Nom>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tokens: Option<Tokens>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mi: Option<Mi>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub abc: Option<Abc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wmc: Option<Wmc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub npm: Option<Npm>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub npa: Option<Npa>,
#[cfg(feature = "vcs-git")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vcs: Option<Vcs>,
}
impl From<&crate::spaces::CodeMetrics> for CodeMetrics {
fn from(c: &crate::spaces::CodeMetrics) -> Self {
let sel = c.selected;
let on = |m: Metric| sel.contains(m);
Self {
nargs: on(Metric::Nargs).then(|| Nargs::from(&c.nargs)),
nexits: on(Metric::Nexits).then(|| Nexits::from(&c.nexits)),
cognitive: on(Metric::Cognitive).then(|| Cognitive::from(&c.cognitive)),
cyclomatic: on(Metric::Cyclomatic).then(|| Cyclomatic::from(&c.cyclomatic)),
halstead: on(Metric::Halstead).then(|| Halstead::from(&c.halstead)),
loc: on(Metric::Loc).then(|| Loc::from(&c.loc)),
nom: on(Metric::Nom).then(|| Nom::from(&c.nom)),
tokens: on(Metric::Tokens).then(|| Tokens::from(&c.tokens)),
mi: on(Metric::Mi).then(|| Mi::from(&c.mi)),
abc: on(Metric::Abc).then(|| Abc::from(&c.abc)),
wmc: (on(Metric::Wmc) && !c.wmc.is_disabled()).then(|| Wmc::from(&c.wmc)),
npm: (on(Metric::Npm) && !c.npm.is_disabled()).then(|| Npm::from(&c.npm)),
npa: (on(Metric::Npa) && !c.npa.is_disabled()).then(|| Npa::from(&c.npa)),
#[cfg(feature = "vcs-git")]
vcs: c.vcs.as_ref().map(Vcs::from),
}
}
}
impl CodeMetrics {
#[must_use]
pub fn selected(&self) -> MetricSet {
let mut set = MetricSet::empty();
let mut mark = |present: bool, metric: Metric| {
if present {
set.insert(metric);
}
};
mark(self.nargs.is_some(), Metric::Nargs);
mark(self.nexits.is_some(), Metric::Nexits);
mark(self.cognitive.is_some(), Metric::Cognitive);
mark(self.cyclomatic.is_some(), Metric::Cyclomatic);
mark(self.halstead.is_some(), Metric::Halstead);
mark(self.loc.is_some(), Metric::Loc);
mark(self.nom.is_some(), Metric::Nom);
mark(self.tokens.is_some(), Metric::Tokens);
mark(self.mi.is_some(), Metric::Mi);
mark(self.abc.is_some(), Metric::Abc);
mark(self.wmc.is_some(), Metric::Wmc);
mark(self.npm.is_some(), Metric::Npm);
mark(self.npa.is_some(), Metric::Npa);
set
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FuncSpace {
pub name: Option<String>,
pub start_line: usize,
pub end_line: usize,
pub kind: SpaceKind,
pub spaces: Vec<FuncSpace>,
pub metrics: CodeMetrics,
#[serde(default, skip_serializing_if = "SuppressionScope::is_empty")]
pub suppressed: SuppressionScope,
}
impl From<&crate::spaces::FuncSpace> for FuncSpace {
fn from(f: &crate::spaces::FuncSpace) -> Self {
Self {
name: f.name.clone(),
start_line: f.start_line,
end_line: f.end_line,
kind: f.kind,
spaces: f.spaces.iter().map(FuncSpace::from).collect(),
metrics: CodeMetrics::from(&f.metrics),
suppressed: f.suppressed.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Ops {
pub name: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub name_was_lossy: bool,
pub start_line: usize,
pub end_line: usize,
pub kind: SpaceKind,
pub spaces: Vec<Ops>,
pub operands: Vec<String>,
pub operators: Vec<String>,
}
impl From<&ops::Ops> for Ops {
fn from(o: &ops::Ops) -> Self {
Self {
name: o.name.clone(),
name_was_lossy: o.name_was_lossy,
start_line: o.start_line,
end_line: o.end_line,
kind: o.kind,
spaces: o.spaces.iter().map(Ops::from).collect(),
operands: o.operands.clone(),
operators: o.operators.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FunctionSpan {
pub name: Option<String>,
pub start_line: usize,
pub end_line: usize,
}
impl From<&function::FunctionSpan> for FunctionSpan {
fn from(f: &function::FunctionSpan) -> Self {
Self {
name: f.name.clone(),
start_line: f.start_line,
end_line: f.end_line,
}
}
}
macro_rules! serialize_via_wire {
($compute:ty => $wire:ident) => {
impl Serialize for $compute {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
$wire::from(self).serialize(serializer)
}
}
};
}
serialize_via_wire!(abc::Stats => Abc);
serialize_via_wire!(cognitive::Stats => Cognitive);
serialize_via_wire!(cyclomatic::Stats => Cyclomatic);
serialize_via_wire!(nexits::Stats => Nexits);
serialize_via_wire!(halstead::Stats => Halstead);
serialize_via_wire!(loc::Stats => Loc);
serialize_via_wire!(mi::Stats => Mi);
serialize_via_wire!(nargs::Stats => Nargs);
serialize_via_wire!(nom::Stats => Nom);
serialize_via_wire!(npa::Stats => Npa);
serialize_via_wire!(npm::Stats => Npm);
serialize_via_wire!(tokens::Stats => Tokens);
serialize_via_wire!(wmc::Stats => Wmc);
serialize_via_wire!(crate::spaces::CodeMetrics => CodeMetrics);
serialize_via_wire!(crate::spaces::FuncSpace => FuncSpace);
serialize_via_wire!(ops::Ops => Ops);
serialize_via_wire!(function::FunctionSpan => FunctionSpan);
#[cfg(test)]
#[allow(clippy::float_cmp)]
mod tests {
use super::*;
use crate::RustParser;
use crate::tools::check_func_space;
const FIXTURE: &str = "\
fn classify(x: i32) -> i32 {
if x > 0 {
x * 2
} else if x < 0 {
-x
} else {
0
}
}
fn run() {
let adder = |a: i32, b: i32| a + b;
let _ = adder(classify(3), classify(-4));
}
";
fn assert_fixture_oracle(tree: &FuncSpace) {
assert_eq!(tree.kind, SpaceKind::Unit);
assert_eq!(tree.spaces.len(), 2, "classify + run");
let m = &tree.metrics;
assert_eq!(m.cyclomatic.as_ref().unwrap().sum, 6, "unit cyclomatic.sum");
assert_eq!(
m.cyclomatic.as_ref().unwrap().value,
1,
"unit cyclomatic.value (own, excludes children)"
);
assert_eq!(m.cognitive.as_ref().unwrap().sum, 3, "unit cognitive.sum");
assert_eq!(
m.cognitive.as_ref().unwrap().value,
0,
"unit cognitive.value (own)"
);
assert_eq!(m.loc.as_ref().unwrap().sloc, 14, "unit loc.sloc");
assert_eq!(m.nom.as_ref().unwrap().total, 3, "unit nom.total");
let abc = m.abc.as_ref().unwrap();
assert_eq!((abc.assignments, abc.branches, abc.conditions), (2, 3, 4));
let classify = tree
.spaces
.iter()
.find(|s| s.name.as_deref() == Some("classify"))
.expect("classify space");
let classify_cyclo = classify.metrics.cyclomatic.as_ref().unwrap();
assert_eq!(classify_cyclo.sum, 3, "classify cyclomatic.sum");
assert_eq!(classify_cyclo.value, 3, "classify cyclomatic.value (leaf)");
let run = tree
.spaces
.iter()
.find(|s| s.name.as_deref() == Some("run"))
.expect("run space");
let run_cyclo = run.metrics.cyclomatic.as_ref().unwrap();
assert_eq!(run_cyclo.sum, 2, "run cyclomatic.sum (run + adder closure)");
assert_eq!(
run_cyclo.value, 1,
"run cyclomatic.value (own, excludes closure)"
);
}
#[test]
fn json_round_trips() {
check_func_space::<RustParser, _>(FIXTURE, "fixture.rs", |fs| {
let json = serde_json::to_string(&fs).expect("serialize FuncSpace to JSON");
let back: FuncSpace = serde_json::from_str(&json).expect("parse wire::FuncSpace");
assert_eq!(
back,
fs.to_wire(),
"deserialized wire tree must equal the projection"
);
assert_eq!(
serde_json::to_string(&back).expect("re-serialize wire"),
json,
"re-serialized wire must be byte-identical to the original JSON",
);
assert_fixture_oracle(&back);
});
}
#[test]
fn yaml_round_trips() {
check_func_space::<RustParser, _>(FIXTURE, "fixture.rs", |fs| {
let yaml = serde_yaml::to_string(&fs).expect("serialize to YAML");
let back: FuncSpace = serde_yaml::from_str(&yaml).expect("parse wire from YAML");
assert_eq!(back, fs.to_wire());
assert_eq!(serde_yaml::to_string(&back).expect("re-serialize"), yaml);
});
}
#[test]
fn toml_round_trips() {
check_func_space::<RustParser, _>(FIXTURE, "fixture.rs", |fs| {
let toml = toml::to_string(&fs).expect("serialize to TOML");
let back: FuncSpace = toml::from_str(&toml).expect("parse wire from TOML");
assert_eq!(back, fs.to_wire());
assert_eq!(toml::to_string(&back).expect("re-serialize"), toml);
});
}
#[test]
fn cbor_round_trips() {
check_func_space::<RustParser, _>(FIXTURE, "fixture.rs", |fs| {
let mut bytes = Vec::new();
ciborium::into_writer(&fs, &mut bytes).expect("serialize to CBOR");
let back: FuncSpace =
ciborium::from_reader(bytes.as_slice()).expect("parse wire from CBOR");
assert_eq!(back, fs.to_wire());
let mut re = Vec::new();
ciborium::into_writer(&back, &mut re).expect("re-serialize");
assert_eq!(re, bytes, "CBOR re-serialization must be byte-identical");
});
}
#[test]
fn function_span_round_trips() {
let resolved = FunctionSpan {
name: Some("foo".to_owned()),
start_line: 1,
end_line: 4,
};
let unresolved = FunctionSpan {
name: None,
start_line: 7,
end_line: 8,
};
for span in [resolved, unresolved] {
let json = serde_json::to_string(&span).expect("serialize FunctionSpan");
assert!(
!json.contains("error"),
"FunctionSpan JSON must not carry an `error` key, got {json}",
);
let back: FunctionSpan = serde_json::from_str(&json).expect("parse FunctionSpan");
assert_eq!(back, span, "FunctionSpan must round-trip through JSON");
}
let json = serde_json::to_string(&FunctionSpan {
name: None,
start_line: 7,
end_line: 8,
})
.expect("serialize");
assert!(
json.contains(r#""name":null"#),
"unresolved name must serialize to JSON null, got {json}",
);
}
#[test]
fn non_finite_floats_round_trip_as_null_or_omission() {
for probe in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let mi = Mi {
original: probe,
sei: 1.5,
visual_studio: 2.0,
};
let json = serde_json::to_string(&mi).expect("JSON");
assert!(
json.contains(r#""original":null"#),
"non-finite must serialize to JSON null, got {json}",
);
assert!(
serde_json::from_str::<Mi>(&json)
.expect("parse")
.original
.is_nan(),
"JSON null must deserialize back to NaN",
);
let yaml = serde_yaml::to_string(&mi).expect("YAML");
assert!(
yaml.contains("original: null"),
"non-finite must serialize to YAML null, got {yaml}",
);
assert!(
serde_yaml::from_str::<Mi>(&yaml)
.expect("parse")
.original
.is_nan()
);
let toml = toml::to_string(&mi).expect("TOML");
assert!(
!toml.contains("original"),
"TOML must omit the non-finite key (no null literal), got {toml}",
);
assert!(
toml::from_str::<Mi>(&toml)
.expect("parse")
.original
.is_nan(),
"omitted TOML key must default back to NaN",
);
let mut cbor = Vec::new();
ciborium::into_writer(&mi, &mut cbor).expect("CBOR");
let value: ciborium::value::Value =
ciborium::from_reader(cbor.as_slice()).expect("parse cbor value");
let ciborium::value::Value::Map(map) = &value else {
panic!("CBOR root is not a map");
};
let original_key = ciborium::value::Value::Text("original".to_owned());
let original = map
.iter()
.find_map(|(k, v)| (*k == original_key).then_some(v));
assert_eq!(
original,
Some(&ciborium::value::Value::Null),
"non-finite must serialize to CBOR null",
);
assert!(
ciborium::from_reader::<Mi, _>(cbor.as_slice())
.expect("parse")
.original
.is_nan(),
"CBOR null must deserialize back to NaN",
);
let back = serde_json::from_str::<Mi>(&json).expect("parse");
assert_eq!(back.sei, 1.5);
assert_eq!(back.visual_studio, 2.0);
}
}
#[test]
fn selected_is_inferred_from_present_keys() {
check_func_space::<RustParser, _>(FIXTURE, "fixture.rs", |fs| {
let full = fs.metrics.to_wire();
let selected = full.selected();
assert!(selected.contains(Metric::Loc));
assert!(selected.contains(Metric::Cyclomatic));
let json = serde_json::to_string(&full).expect("serialize metrics");
let mut value: serde_json::Value = serde_json::from_str(&json).expect("parse value");
let obj = value.as_object_mut().expect("metrics object");
obj.retain(|k, _| k == "loc");
let pruned: CodeMetrics =
serde_json::from_value(value).expect("parse pruned wire metrics");
let pruned_selected = pruned.selected();
assert!(pruned_selected.contains(Metric::Loc));
assert!(!pruned_selected.contains(Metric::Cyclomatic));
assert!(pruned.cyclomatic.is_none());
});
}
}