Skip to main content

vfa_tui/federation/
coverage.rs

1//! Coverage engine — Tasks 7.1 & 7.2 (Req 3.1–3.7).
2//!
3//! # Responsibilities
4//!
5//! - [`CoverageEngine`] — stateless engine that builds the full coverage matrix
6//!   from canonical asset lists and workspace scan results.
7//! - [`CoverageEngine::build_matrix`] — constructs a [`CoverageMatrix`] with
8//!   per-cell classification and per-workspace coverage scores.
9//! - [`CoverageEngine::compute_coverage_score`] — (installed matching canonical)
10//!   / (total applicable) × 100, round-half-up; `None` when no applicable assets
11//!   (Req 3.5).
12//! - [`CoverageEngine::compute_freshness_score`] — delegates to [`freshness_score`]
13//!   from `versions.rs` (Req 8.3).
14//!
15//! # Cell classification (Req 3.x, 10.1)
16//!
17//! | Asset present? | Hash match? | Version behind? | `CellStatus`   |
18//! |---------------|-------------|-----------------|----------------|
19//! | No             | —           | —               | `NotInstalled` |
20//! | Yes            | Yes         | (any)           | `Installed`    |
21//! | Yes            | No          | No              | `Drifted`      |
22//! | Yes            | No          | Yes             | `Drifted`      |
23//! | Yes            | No (match)  | Yes (outdated)  | `Outdated`     |
24//!
25//! More precisely: when the installed asset is **present**:
26//! 1. If `content_hash == canonical_hash` → `Installed` (current).
27//! 2. Else if `content_hash != canonical_hash` AND the installed version is
28//!    **behind** the canonical version (per [`compare_versions`]) → `Outdated`
29//!    (intentional update, just not to the latest canonical content).
30//!    NOTE: Per the spec (Req 3.3 "version differs from canonical → outdated",
31//!    Req 3.4 "hash differs → drifted"), `Drifted` takes precedence for hash
32//!    mismatches where the version is NOT simply behind.
33//!
34//! The implementation uses the following priority order:
35//! 1. No asset present → `NotInstalled`.
36//! 2. Hash match → `Installed`.
37//! 3. Hash mismatch + version behind (installed < canonical semver) → `Outdated`.
38//! 4. Hash mismatch otherwise → `Drifted`.
39//!
40//! This matches the spec intent: `Outdated` is the "known update available"
41//! state (version is simply behind), while `Drifted` covers all other hash
42//! mismatches (local modification, unknown version, etc.).
43
44#![deny(warnings)]
45
46use std::collections::HashMap;
47use std::path::PathBuf;
48
49use crate::federation::scanner::InstalledAsset;
50use crate::federation::versions::{compare_versions, freshness_score, round_half_up_1dp};
51use crate::models::coverage::{AssetType, CellStatus, CoverageCell, CoverageMatrix, CoverageRow};
52use crate::models::provider::Provider;
53use crate::persistence::writer::{DbCommand, WriterHandle};
54
55/// Persist per-workspace coverage scores to `coverage_cache` via the
56/// single-writer task (Req 3.6).
57///
58/// `scores` is a slice of `(workspace_path, workspace_name, coverage_score)`.
59/// Best-effort: writer-channel errors are ignored.
60pub async fn persist_coverage_scores(
61    tx: &WriterHandle,
62    scores: &[(String, String, f64)],
63    computed_at: &str,
64) {
65    for (workspace_path, workspace_name, coverage_score) in scores {
66        let _ = tx
67            .send(DbCommand::RecordCoverageScore {
68                workspace_path: workspace_path.clone(),
69                workspace_name: workspace_name.clone(),
70                coverage_score: *coverage_score,
71                computed_at: computed_at.to_string(),
72            })
73            .await;
74    }
75}
76
77// ---------------------------------------------------------------------------
78// CoverageEngine
79// ---------------------------------------------------------------------------
80
81/// Stateless engine for building and scoring the coverage matrix.
82///
83/// All methods are pure functions — no internal state is mutated.  The struct
84/// exists as an organisational namespace.
85pub struct CoverageEngine;
86
87impl CoverageEngine {
88    // -----------------------------------------------------------------------
89    // build_matrix
90    // -----------------------------------------------------------------------
91
92    /// Build the full coverage matrix from canonical assets and scan results.
93    ///
94    /// # Arguments
95    ///
96    /// - `canonical_asset_ids` — ordered list of all catalog asset identifiers.
97    ///   Each entry becomes one row in the matrix.
98    /// - `workspaces` — list of `(workspace_name, installed_assets)` pairs.
99    ///   Each pair becomes one column and contributes cells for each asset.
100    /// - `canonical_hashes` — map from `asset_id` → expected SHA-256 hex
101    ///   (from `asset-integrity.json`).
102    /// - `canonical_versions` — map from `asset_id` → version string (from
103    ///   catalog).
104    ///
105    /// The matrix rows are ordered by `canonical_asset_ids`, and columns are
106    /// ordered by the order of `workspaces`.  Coverage scores are computed for
107    /// each workspace and stored in `workspace_scores`.
108    pub fn build_matrix(
109        canonical_asset_ids: &[String],
110        workspaces: &[(PathBuf, Vec<InstalledAsset>)],
111        canonical_hashes: &HashMap<String, String>,
112        canonical_versions: &HashMap<String, String>,
113    ) -> CoverageMatrix {
114        // Build an index from (workspace_path, asset_id) → InstalledAsset for
115        // fast lookup.  We use only confirmed assets to avoid false positives.
116        let mut install_index: HashMap<(PathBuf, String), &InstalledAsset> = HashMap::new();
117        for (ws_path, assets) in workspaces {
118            for asset in assets {
119                if asset.confirmed {
120                    install_index.insert((ws_path.clone(), asset.asset_id.clone()), asset);
121                }
122            }
123        }
124
125        // Build workspace names from paths (use the path's last component as the
126        // display name if available, otherwise the full display string).
127        let workspace_names: Vec<String> = workspaces
128            .iter()
129            .map(|(path, _)| {
130                path.file_name()
131                    .and_then(|n| n.to_str())
132                    .map(|s| s.to_string())
133                    .unwrap_or_else(|| path.display().to_string())
134            })
135            .collect();
136
137        // Build rows from canonical asset IDs.
138        let rows: Vec<CoverageRow> = canonical_asset_ids
139            .iter()
140            .map(|id| CoverageRow {
141                asset_id: id.clone(),
142                asset_type: infer_asset_type(id),
143                asset_name: asset_display_name(id),
144                provider: infer_provider(id),
145            })
146            .collect();
147
148        // Fill cells and track per-workspace counts.
149        let mut cells: HashMap<(String, String), CoverageCell> = HashMap::new();
150
151        // Per-workspace counters: (installed_matching, total_applicable)
152        let mut ws_installed: Vec<usize> = vec![0; workspaces.len()];
153        let mut ws_total: Vec<usize> = vec![0; workspaces.len()];
154
155        for asset_id in canonical_asset_ids {
156            let canonical_hash = canonical_hashes.get(asset_id).map(|s| s.as_str());
157            let canonical_version = canonical_versions.get(asset_id).map(|s| s.as_str());
158
159            for (ws_idx, (ws_path, _)) in workspaces.iter().enumerate() {
160                let ws_name = &workspace_names[ws_idx];
161
162                ws_total[ws_idx] += 1; // this asset is applicable
163
164                let cell =
165                    if let Some(asset) = install_index.get(&(ws_path.clone(), asset_id.clone())) {
166                        let status = classify_cell_status(asset, canonical_hash, canonical_version);
167                        if status == CellStatus::Installed {
168                            ws_installed[ws_idx] += 1;
169                        }
170                        CoverageCell {
171                            status,
172                            installed_version: asset.installed_version.clone(),
173                            canonical_version: canonical_version.unwrap_or("").to_string(),
174                            installed_hash: Some(asset.content_hash.clone()),
175                            canonical_hash: canonical_hash.map(|s| s.to_string()),
176                        }
177                    } else {
178                        CoverageCell {
179                            status: CellStatus::NotInstalled,
180                            installed_version: Option::None,
181                            canonical_version: canonical_version.unwrap_or("").to_string(),
182                            installed_hash: Option::None,
183                            canonical_hash: canonical_hash.map(|s| s.to_string()),
184                        }
185                    };
186
187                cells.insert((asset_id.clone(), ws_name.clone()), cell);
188            }
189        }
190
191        // Compute per-workspace coverage scores.
192        let mut workspace_scores: HashMap<String, f64> = HashMap::new();
193        for (ws_idx, ws_name) in workspace_names.iter().enumerate() {
194            if let Some(score) =
195                Self::compute_coverage_score(ws_installed[ws_idx], ws_total[ws_idx])
196            {
197                workspace_scores.insert(ws_name.clone(), score);
198            }
199            // If None (no applicable assets), we omit the workspace from scores (Req 3.5).
200        }
201
202        CoverageMatrix {
203            rows,
204            columns: workspace_names,
205            cells,
206            workspace_scores,
207        }
208    }
209
210    // -----------------------------------------------------------------------
211    // compute_coverage_score
212    // -----------------------------------------------------------------------
213
214    /// Compute the per-workspace coverage percentage (Req 3.5).
215    ///
216    /// Formula: `(installed_matching / total_applicable) × 100`, rounded half-up
217    /// to one decimal place.
218    ///
219    /// Returns `None` when `total_applicable == 0` (workspace has no applicable
220    /// canonical assets — display as "N/A" in the UI per Req 3.5).
221    pub fn compute_coverage_score(
222        installed_matching: usize,
223        total_applicable: usize,
224    ) -> Option<f64> {
225        if total_applicable == 0 {
226            return Option::None;
227        }
228        let ratio = installed_matching as f64 / total_applicable as f64;
229        Some(round_half_up_1dp(ratio * 100.0))
230    }
231
232    // -----------------------------------------------------------------------
233    // compute_freshness_score
234    // -----------------------------------------------------------------------
235
236    /// Compute the freshness score for a workspace (Req 8.3).
237    ///
238    /// Delegates to [`freshness_score`] from `versions.rs`.
239    ///
240    /// Returns `0.0` when `total_with_versions == 0`.
241    pub fn compute_freshness_score(current_count: usize, total_with_versions: usize) -> f64 {
242        freshness_score(current_count, total_with_versions)
243    }
244}
245
246// ---------------------------------------------------------------------------
247// classify_cell_status (private helper)
248// ---------------------------------------------------------------------------
249
250/// Classify the cell status for a single (installed, canonical) pair.
251///
252/// Priority order:
253/// 1. Hash match → `Installed`.
254/// 2. Hash mismatch + version behind (installed < canonical) → `Outdated`.
255/// 3. Hash mismatch otherwise → `Drifted`.
256fn classify_cell_status(
257    asset: &InstalledAsset,
258    canonical_hash: Option<&str>,
259    canonical_version: Option<&str>,
260) -> CellStatus {
261    // Step 1: hash match.
262    if let Some(chash) = canonical_hash {
263        if asset.content_hash == chash {
264            return CellStatus::Installed;
265        }
266    } else {
267        // No canonical hash — if hashes can't be compared, fall back to version.
268        // Without a canonical hash we cannot assert Installed or Drifted safely.
269        // Treat as Installed only if versions match; Outdated if version is behind.
270        if let (Some(iv), Some(cv)) = (asset.installed_version.as_deref(), canonical_version) {
271            if compare_versions(iv, cv) == std::cmp::Ordering::Less {
272                return CellStatus::Outdated;
273            }
274        }
275        return CellStatus::Installed;
276    }
277
278    // Step 2: hash mismatch — check if version is behind.
279    if let (Some(iv), Some(cv)) = (asset.installed_version.as_deref(), canonical_version) {
280        if compare_versions(iv, cv) == std::cmp::Ordering::Less {
281            return CellStatus::Outdated;
282        }
283    }
284
285    // Step 3: hash mismatch and version is not simply behind → Drifted.
286    CellStatus::Drifted
287}
288
289// ---------------------------------------------------------------------------
290// Private helpers — asset metadata inference
291// ---------------------------------------------------------------------------
292
293/// Infer [`AssetType`] from the asset ID prefix.
294fn infer_asset_type(asset_id: &str) -> AssetType {
295    if asset_id.starts_with("agents/") {
296        AssetType::Agent
297    } else if asset_id.starts_with("skills/") {
298        AssetType::Skill
299    } else if asset_id.starts_with("rules/") {
300        AssetType::Rule
301    } else if asset_id.starts_with("mcp/") {
302        AssetType::McpRef
303    } else {
304        AssetType::Agent // default
305    }
306}
307
308/// Extract a human-readable display name from an asset ID.
309///
310/// The display name is the last path component with hyphens replaced by spaces
311/// and each word capitalised.
312fn asset_display_name(asset_id: &str) -> String {
313    let last = asset_id.split('/').next_back().unwrap_or(asset_id);
314    last.split('-')
315        .map(|word| {
316            let mut c = word.chars();
317            match c.next() {
318                None => String::new(),
319                Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
320            }
321        })
322        .collect::<Vec<_>>()
323        .join(" ")
324}
325
326/// Infer a [`Provider`] from the asset ID.
327///
328/// Checks the second path component (e.g. `agents/aws/...` → `Provider::Aws`).
329fn infer_provider(asset_id: &str) -> Provider {
330    let parts: Vec<&str> = asset_id.split('/').collect();
331    if parts.len() >= 2 {
332        match parts[1].to_ascii_lowercase().as_str() {
333            "aws" => Provider::Aws,
334            "azure" => Provider::Azure,
335            "gcp" => Provider::Gcp,
336            "oracle" | "oci" => Provider::Oracle,
337            "kubernetes" | "k8s" => Provider::Kubernetes,
338            "terraform" => Provider::Terraform,
339            "generic" => Provider::Generic,
340            "frontend" => Provider::Frontend,
341            _ => Provider::Generic,
342        }
343    } else {
344        Provider::Generic
345    }
346}
347
348// ---------------------------------------------------------------------------
349// Tests
350// ---------------------------------------------------------------------------
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use crate::federation::scanner::DetectionMethod;
356    use proptest::prelude::*;
357
358    // -----------------------------------------------------------------------
359    // Test helpers
360    // -----------------------------------------------------------------------
361
362    fn make_confirmed_asset(
363        path: &str,
364        asset_id: &str,
365        content_hash: &str,
366        installed_version: Option<&str>,
367    ) -> InstalledAsset {
368        InstalledAsset {
369            workspace_path: PathBuf::from(path),
370            asset_id: asset_id.to_string(),
371            installed_version: installed_version.map(|s| s.to_string()),
372            content_hash: content_hash.to_string(),
373            detection_methods: vec![DetectionMethod::Filename, DetectionMethod::MetadataComment],
374            confirmed: true,
375            harness: ".claude".to_string(),
376        }
377    }
378
379    // -----------------------------------------------------------------------
380    // Unit tests — classify_cell_status
381    // -----------------------------------------------------------------------
382
383    #[test]
384    fn cell_installed_when_hash_matches() {
385        let asset = make_confirmed_asset("/ws/file.md", "agents/aws/cdk", "abc", Some("1.0.0"));
386        assert_eq!(
387            classify_cell_status(&asset, Some("abc"), Some("1.0.0")),
388            CellStatus::Installed
389        );
390    }
391
392    #[test]
393    fn cell_outdated_when_hash_mismatch_version_behind() {
394        let asset =
395            make_confirmed_asset("/ws/file.md", "agents/aws/cdk", "old_hash", Some("1.0.0"));
396        // canonical is 1.2.0 — installed is behind
397        assert_eq!(
398            classify_cell_status(&asset, Some("new_hash"), Some("1.2.0")),
399            CellStatus::Outdated
400        );
401    }
402
403    #[test]
404    fn cell_drifted_when_hash_mismatch_version_same() {
405        let asset = make_confirmed_asset("/ws/file.md", "agents/aws/cdk", "actual", Some("1.0.0"));
406        assert_eq!(
407            classify_cell_status(&asset, Some("expected"), Some("1.0.0")),
408            CellStatus::Drifted
409        );
410    }
411
412    #[test]
413    fn cell_drifted_when_hash_mismatch_no_version() {
414        let asset = make_confirmed_asset("/ws/file.md", "agents/aws/cdk", "actual", None);
415        assert_eq!(
416            classify_cell_status(&asset, Some("expected"), None),
417            CellStatus::Drifted
418        );
419    }
420
421    #[test]
422    fn cell_drifted_when_hash_mismatch_installed_ahead() {
423        // installed is 2.0.0, canonical is 1.0.0 — hash mismatch, not "behind"
424        let asset =
425            make_confirmed_asset("/ws/file.md", "agents/aws/cdk", "new_hash", Some("2.0.0"));
426        assert_eq!(
427            classify_cell_status(&asset, Some("old_hash"), Some("1.0.0")),
428            CellStatus::Drifted
429        );
430    }
431
432    // -----------------------------------------------------------------------
433    // Unit tests — compute_coverage_score
434    // -----------------------------------------------------------------------
435
436    #[test]
437    fn coverage_score_none_when_zero_applicable() {
438        assert_eq!(CoverageEngine::compute_coverage_score(0, 0), Option::None);
439    }
440
441    #[test]
442    fn coverage_score_100_when_all_installed() {
443        assert_eq!(CoverageEngine::compute_coverage_score(5, 5), Some(100.0));
444    }
445
446    #[test]
447    fn coverage_score_0_when_none_installed() {
448        assert_eq!(CoverageEngine::compute_coverage_score(0, 5), Some(0.0));
449    }
450
451    #[test]
452    fn coverage_score_half() {
453        assert_eq!(CoverageEngine::compute_coverage_score(5, 10), Some(50.0));
454    }
455
456    #[test]
457    fn coverage_score_rounding_half_up() {
458        // 1/3 = 33.333… → 33.3
459        let s = CoverageEngine::compute_coverage_score(1, 3).unwrap();
460        assert!((s - 33.3).abs() < 1e-9, "expected 33.3 got {s}");
461    }
462
463    #[test]
464    fn coverage_score_x5_boundary() {
465        // 3/20 = 0.15 × 100 = 15.0 — exact, not a boundary case
466        let s = CoverageEngine::compute_coverage_score(3, 20).unwrap();
467        assert!((s - 15.0).abs() < 1e-9, "expected 15.0 got {s}");
468    }
469
470    // -----------------------------------------------------------------------
471    // Unit tests — build_matrix
472    // -----------------------------------------------------------------------
473
474    #[test]
475    fn build_matrix_empty_inputs() {
476        let matrix = CoverageEngine::build_matrix(&[], &[], &HashMap::new(), &HashMap::new());
477        assert!(matrix.rows.is_empty());
478        assert!(matrix.columns.is_empty());
479        assert!(matrix.cells.is_empty());
480        assert!(matrix.workspace_scores.is_empty());
481    }
482
483    #[test]
484    fn build_matrix_not_installed() {
485        let assets: Vec<InstalledAsset> = vec![];
486        let ws_path = PathBuf::from("/workspaces/team-a");
487        let workspaces = vec![(ws_path, assets)];
488        let canonical_ids = vec!["agents/aws/cdk".to_string()];
489        let mut hashes = HashMap::new();
490        hashes.insert("agents/aws/cdk".to_string(), "abc123".to_string());
491
492        let matrix =
493            CoverageEngine::build_matrix(&canonical_ids, &workspaces, &hashes, &HashMap::new());
494
495        assert_eq!(matrix.rows.len(), 1);
496        assert_eq!(matrix.columns.len(), 1);
497        let cell = matrix
498            .cells
499            .get(&("agents/aws/cdk".to_string(), "team-a".to_string()))
500            .expect("cell should exist");
501        assert_eq!(cell.status, CellStatus::NotInstalled);
502        // Score should be 0.0 (0/1 applicable assets)
503        assert_eq!(matrix.workspace_scores.get("team-a"), Some(&0.0));
504    }
505
506    #[test]
507    fn build_matrix_installed_hash_match() {
508        let ws_path = PathBuf::from("/workspaces/team-a");
509        let asset = make_confirmed_asset(
510            "/workspaces/team-a/.claude/cdk-agent.md",
511            "agents/aws/cdk",
512            "abc123",
513            Some("1.0.0"),
514        );
515        let workspaces = vec![(ws_path, vec![asset])];
516        let canonical_ids = vec!["agents/aws/cdk".to_string()];
517        let mut hashes = HashMap::new();
518        hashes.insert("agents/aws/cdk".to_string(), "abc123".to_string());
519        let mut versions = HashMap::new();
520        versions.insert("agents/aws/cdk".to_string(), "1.0.0".to_string());
521
522        let matrix = CoverageEngine::build_matrix(&canonical_ids, &workspaces, &hashes, &versions);
523
524        let cell = matrix
525            .cells
526            .get(&("agents/aws/cdk".to_string(), "team-a".to_string()))
527            .expect("cell should exist");
528        assert_eq!(cell.status, CellStatus::Installed);
529        assert_eq!(matrix.workspace_scores.get("team-a"), Some(&100.0));
530    }
531
532    #[test]
533    fn build_matrix_outdated_version_behind() {
534        let ws_path = PathBuf::from("/workspaces/team-a");
535        let asset = make_confirmed_asset(
536            "/workspaces/team-a/.claude/cdk-agent.md",
537            "agents/aws/cdk",
538            "old_hash",
539            Some("1.0.0"),
540        );
541        let workspaces = vec![(ws_path, vec![asset])];
542        let canonical_ids = vec!["agents/aws/cdk".to_string()];
543        let mut hashes = HashMap::new();
544        hashes.insert("agents/aws/cdk".to_string(), "new_hash".to_string());
545        let mut versions = HashMap::new();
546        versions.insert("agents/aws/cdk".to_string(), "1.2.0".to_string());
547
548        let matrix = CoverageEngine::build_matrix(&canonical_ids, &workspaces, &hashes, &versions);
549
550        let cell = matrix
551            .cells
552            .get(&("agents/aws/cdk".to_string(), "team-a".to_string()))
553            .expect("cell should exist");
554        assert_eq!(cell.status, CellStatus::Outdated);
555        // Outdated does not count as "matching canonical" for coverage score
556        assert_eq!(matrix.workspace_scores.get("team-a"), Some(&0.0));
557    }
558
559    #[test]
560    fn build_matrix_drifted() {
561        let ws_path = PathBuf::from("/workspaces/team-a");
562        let asset = make_confirmed_asset(
563            "/workspaces/team-a/.claude/cdk-agent.md",
564            "agents/aws/cdk",
565            "modified_hash",
566            Some("1.0.0"),
567        );
568        let workspaces = vec![(ws_path, vec![asset])];
569        let canonical_ids = vec!["agents/aws/cdk".to_string()];
570        let mut hashes = HashMap::new();
571        hashes.insert("agents/aws/cdk".to_string(), "canonical_hash".to_string());
572        let mut versions = HashMap::new();
573        versions.insert("agents/aws/cdk".to_string(), "1.0.0".to_string());
574
575        let matrix = CoverageEngine::build_matrix(&canonical_ids, &workspaces, &hashes, &versions);
576
577        let cell = matrix
578            .cells
579            .get(&("agents/aws/cdk".to_string(), "team-a".to_string()))
580            .expect("cell should exist");
581        assert_eq!(cell.status, CellStatus::Drifted);
582        assert_eq!(matrix.workspace_scores.get("team-a"), Some(&0.0));
583    }
584
585    #[test]
586    fn build_matrix_no_applicable_assets_score_omitted() {
587        let ws_path = PathBuf::from("/workspaces/team-a");
588        let workspaces: Vec<(PathBuf, Vec<InstalledAsset>)> = vec![(ws_path, vec![])];
589        // No canonical assets at all
590        let matrix =
591            CoverageEngine::build_matrix(&[], &workspaces, &HashMap::new(), &HashMap::new());
592        // Score should be absent (None → not stored) per Req 3.5
593        assert!(
594            !matrix.workspace_scores.contains_key("team-a"),
595            "workspace with no applicable assets should not have a score"
596        );
597    }
598
599    #[test]
600    fn build_matrix_unconfirmed_assets_excluded() {
601        use crate::federation::scanner::DetectionMethod;
602        let ws_path = PathBuf::from("/workspaces/team-a");
603        let mut asset = make_confirmed_asset(
604            "/workspaces/team-a/.claude/cdk-agent.md",
605            "agents/aws/cdk",
606            "abc123",
607            Some("1.0.0"),
608        );
609        // Mark as unconfirmed
610        asset.confirmed = false;
611        asset.detection_methods = vec![DetectionMethod::Filename];
612
613        let workspaces = vec![(ws_path, vec![asset])];
614        let canonical_ids = vec!["agents/aws/cdk".to_string()];
615        let mut hashes = HashMap::new();
616        hashes.insert("agents/aws/cdk".to_string(), "abc123".to_string());
617
618        let matrix =
619            CoverageEngine::build_matrix(&canonical_ids, &workspaces, &hashes, &HashMap::new());
620
621        let cell = matrix
622            .cells
623            .get(&("agents/aws/cdk".to_string(), "team-a".to_string()))
624            .expect("cell should exist");
625        // Unconfirmed → treated as NotInstalled
626        assert_eq!(cell.status, CellStatus::NotInstalled);
627    }
628
629    // -----------------------------------------------------------------------
630    // Property 12 — Coverage matrix cell classification (Req 3.1, 3.4, 10.1)
631    //
632    // For any combination of hash match/mismatch and version comparison,
633    // the cell classification must be consistent and correct.
634    // -----------------------------------------------------------------------
635
636    prop_compose! {
637        fn arb_hash()(s in "[0-9a-f]{10}") -> String { s }
638    }
639
640    prop_compose! {
641        fn arb_version_str()(
642            major in 0u8..5,
643            minor in 0u8..10,
644            patch in 0u8..10,
645        ) -> String {
646            format!("{major}.{minor}.{patch}")
647        }
648    }
649
650    proptest! {
651        #![proptest_config(proptest::test_runner::Config {
652            cases: 256,
653            ..Default::default()
654        })]
655
656        /// Property 12a: cell is `Installed` iff hash matches canonical hash.
657        #[test]
658        fn prop12a_installed_iff_hash_matches(
659            hash in arb_hash(),
660            version in arb_version_str(),
661        ) {
662            let asset = make_confirmed_asset("/ws/file.md", "agents/test", &hash, Some(&version));
663            // Same hash → Installed
664            let status = classify_cell_status(&asset, Some(&hash), Some(&version));
665            prop_assert_eq!(status, CellStatus::Installed,
666                "hash match should be Installed");
667        }
668
669        /// Property 12b: cell is `Outdated` when hash mismatches and version is behind.
670        #[test]
671        fn prop12b_outdated_when_hash_mismatch_version_behind(
672            hash_installed in arb_hash(),
673            hash_canonical in arb_hash(),
674            imajor in 0u8..5,
675            iminor in 0u8..8,
676            ipatch in 0u8..10,
677            extra_minor in 1u8..5,
678        ) {
679            prop_assume!(hash_installed != hash_canonical);
680            let canonical_minor = iminor + extra_minor;
681            prop_assume!(canonical_minor < 30);
682
683            let iv = format!("{imajor}.{iminor}.{ipatch}");
684            let cv = format!("{imajor}.{canonical_minor}.{ipatch}");
685
686            let asset = make_confirmed_asset("/ws/file.md", "agents/test", &hash_installed, Some(&iv));
687            let status = classify_cell_status(&asset, Some(&hash_canonical), Some(&cv));
688            prop_assert_eq!(status, CellStatus::Outdated,
689                "hash mismatch + installed behind → Outdated");
690        }
691
692        /// Property 12c: cell is `Drifted` when hash mismatches and version is NOT behind.
693        #[test]
694        fn prop12c_drifted_when_hash_mismatch_version_not_behind(
695            hash_installed in arb_hash(),
696            hash_canonical in arb_hash(),
697            version in arb_version_str(),
698        ) {
699            prop_assume!(hash_installed != hash_canonical);
700            // Same version (not behind) → Drifted
701            let asset = make_confirmed_asset("/ws/file.md", "agents/test", &hash_installed, Some(&version));
702            let status = classify_cell_status(&asset, Some(&hash_canonical), Some(&version));
703            prop_assert_eq!(status, CellStatus::Drifted,
704                "hash mismatch + same version → Drifted");
705        }
706
707        /// Property 12d: `NotInstalled` cells have no installed hash.
708        /// Verified by checking the matrix output for absent assets.
709        #[test]
710        fn prop12d_absent_asset_is_not_installed(
711            hash in arb_hash(),
712            version in arb_version_str(),
713        ) {
714            let ws_path = PathBuf::from("/ws");
715            // No asset installed for this workspace
716            let workspaces: Vec<(PathBuf, Vec<InstalledAsset>)> = vec![(ws_path, vec![])];
717            let canonical_ids = vec!["agents/test".to_string()];
718            let mut hashes = HashMap::new();
719            hashes.insert("agents/test".to_string(), hash.clone());
720            let mut versions = HashMap::new();
721            versions.insert("agents/test".to_string(), version.clone());
722
723            let matrix = CoverageEngine::build_matrix(
724                &canonical_ids, &workspaces, &hashes, &versions,
725            );
726
727            let cell = matrix.cells.values().next().expect("should have a cell");
728            prop_assert_eq!(&cell.status, &CellStatus::NotInstalled);
729            prop_assert!(cell.installed_hash.is_none());
730        }
731    }
732
733    proptest! {
734        #![proptest_config(proptest::test_runner::Config {
735            cases: 256,
736            ..Default::default()
737        })]
738
739        // -----------------------------------------------------------------------
740        // Property 13 — Percentage score computation (Req 3.5, 8.3)
741        // -----------------------------------------------------------------------
742
743        /// Property 13a: coverage score is always in [0.0, 100.0] and None iff
744        /// total_applicable == 0.
745        #[test]
746        fn prop13a_coverage_score_range_and_none(
747            installed in 0usize..=500,
748            total in 0usize..=500,
749        ) {
750            let installed = installed.min(total); // can't exceed total
751            match CoverageEngine::compute_coverage_score(installed, total) {
752                Option::None => prop_assert_eq!(total, 0,
753                    "None should only be returned when total==0"),
754                Some(score) => {
755                    prop_assert!(total > 0, "Some score requires total > 0");
756                    prop_assert!((0.0..=100.0).contains(&score),
757                        "score {} out of range [0, 100]", score);
758                }
759            }
760        }
761
762        /// Property 13b: round-half-up correctness — values ending in .x5 round up.
763        /// Tests the rounding helper directly via compute_coverage_score.
764        #[test]
765        fn prop13b_round_half_up_x5_boundary(
766            n in 1usize..=200,
767        ) {
768            // For n/200 we can construct exact midpoints at .x5 if we pick n carefully.
769            // We just verify the result matches the round-half-up formula.
770            let ratio = n as f64 / 200_f64;
771            let expected = round_half_up_1dp(ratio * 100.0);
772            let got = CoverageEngine::compute_coverage_score(n, 200).unwrap();
773            prop_assert!(
774                (got - expected).abs() < 1e-9,
775                "compute_coverage_score({}, 200) = {} but round_half_up gives {}",
776                n, got, expected
777            );
778        }
779
780        /// Property 13c: freshness_score is always in [0.0, 100.0].
781        #[test]
782        fn prop13c_freshness_score_range(
783            current in 0usize..=1000,
784            total in 0usize..=1000,
785        ) {
786            let current = current.min(total);
787            let score = CoverageEngine::compute_freshness_score(current, total);
788            prop_assert!(
789                (0.0..=100.0).contains(&score),
790                "freshness_score({}, {}) = {} out of range",
791                current, total, score
792            );
793        }
794    }
795}