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}