1use serde::{Deserialize, Serialize, Serializer};
46
47use crate::metric_set::{Metric, MetricSet};
48use crate::metrics::{
49 abc, cognitive, cyclomatic, halstead, loc, mi, nargs, nexits, nom, npa, npm, tokens, wmc,
50};
51use crate::spaces::SpaceKind;
52use crate::suppression::SuppressionScope;
53use crate::{function, ops};
54
55mod metrics;
61#[cfg(feature = "vcs-git")]
64mod vcs;
65
66pub use metrics::*;
67#[cfg(feature = "vcs-git")]
68pub use vcs::*;
69
70fn nan_default() -> f64 {
74 f64::NAN
75}
76
77mod non_finite {
87 use serde::{Deserialize, Deserializer, Serializer};
88
89 #[allow(clippy::trivially_copy_pass_by_ref)]
92 pub(super) fn serialize<S: Serializer>(value: &f64, serializer: S) -> Result<S::Ok, S::Error> {
93 if value.is_finite() {
94 serializer.serialize_f64(*value)
95 } else {
96 serializer.serialize_none()
97 }
98 }
99
100 pub(super) fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<f64, D::Error> {
101 Ok(Option::<f64>::deserialize(deserializer)?.unwrap_or(f64::NAN))
102 }
103}
104
105#[cfg(feature = "vcs-git")]
109fn latest_present_risk(points: &[Option<crate::vcs::Stats>]) -> f64 {
110 points
111 .iter()
112 .rev()
113 .find_map(|s| s.as_ref().map(|s| s.risk_score))
114 .unwrap_or(0.0)
115}
116
117#[cfg(all(test, feature = "vcs-git"))]
118mod trend_wire_tests {
119 use super::*;
120
121 #[allow(clippy::float_cmp)]
124 fn risk(points: &[Option<f64>]) -> f64 {
125 let owned: Vec<Option<crate::vcs::Stats>> = points
126 .iter()
127 .map(|p| {
128 p.map(|risk_score| crate::vcs::Stats {
129 risk_score,
130 ..Default::default()
131 })
132 })
133 .collect();
134 latest_present_risk(&owned)
135 }
136
137 #[test]
138 #[allow(clippy::float_cmp)]
139 fn latest_present_risk_picks_the_newest_present_point() {
140 assert_eq!(risk(&[Some(1.0), None, Some(3.0), None]), 3.0);
143 }
144
145 #[test]
146 #[allow(clippy::float_cmp)]
147 fn latest_present_risk_defaults_to_zero_when_all_absent() {
148 assert_eq!(risk(&[None, None]), 0.0);
151 assert_eq!(risk(&[]), 0.0);
152 }
153
154 fn sample_vcs() -> Vcs {
156 Vcs {
157 commits_long: 12,
158 commits_recent: 4,
159 churn_long: 340,
160 churn_recent: 90,
161 authors_long: 3,
162 authors_recent: 2,
163 ownership_top_share: 0.625,
164 burst: 0.333,
165 bug_fix_commits: 2,
166 security_fix_commits: 1,
167 revert_commits: 0,
168 age_days: 200,
169 last_modified_days: 5,
170 change_entropy_long: 1.5,
171 change_entropy_recent: 0.5,
172 cochange_entropy_long: 2.0,
173 cochange_entropy_recent: 0.25,
174 risk_score: 7.5,
175 hotspot_score: Some(3.25),
176 author_ids: Some(vec!["deadbeef".to_owned()]),
177 }
178 }
179
180 #[test]
185 fn vcs_non_finite_floats_round_trip_as_null() {
186 let mut row = sample_vcs();
187 row.risk_score = f64::NAN;
188 row.burst = f64::INFINITY;
189 row.cochange_entropy_recent = f64::NEG_INFINITY;
190
191 let json = serde_json::to_string(&row).expect("serialize Vcs with NaN to JSON");
194 assert!(json.contains("\"risk_score\":null"), "got {json}");
195 let from_json: Vcs = serde_json::from_str(&json).expect("parse Vcs from JSON");
196 assert!(from_json.risk_score.is_nan());
197 assert!(from_json.burst.is_nan());
198 assert!(from_json.cochange_entropy_recent.is_nan());
199 assert_eq!(from_json.commits_long, row.commits_long);
201 assert!((from_json.ownership_top_share - row.ownership_top_share).abs() < 1e-12);
202
203 let yaml = serde_yaml::to_string(&row).expect("serialize Vcs to YAML");
205 let from_yaml: Vcs = serde_yaml::from_str(&yaml).expect("parse Vcs from YAML");
206 assert!(from_yaml.risk_score.is_nan() && from_yaml.burst.is_nan());
207
208 let mut bytes = Vec::new();
210 ciborium::into_writer(&row, &mut bytes).expect("serialize Vcs to CBOR");
211 let from_cbor: Vcs = ciborium::from_reader(bytes.as_slice()).expect("parse Vcs from CBOR");
212 assert!(from_cbor.risk_score.is_nan() && from_cbor.cochange_entropy_recent.is_nan());
213 }
214
215 #[test]
220 fn vcs_trend_point_round_trips_through_yaml_and_cbor() {
221 let point = VcsTrendPoint {
222 as_of: 1_700_000_000,
223 vcs: sample_vcs(),
224 };
225
226 let yaml = serde_yaml::to_string(&point).expect("serialize VcsTrendPoint to YAML");
227 let from_yaml: VcsTrendPoint = serde_yaml::from_str(&yaml).expect("parse point from YAML");
228 assert_eq!(from_yaml, point);
229
230 let mut bytes = Vec::new();
231 ciborium::into_writer(&point, &mut bytes).expect("serialize VcsTrendPoint to CBOR");
232 let from_cbor: VcsTrendPoint =
233 ciborium::from_reader(bytes.as_slice()).expect("parse point from CBOR");
234 assert_eq!(from_cbor, point);
235 }
236
237 #[test]
238 fn vcs_trend_round_trips_through_yaml_and_cbor() {
239 let trend = VcsTrend {
240 trend_schema_version: 1,
241 vcs_schema_version: 2,
242 risk_score_version: 2,
243 long_window_days: 365,
244 recent_window_days: 90,
245 truncated_shallow_clone: false,
246 as_of_points: vec![1_699_000_000, 1_700_000_000],
247 files: std::collections::BTreeMap::from([(
248 "src/lib.rs".to_owned(),
249 vec![
250 None,
251 Some(VcsTrendPoint {
252 as_of: 1_700_000_000,
253 vcs: sample_vcs(),
254 }),
255 ],
256 )]),
257 deltas: VcsTrendDeltas::default(),
258 };
259
260 let yaml = serde_yaml::to_string(&trend).expect("serialize VcsTrend to YAML");
261 let from_yaml: VcsTrend = serde_yaml::from_str(&yaml).expect("parse VcsTrend from YAML");
262 assert_eq!(from_yaml, trend);
263
264 let mut bytes = Vec::new();
265 ciborium::into_writer(&trend, &mut bytes).expect("serialize VcsTrend to CBOR");
266 let from_cbor: VcsTrend =
267 ciborium::from_reader(bytes.as_slice()).expect("parse VcsTrend from CBOR");
268 assert_eq!(from_cbor, trend);
269 }
270}
271
272#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
282pub struct CodeMetrics {
283 #[serde(default, skip_serializing_if = "Option::is_none")]
285 pub nargs: Option<Nargs>,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub nexits: Option<Nexits>,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub cognitive: Option<Cognitive>,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub cyclomatic: Option<Cyclomatic>,
295 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub halstead: Option<Halstead>,
298 #[serde(default, skip_serializing_if = "Option::is_none")]
300 pub loc: Option<Loc>,
301 #[serde(default, skip_serializing_if = "Option::is_none")]
303 pub nom: Option<Nom>,
304 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub tokens: Option<Tokens>,
307 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub mi: Option<Mi>,
310 #[serde(default, skip_serializing_if = "Option::is_none")]
312 pub abc: Option<Abc>,
313 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub wmc: Option<Wmc>,
316 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub npm: Option<Npm>,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub npa: Option<Npa>,
322 #[cfg(feature = "vcs-git")]
325 #[serde(default, skip_serializing_if = "Option::is_none")]
326 pub vcs: Option<Vcs>,
327}
328
329impl From<&crate::spaces::CodeMetrics> for CodeMetrics {
330 fn from(c: &crate::spaces::CodeMetrics) -> Self {
331 let sel = c.selected;
332 let on = |m: Metric| sel.contains(m);
336 Self {
337 nargs: on(Metric::Nargs).then(|| Nargs::from(&c.nargs)),
338 nexits: on(Metric::Nexits).then(|| Nexits::from(&c.nexits)),
339 cognitive: on(Metric::Cognitive).then(|| Cognitive::from(&c.cognitive)),
340 cyclomatic: on(Metric::Cyclomatic).then(|| Cyclomatic::from(&c.cyclomatic)),
341 halstead: on(Metric::Halstead).then(|| Halstead::from(&c.halstead)),
342 loc: on(Metric::Loc).then(|| Loc::from(&c.loc)),
343 nom: on(Metric::Nom).then(|| Nom::from(&c.nom)),
344 tokens: on(Metric::Tokens).then(|| Tokens::from(&c.tokens)),
345 mi: on(Metric::Mi).then(|| Mi::from(&c.mi)),
346 abc: on(Metric::Abc).then(|| Abc::from(&c.abc)),
347 wmc: (on(Metric::Wmc) && !c.wmc.is_disabled()).then(|| Wmc::from(&c.wmc)),
348 npm: (on(Metric::Npm) && !c.npm.is_disabled()).then(|| Npm::from(&c.npm)),
349 npa: (on(Metric::Npa) && !c.npa.is_disabled()).then(|| Npa::from(&c.npa)),
350 #[cfg(feature = "vcs-git")]
353 vcs: c.vcs.as_ref().map(Vcs::from),
354 }
355 }
356}
357
358impl CodeMetrics {
359 #[must_use]
366 pub fn selected(&self) -> MetricSet {
367 let mut set = MetricSet::empty();
368 let mut mark = |present: bool, metric: Metric| {
369 if present {
370 set.insert(metric);
371 }
372 };
373 mark(self.nargs.is_some(), Metric::Nargs);
374 mark(self.nexits.is_some(), Metric::Nexits);
375 mark(self.cognitive.is_some(), Metric::Cognitive);
376 mark(self.cyclomatic.is_some(), Metric::Cyclomatic);
377 mark(self.halstead.is_some(), Metric::Halstead);
378 mark(self.loc.is_some(), Metric::Loc);
379 mark(self.nom.is_some(), Metric::Nom);
380 mark(self.tokens.is_some(), Metric::Tokens);
381 mark(self.mi.is_some(), Metric::Mi);
382 mark(self.abc.is_some(), Metric::Abc);
383 mark(self.wmc.is_some(), Metric::Wmc);
384 mark(self.npm.is_some(), Metric::Npm);
385 mark(self.npa.is_some(), Metric::Npa);
386 set
387 }
388}
389
390#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
392pub struct FuncSpace {
393 pub name: Option<String>,
395 pub start_line: usize,
397 pub end_line: usize,
399 pub kind: SpaceKind,
401 pub spaces: Vec<FuncSpace>,
403 pub metrics: CodeMetrics,
405 #[serde(default, skip_serializing_if = "SuppressionScope::is_empty")]
408 pub suppressed: SuppressionScope,
409}
410
411impl From<&crate::spaces::FuncSpace> for FuncSpace {
412 fn from(f: &crate::spaces::FuncSpace) -> Self {
413 Self {
414 name: f.name.clone(),
415 start_line: f.start_line,
416 end_line: f.end_line,
417 kind: f.kind,
418 spaces: f.spaces.iter().map(FuncSpace::from).collect(),
419 metrics: CodeMetrics::from(&f.metrics),
420 suppressed: f.suppressed.clone(),
421 }
422 }
423}
424
425#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
427pub struct Ops {
428 pub name: Option<String>,
430 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
432 pub name_was_lossy: bool,
433 pub start_line: usize,
435 pub end_line: usize,
437 pub kind: SpaceKind,
439 pub spaces: Vec<Ops>,
441 pub operands: Vec<String>,
443 pub operators: Vec<String>,
445}
446
447impl From<&ops::Ops> for Ops {
448 fn from(o: &ops::Ops) -> Self {
449 Self {
450 name: o.name.clone(),
451 name_was_lossy: o.name_was_lossy,
452 start_line: o.start_line,
453 end_line: o.end_line,
454 kind: o.kind,
455 spaces: o.spaces.iter().map(Ops::from).collect(),
456 operands: o.operands.clone(),
457 operators: o.operators.clone(),
458 }
459 }
460}
461
462#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
464pub struct FunctionSpan {
465 pub name: Option<String>,
467 pub start_line: usize,
469 pub end_line: usize,
471}
472
473impl From<&function::FunctionSpan> for FunctionSpan {
474 fn from(f: &function::FunctionSpan) -> Self {
475 Self {
476 name: f.name.clone(),
477 start_line: f.start_line,
478 end_line: f.end_line,
479 }
480 }
481}
482
483macro_rules! serialize_via_wire {
493 ($compute:ty => $wire:ident) => {
494 impl Serialize for $compute {
495 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
496 $wire::from(self).serialize(serializer)
497 }
498 }
499 };
500}
501
502serialize_via_wire!(abc::Stats => Abc);
503serialize_via_wire!(cognitive::Stats => Cognitive);
504serialize_via_wire!(cyclomatic::Stats => Cyclomatic);
505serialize_via_wire!(nexits::Stats => Nexits);
506serialize_via_wire!(halstead::Stats => Halstead);
507serialize_via_wire!(loc::Stats => Loc);
508serialize_via_wire!(mi::Stats => Mi);
509serialize_via_wire!(nargs::Stats => Nargs);
510serialize_via_wire!(nom::Stats => Nom);
511serialize_via_wire!(npa::Stats => Npa);
512serialize_via_wire!(npm::Stats => Npm);
513serialize_via_wire!(tokens::Stats => Tokens);
514serialize_via_wire!(wmc::Stats => Wmc);
515serialize_via_wire!(crate::spaces::CodeMetrics => CodeMetrics);
516serialize_via_wire!(crate::spaces::FuncSpace => FuncSpace);
517serialize_via_wire!(ops::Ops => Ops);
518serialize_via_wire!(function::FunctionSpan => FunctionSpan);
519
520#[cfg(test)]
521#[allow(clippy::float_cmp)]
527mod tests {
528 use super::*;
529 use crate::RustParser;
530 use crate::tools::check_func_space;
531
532 const FIXTURE: &str = "\
535fn classify(x: i32) -> i32 {
536 if x > 0 {
537 x * 2
538 } else if x < 0 {
539 -x
540 } else {
541 0
542 }
543}
544
545fn run() {
546 let adder = |a: i32, b: i32| a + b;
547 let _ = adder(classify(3), classify(-4));
548}
549";
550
551 fn assert_fixture_oracle(tree: &FuncSpace) {
558 assert_eq!(tree.kind, SpaceKind::Unit);
560 assert_eq!(tree.spaces.len(), 2, "classify + run");
561
562 let m = &tree.metrics;
563 assert_eq!(m.cyclomatic.as_ref().unwrap().sum, 6, "unit cyclomatic.sum");
564 assert_eq!(
568 m.cyclomatic.as_ref().unwrap().value,
569 1,
570 "unit cyclomatic.value (own, excludes children)"
571 );
572 assert_eq!(m.cognitive.as_ref().unwrap().sum, 3, "unit cognitive.sum");
573 assert_eq!(
574 m.cognitive.as_ref().unwrap().value,
575 0,
576 "unit cognitive.value (own)"
577 );
578 assert_eq!(m.loc.as_ref().unwrap().sloc, 14, "unit loc.sloc");
579 assert_eq!(m.nom.as_ref().unwrap().total, 3, "unit nom.total");
580 let abc = m.abc.as_ref().unwrap();
583 assert_eq!((abc.assignments, abc.branches, abc.conditions), (2, 3, 4));
584
585 let classify = tree
586 .spaces
587 .iter()
588 .find(|s| s.name.as_deref() == Some("classify"))
589 .expect("classify space");
590 let classify_cyclo = classify.metrics.cyclomatic.as_ref().unwrap();
591 assert_eq!(classify_cyclo.sum, 3, "classify cyclomatic.sum");
592 assert_eq!(classify_cyclo.value, 3, "classify cyclomatic.value (leaf)");
594
595 let run = tree
599 .spaces
600 .iter()
601 .find(|s| s.name.as_deref() == Some("run"))
602 .expect("run space");
603 let run_cyclo = run.metrics.cyclomatic.as_ref().unwrap();
604 assert_eq!(run_cyclo.sum, 2, "run cyclomatic.sum (run + adder closure)");
605 assert_eq!(
606 run_cyclo.value, 1,
607 "run cyclomatic.value (own, excludes closure)"
608 );
609 }
610
611 #[test]
616 fn json_round_trips() {
617 check_func_space::<RustParser, _>(FIXTURE, "fixture.rs", |fs| {
618 let json = serde_json::to_string(&fs).expect("serialize FuncSpace to JSON");
619 let back: FuncSpace = serde_json::from_str(&json).expect("parse wire::FuncSpace");
620 assert_eq!(
621 back,
622 fs.to_wire(),
623 "deserialized wire tree must equal the projection"
624 );
625 assert_eq!(
626 serde_json::to_string(&back).expect("re-serialize wire"),
627 json,
628 "re-serialized wire must be byte-identical to the original JSON",
629 );
630 assert_fixture_oracle(&back);
633 });
634 }
635
636 #[test]
637 fn yaml_round_trips() {
638 check_func_space::<RustParser, _>(FIXTURE, "fixture.rs", |fs| {
639 let yaml = serde_yaml::to_string(&fs).expect("serialize to YAML");
640 let back: FuncSpace = serde_yaml::from_str(&yaml).expect("parse wire from YAML");
641 assert_eq!(back, fs.to_wire());
642 assert_eq!(serde_yaml::to_string(&back).expect("re-serialize"), yaml);
643 });
644 }
645
646 #[test]
647 fn toml_round_trips() {
648 check_func_space::<RustParser, _>(FIXTURE, "fixture.rs", |fs| {
649 let toml = toml::to_string(&fs).expect("serialize to TOML");
650 let back: FuncSpace = toml::from_str(&toml).expect("parse wire from TOML");
651 assert_eq!(back, fs.to_wire());
652 assert_eq!(toml::to_string(&back).expect("re-serialize"), toml);
653 });
654 }
655
656 #[test]
657 fn cbor_round_trips() {
658 check_func_space::<RustParser, _>(FIXTURE, "fixture.rs", |fs| {
659 let mut bytes = Vec::new();
660 ciborium::into_writer(&fs, &mut bytes).expect("serialize to CBOR");
661 let back: FuncSpace =
662 ciborium::from_reader(bytes.as_slice()).expect("parse wire from CBOR");
663 assert_eq!(back, fs.to_wire());
664 let mut re = Vec::new();
665 ciborium::into_writer(&back, &mut re).expect("re-serialize");
666 assert_eq!(re, bytes, "CBOR re-serialization must be byte-identical");
667 });
668 }
669
670 #[test]
675 fn function_span_round_trips() {
676 let resolved = FunctionSpan {
677 name: Some("foo".to_owned()),
678 start_line: 1,
679 end_line: 4,
680 };
681 let unresolved = FunctionSpan {
682 name: None,
683 start_line: 7,
684 end_line: 8,
685 };
686
687 for span in [resolved, unresolved] {
688 let json = serde_json::to_string(&span).expect("serialize FunctionSpan");
689 assert!(
690 !json.contains("error"),
691 "FunctionSpan JSON must not carry an `error` key, got {json}",
692 );
693 let back: FunctionSpan = serde_json::from_str(&json).expect("parse FunctionSpan");
694 assert_eq!(back, span, "FunctionSpan must round-trip through JSON");
695 }
696
697 let json = serde_json::to_string(&FunctionSpan {
699 name: None,
700 start_line: 7,
701 end_line: 8,
702 })
703 .expect("serialize");
704 assert!(
705 json.contains(r#""name":null"#),
706 "unresolved name must serialize to JSON null, got {json}",
707 );
708 }
709
710 #[test]
715 fn non_finite_floats_round_trip_as_null_or_omission() {
716 for probe in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
717 let mi = Mi {
718 original: probe,
719 sei: 1.5,
720 visual_studio: 2.0,
721 };
722
723 let json = serde_json::to_string(&mi).expect("JSON");
724 assert!(
725 json.contains(r#""original":null"#),
726 "non-finite must serialize to JSON null, got {json}",
727 );
728 assert!(
729 serde_json::from_str::<Mi>(&json)
730 .expect("parse")
731 .original
732 .is_nan(),
733 "JSON null must deserialize back to NaN",
734 );
735
736 let yaml = serde_yaml::to_string(&mi).expect("YAML");
737 assert!(
738 yaml.contains("original: null"),
739 "non-finite must serialize to YAML null, got {yaml}",
740 );
741 assert!(
742 serde_yaml::from_str::<Mi>(&yaml)
743 .expect("parse")
744 .original
745 .is_nan()
746 );
747
748 let toml = toml::to_string(&mi).expect("TOML");
749 assert!(
750 !toml.contains("original"),
751 "TOML must omit the non-finite key (no null literal), got {toml}",
752 );
753 assert!(
754 toml::from_str::<Mi>(&toml)
755 .expect("parse")
756 .original
757 .is_nan(),
758 "omitted TOML key must default back to NaN",
759 );
760
761 let mut cbor = Vec::new();
764 ciborium::into_writer(&mi, &mut cbor).expect("CBOR");
765 let value: ciborium::value::Value =
766 ciborium::from_reader(cbor.as_slice()).expect("parse cbor value");
767 let ciborium::value::Value::Map(map) = &value else {
768 panic!("CBOR root is not a map");
769 };
770 let original_key = ciborium::value::Value::Text("original".to_owned());
771 let original = map
772 .iter()
773 .find_map(|(k, v)| (*k == original_key).then_some(v));
774 assert_eq!(
775 original,
776 Some(&ciborium::value::Value::Null),
777 "non-finite must serialize to CBOR null",
778 );
779 assert!(
780 ciborium::from_reader::<Mi, _>(cbor.as_slice())
781 .expect("parse")
782 .original
783 .is_nan(),
784 "CBOR null must deserialize back to NaN",
785 );
786
787 let back = serde_json::from_str::<Mi>(&json).expect("parse");
789 assert_eq!(back.sei, 1.5);
790 assert_eq!(back.visual_studio, 2.0);
791 }
792 }
793
794 #[test]
798 fn selected_is_inferred_from_present_keys() {
799 check_func_space::<RustParser, _>(FIXTURE, "fixture.rs", |fs| {
800 let full = fs.metrics.to_wire();
801 let selected = full.selected();
802 assert!(selected.contains(Metric::Loc));
803 assert!(selected.contains(Metric::Cyclomatic));
804
805 let json = serde_json::to_string(&full).expect("serialize metrics");
807 let mut value: serde_json::Value = serde_json::from_str(&json).expect("parse value");
808 let obj = value.as_object_mut().expect("metrics object");
809 obj.retain(|k, _| k == "loc");
810 let pruned: CodeMetrics =
811 serde_json::from_value(value).expect("parse pruned wire metrics");
812 let pruned_selected = pruned.selected();
813 assert!(pruned_selected.contains(Metric::Loc));
814 assert!(!pruned_selected.contains(Metric::Cyclomatic));
815 assert!(pruned.cyclomatic.is_none());
816 });
817 }
818}