use serde::Serialize;
use crate::diff::ChangeSet;
use crate::model::Component;
pub const MIN_MAJOR_DELTA: u32 = 2;
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct VersionJumpFinding {
pub before: Component,
pub after: Component,
pub before_major: u32,
pub after_major: u32,
}
pub fn enrich(cs: &ChangeSet) -> Vec<VersionJumpFinding> {
enrich_with(cs, None)
}
pub fn enrich_with(cs: &ChangeSet, min_major_delta: Option<u32>) -> Vec<VersionJumpFinding> {
let threshold = min_major_delta.unwrap_or(MIN_MAJOR_DELTA);
let mut out = Vec::new();
for (before, after) in &cs.version_changed {
let Some(before_major) = extract_major(&before.version) else {
continue;
};
let Some(after_major) = extract_major(&after.version) else {
continue;
};
if after_major.saturating_sub(before_major) >= threshold {
out.push(VersionJumpFinding {
before: before.clone(),
after: after.clone(),
before_major,
after_major,
});
}
}
out
}
fn extract_major(version: &str) -> Option<u32> {
let s = version.strip_prefix('v').unwrap_or(version);
let head = s.split(['.', '-', '+']).next()?;
if head.is_empty() {
return None;
}
if head.len() > 1 && head.starts_with('0') {
return None;
}
head.parse::<u32>().ok()
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::todo,
clippy::unimplemented
)]
use super::*;
use crate::model::{Component, Ecosystem, Relationship};
fn comp(name: &str, version: &str) -> Component {
Component {
name: name.to_string(),
version: version.to_string(),
ecosystem: Ecosystem::Npm,
purl: Some(format!("pkg:npm/{name}@{version}")),
licenses: Vec::new(),
supplier: None,
hashes: Vec::new(),
relationship: Relationship::Unknown,
source_url: None,
bom_ref: None,
}
}
#[test]
fn extract_major_plain() {
assert_eq!(extract_major("1.2.3"), Some(1));
assert_eq!(extract_major("0.1.0"), Some(0));
assert_eq!(extract_major("42.0.0"), Some(42));
}
#[test]
fn extract_major_v_prefix() {
assert_eq!(extract_major("v2.0.0"), Some(2));
assert_eq!(extract_major("v10.5.1"), Some(10));
}
#[test]
fn extract_major_pre_release_suffix() {
assert_eq!(extract_major("2.5.3-beta.1"), Some(2));
assert_eq!(extract_major("4-rc.1"), Some(4));
}
#[test]
fn extract_major_build_metadata() {
assert_eq!(extract_major("3.0.0+build.7"), Some(3));
assert_eq!(extract_major("3+build.123"), Some(3));
}
#[test]
fn extract_major_returns_none_on_empty() {
assert_eq!(extract_major(""), None);
assert_eq!(extract_major("v"), None);
}
#[test]
fn extract_major_returns_none_on_non_numeric() {
assert_eq!(extract_major("latest"), None);
assert_eq!(extract_major("nightly"), None);
assert_eq!(extract_major("main"), None);
}
#[test]
fn extract_major_rejects_leading_zero() {
assert_eq!(extract_major("01.2.3"), None);
assert_eq!(extract_major("007"), None);
}
#[test]
fn enrich_flags_delta_of_three() {
let cs = ChangeSet {
version_changed: vec![(comp("a", "1.2.3"), comp("a", "4.0.0"))],
..Default::default()
};
let findings = enrich(&cs);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].before_major, 1);
assert_eq!(findings[0].after_major, 4);
}
#[test]
fn enrich_flags_delta_of_two() {
let cs = ChangeSet {
version_changed: vec![(comp("a", "1.0.0"), comp("a", "3.0.0"))],
..Default::default()
};
let findings = enrich(&cs);
assert_eq!(findings.len(), 1);
}
#[test]
fn enrich_does_not_flag_single_major_bump() {
let cs = ChangeSet {
version_changed: vec![(comp("a", "1.0.0"), comp("a", "2.0.0"))],
..Default::default()
};
assert!(enrich(&cs).is_empty());
}
#[test]
fn enrich_does_not_flag_minor_or_patch_bump() {
let cs = ChangeSet {
version_changed: vec![
(comp("a", "1.0.0"), comp("a", "1.5.0")),
(comp("b", "2.3.4"), comp("b", "2.3.5")),
],
..Default::default()
};
assert!(enrich(&cs).is_empty());
}
#[test]
fn enrich_skips_pairs_with_unparseable_versions() {
let cs = ChangeSet {
version_changed: vec![
(comp("a", "latest"), comp("a", "4.0.0")),
(comp("b", "1.0.0"), comp("b", "nightly")),
(comp("c", "1.0.0"), comp("c", "4.0.0")),
],
..Default::default()
};
let findings = enrich(&cs);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].after.name, "c");
}
#[test]
fn enrich_returns_empty_for_empty_changeset() {
let findings = enrich(&ChangeSet::default());
assert!(findings.is_empty());
}
#[test]
fn enrich_returns_empty_for_added_only_changeset() {
let cs = ChangeSet {
added: vec![comp("a", "1.0.0"), comp("b", "9.9.9")],
removed: vec![comp("c", "1.0.0")],
..Default::default()
};
assert!(enrich(&cs).is_empty());
}
#[test]
fn enrich_with_default_threshold_matches_enrich() {
let cs = ChangeSet {
version_changed: vec![(comp("a", "1.0.0"), comp("a", "4.0.0"))],
..Default::default()
};
let findings = enrich_with(&cs, None);
assert_eq!(findings.len(), 1);
}
#[test]
fn enrich_with_threshold_one_trips_on_single_major_bump() {
let cs = ChangeSet {
version_changed: vec![(comp("a", "1.0.0"), comp("a", "2.0.0"))],
..Default::default()
};
assert!(enrich(&cs).is_empty());
let findings = enrich_with(&cs, Some(1));
assert_eq!(findings.len(), 1);
}
#[test]
fn enrich_with_high_threshold_suppresses_smaller_jumps() {
let cs = ChangeSet {
version_changed: vec![(comp("a", "1.0.0"), comp("a", "4.0.0"))],
..Default::default()
};
assert_eq!(enrich(&cs).len(), 1);
assert!(enrich_with(&cs, Some(5)).is_empty());
}
#[test]
fn enrich_preserves_input_order() {
let cs = ChangeSet {
version_changed: vec![
(comp("alpha", "1.0.0"), comp("alpha", "5.0.0")),
(comp("beta", "2.0.0"), comp("beta", "4.0.0")),
(comp("gamma", "3.0.0"), comp("gamma", "9.0.0")),
],
..Default::default()
};
let findings = enrich(&cs);
assert_eq!(findings.len(), 3);
assert_eq!(findings[0].after.name, "alpha");
assert_eq!(findings[1].after.name, "beta");
assert_eq!(findings[2].after.name, "gamma");
}
use proptest::prelude::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(2048))]
#[test]
fn extract_major_does_not_panic(s in ".*") {
let _ = extract_major(&s);
}
#[test]
fn extract_major_round_trips_well_formed_numerics(major in 1u32..=10_000) {
let v = format!("{major}.0.0");
prop_assert_eq!(extract_major(&v), Some(major));
let with_v = format!("v{major}.0.0");
prop_assert_eq!(extract_major(&with_v), Some(major));
let with_pre = format!("{major}.0.0-rc.1");
prop_assert_eq!(extract_major(&with_pre), Some(major));
}
#[test]
fn extract_major_handles_unicode_without_panic(prefix in "\\PC*", major in 1u32..1000) {
let s = format!("{prefix}{major}.0.0");
let _ = extract_major(&s);
}
}
}