Skip to main content

vfa_tui/federation/
versions.rs

1//! Version comparison engine — parse semver, compute deltas, detect stale assets.
2//!
3//! # Responsibilities
4//!
5//! - [`parse_semver`] — parse a `"MAJOR.MINOR.PATCH"` string into a numeric tuple.
6//! - [`compare_versions`] — total order for version strings: semver when both
7//!   parse, lexicographic fallback with a `tracing::warn` (Req 8.6).
8//! - [`version_delta`] — compute [`VersionDelta`] and derive a status
9//!   (current / outdated / unknown) (Req 8.2).
10//! - [`is_stale`] — flag an asset when `canonical.minor - installed.minor >
11//!   threshold` on the same major (Req 9.1).
12//! - [`extract_version`] — priority-ordered version extraction from an
13//!   [`InstalledAsset`] (Req 8.1).
14//! - [`freshness_score`] — ratio of current assets, rounded half-up to 1 decimal
15//!   (Req 8.3).
16//! - [`round_half_up_1dp`] — shared deterministic rounding helper (Req 27.5).
17
18#![deny(warnings)]
19
20use std::cmp::Ordering;
21
22use tracing::warn;
23
24use crate::federation::scanner::InstalledAsset;
25
26// ---------------------------------------------------------------------------
27// round_half_up_1dp — deterministic 1-decimal-place rounding helper
28// ---------------------------------------------------------------------------
29
30/// Round `value` to **one decimal place** using **round-half-up** semantics.
31///
32/// This is the canonical rounding helper for all percentage scores in the
33/// federation module (Req 27.5).  It does **not** rely on default `f64`
34/// `Display` formatting, which uses round-half-to-even (banker's rounding).
35///
36/// # Algorithm
37///
38/// ```text
39/// rounded = floor(value * 10 + 0.5) / 10
40/// ```
41///
42/// This satisfies: 0.05 → 0.1, 0.15 → 0.2, 50.05 → 50.1.
43pub fn round_half_up_1dp(value: f64) -> f64 {
44    (value * 10.0 + 0.5).floor() / 10.0
45}
46
47// ---------------------------------------------------------------------------
48// parse_semver
49// ---------------------------------------------------------------------------
50
51/// Parse a semver string `"MAJOR.MINOR.PATCH"` into `(major, minor, patch)`.
52///
53/// Returns `None` for any string that does not match the strict
54/// `u64.u64.u64` format (including pre-release labels, build metadata,
55/// leading `v`, or fewer/more than three components).
56pub fn parse_semver(version: &str) -> Option<(u64, u64, u64)> {
57    let parts: Vec<&str> = version.split('.').collect();
58    if parts.len() != 3 {
59        return None;
60    }
61    let major = parts[0].parse::<u64>().ok()?;
62    let minor = parts[1].parse::<u64>().ok()?;
63    let patch = parts[2].parse::<u64>().ok()?;
64    Some((major, minor, patch))
65}
66
67// ---------------------------------------------------------------------------
68// compare_versions
69// ---------------------------------------------------------------------------
70
71/// Compare two version strings.
72///
73/// When **both** strings parse as semver, the comparison is numeric
74/// `(major, minor, patch)` tuple ordering.  When either string is
75/// non-semver, the comparison falls back to **case-insensitive
76/// lexicographic** ordering and a `tracing::warn` is emitted (Req 8.6).
77pub fn compare_versions(a: &str, b: &str) -> Ordering {
78    match (parse_semver(a), parse_semver(b)) {
79        (Some(av), Some(bv)) => av.cmp(&bv),
80        _ => {
81            warn!(
82                a = a,
83                b = b,
84                "non-semver version string(s) — falling back to lexicographic comparison"
85            );
86            a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase())
87        }
88    }
89}
90
91// ---------------------------------------------------------------------------
92// VersionStatus
93// ---------------------------------------------------------------------------
94
95/// Classification of an installed version relative to the canonical version.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub enum VersionStatus {
98    /// Installed version matches canonical (delta = 0.0.0).
99    Current,
100    /// Installed version is behind canonical.
101    Outdated,
102    /// One or both version strings could not be parsed — comparison is best-effort.
103    Unknown,
104}
105
106// ---------------------------------------------------------------------------
107// VersionDelta
108// ---------------------------------------------------------------------------
109
110/// Computed difference between an installed version and the canonical version.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct VersionDelta {
113    /// `canonical.major - installed.major` (may be 0 or negative if installed is ahead).
114    pub major_delta: i64,
115    /// `canonical.minor - installed.minor`.
116    pub minor_delta: i64,
117    /// `canonical.patch - installed.patch`.
118    pub patch_delta: i64,
119    /// Human-readable status derived from the delta.
120    pub status: VersionStatus,
121}
122
123// ---------------------------------------------------------------------------
124// version_delta
125// ---------------------------------------------------------------------------
126
127/// Compute a [`VersionDelta`] between an installed version and the canonical
128/// version (Req 8.2).
129///
130/// # Status rules
131///
132/// - Both parse as semver AND installed == canonical → [`VersionStatus::Current`]
133/// - Both parse as semver AND installed < canonical → [`VersionStatus::Outdated`]
134/// - Both parse as semver AND installed > canonical → [`VersionStatus::Outdated`]
135///   (unusual: installed is ahead; still flagged as non-current)
136/// - Either cannot be parsed → [`VersionStatus::Unknown`], delta components
137///   computed as 0.
138pub fn version_delta(installed: &str, canonical: &str) -> VersionDelta {
139    match (parse_semver(installed), parse_semver(canonical)) {
140        (Some((im, in_, ip)), Some((cm, cn, cp))) => {
141            let major_delta = cm as i64 - im as i64;
142            let minor_delta = cn as i64 - in_ as i64;
143            let patch_delta = cp as i64 - ip as i64;
144            let status = if major_delta == 0 && minor_delta == 0 && patch_delta == 0 {
145                VersionStatus::Current
146            } else {
147                VersionStatus::Outdated
148            };
149            VersionDelta {
150                major_delta,
151                minor_delta,
152                patch_delta,
153                status,
154            }
155        }
156        _ => VersionDelta {
157            major_delta: 0,
158            minor_delta: 0,
159            patch_delta: 0,
160            status: VersionStatus::Unknown,
161        },
162    }
163}
164
165// ---------------------------------------------------------------------------
166// is_stale
167// ---------------------------------------------------------------------------
168
169/// Returns `true` when the installed asset is considered **stale** (Req 9.1).
170///
171/// # Staleness rule
172///
173/// An asset is stale when **all** of the following hold:
174/// 1. Both version strings parse as semver.
175/// 2. `canonical.major == installed.major` (same major version train).
176/// 3. `canonical.minor - installed.minor > minor_threshold`.
177///
178/// **Major-bump handling**: when the canonical major is greater than the
179/// installed major, the asset is always considered outdated (via
180/// [`version_delta`]) but is **not** counted as stale by this function — a
181/// major version bump may involve intentional breaking changes and should be
182/// escalated through a separate upgrade path, not automatically flagged as
183/// stale.  Callers that need to alert on cross-major divergence should check
184/// `version_delta` directly.
185///
186/// # Parameters
187///
188/// - `installed` — version string from the installed asset.
189/// - `canonical` — version string from the catalog.
190/// - `minor_threshold` — number of minor versions behind before flagging as
191///   stale (Req 9.1 default: 2).
192pub fn is_stale(installed: &str, canonical: &str, minor_threshold: u32) -> bool {
193    match (parse_semver(installed), parse_semver(canonical)) {
194        (Some((im, in_, _)), Some((cm, cn, _))) => {
195            if cm != im {
196                // Different major — not handled as stale (see doc comment).
197                return false;
198            }
199            let behind = cn.saturating_sub(in_);
200            behind > minor_threshold as u64
201        }
202        _ => false,
203    }
204}
205
206// ---------------------------------------------------------------------------
207// extract_version
208// ---------------------------------------------------------------------------
209
210/// Extract the version string from an [`InstalledAsset`] using the
211/// priority-ordered strategy (Req 8.1):
212///
213/// 1. `installed_version` — set by the `# VFA-EXPORT:` metadata comment parser
214///    when the export CLI wrote a version field.
215/// 2. Any frontmatter version already captured — in the current model this is
216///    also surfaced via `installed_version` since the scanner unifies both
217///    sources into that field.  If a future scanner extension separates them
218///    this function can be extended.
219/// 3. `None` — caller falls back to content hash comparison (Req 8.5).
220///
221/// This function is intentionally lightweight: the scanner is the authority
222/// on version extraction; this function just reads the canonical field.
223pub fn extract_version(asset: &InstalledAsset) -> Option<String> {
224    asset.installed_version.clone()
225}
226
227// ---------------------------------------------------------------------------
228// freshness_score
229// ---------------------------------------------------------------------------
230
231/// Compute the "freshness score" for a workspace (Req 8.3).
232///
233/// Formula: `(assets_at_current / total_with_versions) × 100`, rounded
234/// half-up to one decimal place.
235///
236/// Returns `0.0` when `total_with_versions == 0` (no detectable versions).
237pub fn freshness_score(assets_at_current: usize, total_with_versions: usize) -> f64 {
238    if total_with_versions == 0 {
239        return 0.0;
240    }
241    let ratio = assets_at_current as f64 / total_with_versions as f64;
242    round_half_up_1dp(ratio * 100.0)
243}
244
245// ---------------------------------------------------------------------------
246// Tests
247// ---------------------------------------------------------------------------
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use proptest::prelude::*;
253
254    // -----------------------------------------------------------------------
255    // Unit tests — parse_semver
256    // -----------------------------------------------------------------------
257
258    #[test]
259    fn parse_semver_valid() {
260        assert_eq!(parse_semver("1.2.3"), Some((1, 2, 3)));
261        assert_eq!(parse_semver("0.0.0"), Some((0, 0, 0)));
262        assert_eq!(parse_semver("10.20.30"), Some((10, 20, 30)));
263    }
264
265    #[test]
266    fn parse_semver_invalid_forms() {
267        assert!(parse_semver("1.2").is_none());
268        assert!(parse_semver("1.2.3.4").is_none());
269        assert!(parse_semver("v1.2.3").is_none());
270        assert!(parse_semver("1.2.3-alpha").is_none());
271        assert!(parse_semver("").is_none());
272        assert!(parse_semver("not-a-version").is_none());
273        assert!(parse_semver("1.2.x").is_none());
274    }
275
276    // -----------------------------------------------------------------------
277    // Unit tests — compare_versions
278    // -----------------------------------------------------------------------
279
280    #[test]
281    fn compare_versions_semver_ordering() {
282        assert_eq!(compare_versions("1.0.0", "2.0.0"), Ordering::Less);
283        assert_eq!(compare_versions("1.2.3", "1.2.3"), Ordering::Equal);
284        assert_eq!(compare_versions("2.0.0", "1.9.9"), Ordering::Greater);
285        assert_eq!(compare_versions("1.10.0", "1.9.0"), Ordering::Greater);
286    }
287
288    #[test]
289    fn compare_versions_lexicographic_fallback() {
290        // Both non-semver — falls back to lexicographic, no panic
291        let result = compare_versions("alpha", "beta");
292        assert_eq!(result, Ordering::Less);
293    }
294
295    // -----------------------------------------------------------------------
296    // Unit tests — version_delta
297    // -----------------------------------------------------------------------
298
299    #[test]
300    fn version_delta_current() {
301        let d = version_delta("1.2.3", "1.2.3");
302        assert_eq!(d.status, VersionStatus::Current);
303        assert_eq!(d.major_delta, 0);
304        assert_eq!(d.minor_delta, 0);
305        assert_eq!(d.patch_delta, 0);
306    }
307
308    #[test]
309    fn version_delta_outdated() {
310        let d = version_delta("1.1.0", "1.3.0");
311        assert_eq!(d.status, VersionStatus::Outdated);
312        assert_eq!(d.minor_delta, 2);
313    }
314
315    #[test]
316    fn version_delta_unknown_non_semver() {
317        let d = version_delta("alpha", "beta");
318        assert_eq!(d.status, VersionStatus::Unknown);
319    }
320
321    // -----------------------------------------------------------------------
322    // Unit tests — is_stale
323    // -----------------------------------------------------------------------
324
325    #[test]
326    fn is_stale_exactly_at_threshold_not_stale() {
327        // Default threshold 2: canonical.minor - installed.minor == 2 → NOT stale
328        assert!(!is_stale("1.0.0", "1.2.0", 2));
329    }
330
331    #[test]
332    fn is_stale_over_threshold() {
333        // canonical.minor - installed.minor == 3 > 2 → stale
334        assert!(is_stale("1.0.0", "1.3.0", 2));
335    }
336
337    #[test]
338    fn is_stale_different_major_not_stale() {
339        // Major bump: not handled as stale
340        assert!(!is_stale("1.0.0", "2.5.0", 2));
341    }
342
343    #[test]
344    fn is_stale_non_semver_not_stale() {
345        assert!(!is_stale("alpha", "beta", 2));
346    }
347
348    // -----------------------------------------------------------------------
349    // Unit tests — freshness_score
350    // -----------------------------------------------------------------------
351
352    #[test]
353    fn freshness_score_zero_total() {
354        assert_eq!(freshness_score(0, 0), 0.0);
355    }
356
357    #[test]
358    fn freshness_score_all_current() {
359        assert_eq!(freshness_score(5, 5), 100.0);
360    }
361
362    #[test]
363    fn freshness_score_half() {
364        // 5/10 = 50.0
365        assert_eq!(freshness_score(5, 10), 50.0);
366    }
367
368    #[test]
369    fn freshness_score_rounding_half_up() {
370        // 1/3 = 33.333... → 33.3
371        let s = freshness_score(1, 3);
372        assert_eq!(s, 33.3);
373    }
374
375    // -----------------------------------------------------------------------
376    // Unit tests — round_half_up_1dp
377    // -----------------------------------------------------------------------
378
379    #[test]
380    fn round_half_up_boundary() {
381        // 0.05 → 0.1 (round half up)
382        let r = round_half_up_1dp(0.05);
383        assert!((r - 0.1).abs() < 1e-9, "expected 0.1 got {r}");
384    }
385
386    #[test]
387    fn round_half_up_50_05() {
388        let r = round_half_up_1dp(50.05);
389        assert!((r - 50.1).abs() < 1e-9, "expected 50.1 got {r}");
390    }
391
392    #[test]
393    fn round_half_up_exact() {
394        let r = round_half_up_1dp(33.3);
395        assert!((r - 33.3).abs() < 1e-9, "expected 33.3 got {r}");
396    }
397
398    // -----------------------------------------------------------------------
399    // Property 20 — Semantic version comparison (Req 8.1, 8.6, 9.1)
400    //
401    // Tests:
402    // (a) semver compare is a total order consistent with numeric tuple ordering.
403    // (b) non-semver falls back lexicographically (no panic).
404    // (c) is_stale is monotonic in minor delta.
405    // -----------------------------------------------------------------------
406
407    prop_compose! {
408        /// Generate a valid semver triple.
409        fn arb_semver()(
410            major in 0u64..20,
411            minor in 0u64..30,
412            patch in 0u64..30,
413        ) -> (u64, u64, u64) {
414            (major, minor, patch)
415        }
416    }
417
418    prop_compose! {
419        fn arb_semver_str()(sv in arb_semver()) -> String {
420            format!("{}.{}.{}", sv.0, sv.1, sv.2)
421        }
422    }
423
424    proptest! {
425        #![proptest_config(proptest::test_runner::Config {
426            cases: 256,
427            ..Default::default()
428        })]
429
430        // (a) compare_versions for two semver strings is consistent with tuple order.
431        #[test]
432        fn prop20a_semver_total_order_consistent(
433            a in arb_semver(),
434            b in arb_semver(),
435        ) {
436            let a_str = format!("{}.{}.{}", a.0, a.1, a.2);
437            let b_str = format!("{}.{}.{}", b.0, b.1, b.2);
438            let expected = a.cmp(&b);
439            let got = compare_versions(&a_str, &b_str);
440            prop_assert_eq!(
441                got, expected,
442                "compare_versions({}, {}) should equal tuple cmp({:?}, {:?})",
443                a_str, b_str, a, b
444            );
445        }
446
447        // (b) compare_versions on non-semver strings never panics and returns
448        // a consistent result equal to case-insensitive lexicographic ordering.
449        #[test]
450        fn prop20b_non_semver_lexicographic_fallback(
451            // Strings that won't happen to parse as semver (contain alpha chars)
452            a in "[a-z]{2,8}(-[0-9]{1,3})?",
453            b in "[a-z]{2,8}(-[0-9]{1,3})?",
454        ) {
455            // Must not panic
456            let got = compare_versions(&a, &b);
457            // Must equal case-insensitive lexicographic ordering
458            let expected = a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase());
459            prop_assert_eq!(
460                got, expected,
461                "fallback compare({}, {}) should be lexicographic",
462                a, b
463            );
464        }
465
466        // (c) is_stale is monotonic in the minor delta (on the same major).
467        // If delta D makes it stale, then D+1 also makes it stale.
468        #[test]
469        fn prop20c_is_stale_monotonic(
470            major in 0u64..10,
471            installed_minor in 0u64..25,
472            extra_minor in 1u64..10,
473            patch in 0u64..10,
474            threshold in 0u32..5,
475        ) {
476            // baseline: canonical_minor = installed_minor + threshold + 1
477            // → stale
478            let canon_minor_stale = installed_minor + threshold as u64 + 1;
479            let canon_minor_more = canon_minor_stale + extra_minor;
480
481            let installed = format!("{major}.{installed_minor}.{patch}");
482            let canonical_stale = format!("{major}.{canon_minor_stale}.{patch}");
483            let canonical_more = format!("{major}.{canon_minor_more}.{patch}");
484
485            // Baseline should be stale
486            prop_assert!(
487                is_stale(&installed, &canonical_stale, threshold),
488                "delta {} should be stale with threshold {}",
489                threshold as u64 + 1, threshold
490            );
491            // More minor versions behind → also stale
492            prop_assert!(
493                is_stale(&installed, &canonical_more, threshold),
494                "delta {} should also be stale",
495                canon_minor_more - installed_minor
496            );
497        }
498
499        // Additional: freshness_score result is always in [0.0, 100.0] and
500        // round_half_up_1dp preserves .x5 boundary correctly.
501        #[test]
502        fn prop20d_freshness_score_range(
503            current in 0usize..=1000,
504            total in 1usize..=1000,
505        ) {
506            // current can't exceed total
507            let current = current.min(total);
508            let score = freshness_score(current, total);
509            prop_assert!(
510                (0.0..=100.0).contains(&score),
511                "freshness_score({}, {}) = {} out of range",
512                current, total, score
513            );
514        }
515    }
516}