#![deny(warnings)]
use std::cmp::Ordering;
use tracing::warn;
use crate::federation::scanner::InstalledAsset;
pub fn round_half_up_1dp(value: f64) -> f64 {
(value * 10.0 + 0.5).floor() / 10.0
}
pub fn parse_semver(version: &str) -> Option<(u64, u64, u64)> {
let parts: Vec<&str> = version.split('.').collect();
if parts.len() != 3 {
return None;
}
let major = parts[0].parse::<u64>().ok()?;
let minor = parts[1].parse::<u64>().ok()?;
let patch = parts[2].parse::<u64>().ok()?;
Some((major, minor, patch))
}
pub fn compare_versions(a: &str, b: &str) -> Ordering {
match (parse_semver(a), parse_semver(b)) {
(Some(av), Some(bv)) => av.cmp(&bv),
_ => {
warn!(
a = a,
b = b,
"non-semver version string(s) — falling back to lexicographic comparison"
);
a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase())
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VersionStatus {
Current,
Outdated,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VersionDelta {
pub major_delta: i64,
pub minor_delta: i64,
pub patch_delta: i64,
pub status: VersionStatus,
}
pub fn version_delta(installed: &str, canonical: &str) -> VersionDelta {
match (parse_semver(installed), parse_semver(canonical)) {
(Some((im, in_, ip)), Some((cm, cn, cp))) => {
let major_delta = cm as i64 - im as i64;
let minor_delta = cn as i64 - in_ as i64;
let patch_delta = cp as i64 - ip as i64;
let status = if major_delta == 0 && minor_delta == 0 && patch_delta == 0 {
VersionStatus::Current
} else {
VersionStatus::Outdated
};
VersionDelta {
major_delta,
minor_delta,
patch_delta,
status,
}
}
_ => VersionDelta {
major_delta: 0,
minor_delta: 0,
patch_delta: 0,
status: VersionStatus::Unknown,
},
}
}
pub fn is_stale(installed: &str, canonical: &str, minor_threshold: u32) -> bool {
match (parse_semver(installed), parse_semver(canonical)) {
(Some((im, in_, _)), Some((cm, cn, _))) => {
if cm != im {
return false;
}
let behind = cn.saturating_sub(in_);
behind > minor_threshold as u64
}
_ => false,
}
}
pub fn extract_version(asset: &InstalledAsset) -> Option<String> {
asset.installed_version.clone()
}
pub fn freshness_score(assets_at_current: usize, total_with_versions: usize) -> f64 {
if total_with_versions == 0 {
return 0.0;
}
let ratio = assets_at_current as f64 / total_with_versions as f64;
round_half_up_1dp(ratio * 100.0)
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn parse_semver_valid() {
assert_eq!(parse_semver("1.2.3"), Some((1, 2, 3)));
assert_eq!(parse_semver("0.0.0"), Some((0, 0, 0)));
assert_eq!(parse_semver("10.20.30"), Some((10, 20, 30)));
}
#[test]
fn parse_semver_invalid_forms() {
assert!(parse_semver("1.2").is_none());
assert!(parse_semver("1.2.3.4").is_none());
assert!(parse_semver("v1.2.3").is_none());
assert!(parse_semver("1.2.3-alpha").is_none());
assert!(parse_semver("").is_none());
assert!(parse_semver("not-a-version").is_none());
assert!(parse_semver("1.2.x").is_none());
}
#[test]
fn compare_versions_semver_ordering() {
assert_eq!(compare_versions("1.0.0", "2.0.0"), Ordering::Less);
assert_eq!(compare_versions("1.2.3", "1.2.3"), Ordering::Equal);
assert_eq!(compare_versions("2.0.0", "1.9.9"), Ordering::Greater);
assert_eq!(compare_versions("1.10.0", "1.9.0"), Ordering::Greater);
}
#[test]
fn compare_versions_lexicographic_fallback() {
let result = compare_versions("alpha", "beta");
assert_eq!(result, Ordering::Less);
}
#[test]
fn version_delta_current() {
let d = version_delta("1.2.3", "1.2.3");
assert_eq!(d.status, VersionStatus::Current);
assert_eq!(d.major_delta, 0);
assert_eq!(d.minor_delta, 0);
assert_eq!(d.patch_delta, 0);
}
#[test]
fn version_delta_outdated() {
let d = version_delta("1.1.0", "1.3.0");
assert_eq!(d.status, VersionStatus::Outdated);
assert_eq!(d.minor_delta, 2);
}
#[test]
fn version_delta_unknown_non_semver() {
let d = version_delta("alpha", "beta");
assert_eq!(d.status, VersionStatus::Unknown);
}
#[test]
fn is_stale_exactly_at_threshold_not_stale() {
assert!(!is_stale("1.0.0", "1.2.0", 2));
}
#[test]
fn is_stale_over_threshold() {
assert!(is_stale("1.0.0", "1.3.0", 2));
}
#[test]
fn is_stale_different_major_not_stale() {
assert!(!is_stale("1.0.0", "2.5.0", 2));
}
#[test]
fn is_stale_non_semver_not_stale() {
assert!(!is_stale("alpha", "beta", 2));
}
#[test]
fn freshness_score_zero_total() {
assert_eq!(freshness_score(0, 0), 0.0);
}
#[test]
fn freshness_score_all_current() {
assert_eq!(freshness_score(5, 5), 100.0);
}
#[test]
fn freshness_score_half() {
assert_eq!(freshness_score(5, 10), 50.0);
}
#[test]
fn freshness_score_rounding_half_up() {
let s = freshness_score(1, 3);
assert_eq!(s, 33.3);
}
#[test]
fn round_half_up_boundary() {
let r = round_half_up_1dp(0.05);
assert!((r - 0.1).abs() < 1e-9, "expected 0.1 got {r}");
}
#[test]
fn round_half_up_50_05() {
let r = round_half_up_1dp(50.05);
assert!((r - 50.1).abs() < 1e-9, "expected 50.1 got {r}");
}
#[test]
fn round_half_up_exact() {
let r = round_half_up_1dp(33.3);
assert!((r - 33.3).abs() < 1e-9, "expected 33.3 got {r}");
}
prop_compose! {
fn arb_semver()(
major in 0u64..20,
minor in 0u64..30,
patch in 0u64..30,
) -> (u64, u64, u64) {
(major, minor, patch)
}
}
prop_compose! {
fn arb_semver_str()(sv in arb_semver()) -> String {
format!("{}.{}.{}", sv.0, sv.1, sv.2)
}
}
proptest! {
#![proptest_config(proptest::test_runner::Config {
cases: 256,
..Default::default()
})]
#[test]
fn prop20a_semver_total_order_consistent(
a in arb_semver(),
b in arb_semver(),
) {
let a_str = format!("{}.{}.{}", a.0, a.1, a.2);
let b_str = format!("{}.{}.{}", b.0, b.1, b.2);
let expected = a.cmp(&b);
let got = compare_versions(&a_str, &b_str);
prop_assert_eq!(
got, expected,
"compare_versions({}, {}) should equal tuple cmp({:?}, {:?})",
a_str, b_str, a, b
);
}
#[test]
fn prop20b_non_semver_lexicographic_fallback(
a in "[a-z]{2,8}(-[0-9]{1,3})?",
b in "[a-z]{2,8}(-[0-9]{1,3})?",
) {
let got = compare_versions(&a, &b);
let expected = a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase());
prop_assert_eq!(
got, expected,
"fallback compare({}, {}) should be lexicographic",
a, b
);
}
#[test]
fn prop20c_is_stale_monotonic(
major in 0u64..10,
installed_minor in 0u64..25,
extra_minor in 1u64..10,
patch in 0u64..10,
threshold in 0u32..5,
) {
let canon_minor_stale = installed_minor + threshold as u64 + 1;
let canon_minor_more = canon_minor_stale + extra_minor;
let installed = format!("{major}.{installed_minor}.{patch}");
let canonical_stale = format!("{major}.{canon_minor_stale}.{patch}");
let canonical_more = format!("{major}.{canon_minor_more}.{patch}");
prop_assert!(
is_stale(&installed, &canonical_stale, threshold),
"delta {} should be stale with threshold {}",
threshold as u64 + 1, threshold
);
prop_assert!(
is_stale(&installed, &canonical_more, threshold),
"delta {} should also be stale",
canon_minor_more - installed_minor
);
}
#[test]
fn prop20d_freshness_score_range(
current in 0usize..=1000,
total in 1usize..=1000,
) {
let current = current.min(total);
let score = freshness_score(current, total);
prop_assert!(
(0.0..=100.0).contains(&score),
"freshness_score({}, {}) = {} out of range",
current, total, score
);
}
}
}