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            "java" => Provider::Java,
342            "kotlin" => Provider::Kotlin,
343            "php" => Provider::Php,
344            "python" => Provider::Python,
345            "typescript" => Provider::Typescript,
346            _ => Provider::Generic,
347        }
348    } else {
349        Provider::Generic
350    }
351}
352
353// ---------------------------------------------------------------------------
354// Tests
355// ---------------------------------------------------------------------------
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360    use crate::federation::scanner::DetectionMethod;
361    use proptest::prelude::*;
362
363    // -----------------------------------------------------------------------
364    // Test helpers
365    // -----------------------------------------------------------------------
366
367    fn make_confirmed_asset(
368        path: &str,
369        asset_id: &str,
370        content_hash: &str,
371        installed_version: Option<&str>,
372    ) -> InstalledAsset {
373        InstalledAsset {
374            workspace_path: PathBuf::from(path),
375            asset_id: asset_id.to_string(),
376            installed_version: installed_version.map(|s| s.to_string()),
377            content_hash: content_hash.to_string(),
378            detection_methods: vec![DetectionMethod::Filename, DetectionMethod::MetadataComment],
379            confirmed: true,
380            harness: ".claude".to_string(),
381        }
382    }
383
384    // -----------------------------------------------------------------------
385    // Unit tests — classify_cell_status
386    // -----------------------------------------------------------------------
387
388    #[test]
389    fn cell_installed_when_hash_matches() {
390        let asset = make_confirmed_asset("/ws/file.md", "agents/aws/cdk", "abc", Some("1.0.0"));
391        assert_eq!(
392            classify_cell_status(&asset, Some("abc"), Some("1.0.0")),
393            CellStatus::Installed
394        );
395    }
396
397    #[test]
398    fn cell_outdated_when_hash_mismatch_version_behind() {
399        let asset =
400            make_confirmed_asset("/ws/file.md", "agents/aws/cdk", "old_hash", Some("1.0.0"));
401        // canonical is 1.2.0 — installed is behind
402        assert_eq!(
403            classify_cell_status(&asset, Some("new_hash"), Some("1.2.0")),
404            CellStatus::Outdated
405        );
406    }
407
408    #[test]
409    fn cell_drifted_when_hash_mismatch_version_same() {
410        let asset = make_confirmed_asset("/ws/file.md", "agents/aws/cdk", "actual", Some("1.0.0"));
411        assert_eq!(
412            classify_cell_status(&asset, Some("expected"), Some("1.0.0")),
413            CellStatus::Drifted
414        );
415    }
416
417    #[test]
418    fn cell_drifted_when_hash_mismatch_no_version() {
419        let asset = make_confirmed_asset("/ws/file.md", "agents/aws/cdk", "actual", None);
420        assert_eq!(
421            classify_cell_status(&asset, Some("expected"), None),
422            CellStatus::Drifted
423        );
424    }
425
426    #[test]
427    fn cell_drifted_when_hash_mismatch_installed_ahead() {
428        // installed is 2.0.0, canonical is 1.0.0 — hash mismatch, not "behind"
429        let asset =
430            make_confirmed_asset("/ws/file.md", "agents/aws/cdk", "new_hash", Some("2.0.0"));
431        assert_eq!(
432            classify_cell_status(&asset, Some("old_hash"), Some("1.0.0")),
433            CellStatus::Drifted
434        );
435    }
436
437    // -----------------------------------------------------------------------
438    // Unit tests — compute_coverage_score
439    // -----------------------------------------------------------------------
440
441    #[test]
442    fn coverage_score_none_when_zero_applicable() {
443        assert_eq!(CoverageEngine::compute_coverage_score(0, 0), Option::None);
444    }
445
446    #[test]
447    fn coverage_score_100_when_all_installed() {
448        assert_eq!(CoverageEngine::compute_coverage_score(5, 5), Some(100.0));
449    }
450
451    #[test]
452    fn coverage_score_0_when_none_installed() {
453        assert_eq!(CoverageEngine::compute_coverage_score(0, 5), Some(0.0));
454    }
455
456    #[test]
457    fn coverage_score_half() {
458        assert_eq!(CoverageEngine::compute_coverage_score(5, 10), Some(50.0));
459    }
460
461    #[test]
462    fn coverage_score_rounding_half_up() {
463        // 1/3 = 33.333… → 33.3
464        let s = CoverageEngine::compute_coverage_score(1, 3).unwrap();
465        assert!((s - 33.3).abs() < 1e-9, "expected 33.3 got {s}");
466    }
467
468    #[test]
469    fn coverage_score_x5_boundary() {
470        // 3/20 = 0.15 × 100 = 15.0 — exact, not a boundary case
471        let s = CoverageEngine::compute_coverage_score(3, 20).unwrap();
472        assert!((s - 15.0).abs() < 1e-9, "expected 15.0 got {s}");
473    }
474
475    // -----------------------------------------------------------------------
476    // Unit tests — build_matrix
477    // -----------------------------------------------------------------------
478
479    #[test]
480    fn build_matrix_empty_inputs() {
481        let matrix = CoverageEngine::build_matrix(&[], &[], &HashMap::new(), &HashMap::new());
482        assert!(matrix.rows.is_empty());
483        assert!(matrix.columns.is_empty());
484        assert!(matrix.cells.is_empty());
485        assert!(matrix.workspace_scores.is_empty());
486    }
487
488    #[test]
489    fn build_matrix_not_installed() {
490        let assets: Vec<InstalledAsset> = vec![];
491        let ws_path = PathBuf::from("/workspaces/team-a");
492        let workspaces = vec![(ws_path, assets)];
493        let canonical_ids = vec!["agents/aws/cdk".to_string()];
494        let mut hashes = HashMap::new();
495        hashes.insert("agents/aws/cdk".to_string(), "abc123".to_string());
496
497        let matrix =
498            CoverageEngine::build_matrix(&canonical_ids, &workspaces, &hashes, &HashMap::new());
499
500        assert_eq!(matrix.rows.len(), 1);
501        assert_eq!(matrix.columns.len(), 1);
502        let cell = matrix
503            .cells
504            .get(&("agents/aws/cdk".to_string(), "team-a".to_string()))
505            .expect("cell should exist");
506        assert_eq!(cell.status, CellStatus::NotInstalled);
507        // Score should be 0.0 (0/1 applicable assets)
508        assert_eq!(matrix.workspace_scores.get("team-a"), Some(&0.0));
509    }
510
511    #[test]
512    fn build_matrix_installed_hash_match() {
513        let ws_path = PathBuf::from("/workspaces/team-a");
514        let asset = make_confirmed_asset(
515            "/workspaces/team-a/.claude/cdk-agent.md",
516            "agents/aws/cdk",
517            "abc123",
518            Some("1.0.0"),
519        );
520        let workspaces = vec![(ws_path, vec![asset])];
521        let canonical_ids = vec!["agents/aws/cdk".to_string()];
522        let mut hashes = HashMap::new();
523        hashes.insert("agents/aws/cdk".to_string(), "abc123".to_string());
524        let mut versions = HashMap::new();
525        versions.insert("agents/aws/cdk".to_string(), "1.0.0".to_string());
526
527        let matrix = CoverageEngine::build_matrix(&canonical_ids, &workspaces, &hashes, &versions);
528
529        let cell = matrix
530            .cells
531            .get(&("agents/aws/cdk".to_string(), "team-a".to_string()))
532            .expect("cell should exist");
533        assert_eq!(cell.status, CellStatus::Installed);
534        assert_eq!(matrix.workspace_scores.get("team-a"), Some(&100.0));
535    }
536
537    #[test]
538    fn build_matrix_outdated_version_behind() {
539        let ws_path = PathBuf::from("/workspaces/team-a");
540        let asset = make_confirmed_asset(
541            "/workspaces/team-a/.claude/cdk-agent.md",
542            "agents/aws/cdk",
543            "old_hash",
544            Some("1.0.0"),
545        );
546        let workspaces = vec![(ws_path, vec![asset])];
547        let canonical_ids = vec!["agents/aws/cdk".to_string()];
548        let mut hashes = HashMap::new();
549        hashes.insert("agents/aws/cdk".to_string(), "new_hash".to_string());
550        let mut versions = HashMap::new();
551        versions.insert("agents/aws/cdk".to_string(), "1.2.0".to_string());
552
553        let matrix = CoverageEngine::build_matrix(&canonical_ids, &workspaces, &hashes, &versions);
554
555        let cell = matrix
556            .cells
557            .get(&("agents/aws/cdk".to_string(), "team-a".to_string()))
558            .expect("cell should exist");
559        assert_eq!(cell.status, CellStatus::Outdated);
560        // Outdated does not count as "matching canonical" for coverage score
561        assert_eq!(matrix.workspace_scores.get("team-a"), Some(&0.0));
562    }
563
564    #[test]
565    fn build_matrix_drifted() {
566        let ws_path = PathBuf::from("/workspaces/team-a");
567        let asset = make_confirmed_asset(
568            "/workspaces/team-a/.claude/cdk-agent.md",
569            "agents/aws/cdk",
570            "modified_hash",
571            Some("1.0.0"),
572        );
573        let workspaces = vec![(ws_path, vec![asset])];
574        let canonical_ids = vec!["agents/aws/cdk".to_string()];
575        let mut hashes = HashMap::new();
576        hashes.insert("agents/aws/cdk".to_string(), "canonical_hash".to_string());
577        let mut versions = HashMap::new();
578        versions.insert("agents/aws/cdk".to_string(), "1.0.0".to_string());
579
580        let matrix = CoverageEngine::build_matrix(&canonical_ids, &workspaces, &hashes, &versions);
581
582        let cell = matrix
583            .cells
584            .get(&("agents/aws/cdk".to_string(), "team-a".to_string()))
585            .expect("cell should exist");
586        assert_eq!(cell.status, CellStatus::Drifted);
587        assert_eq!(matrix.workspace_scores.get("team-a"), Some(&0.0));
588    }
589
590    #[test]
591    fn build_matrix_no_applicable_assets_score_omitted() {
592        let ws_path = PathBuf::from("/workspaces/team-a");
593        let workspaces: Vec<(PathBuf, Vec<InstalledAsset>)> = vec![(ws_path, vec![])];
594        // No canonical assets at all
595        let matrix =
596            CoverageEngine::build_matrix(&[], &workspaces, &HashMap::new(), &HashMap::new());
597        // Score should be absent (None → not stored) per Req 3.5
598        assert!(
599            !matrix.workspace_scores.contains_key("team-a"),
600            "workspace with no applicable assets should not have a score"
601        );
602    }
603
604    #[test]
605    fn build_matrix_unconfirmed_assets_excluded() {
606        use crate::federation::scanner::DetectionMethod;
607        let ws_path = PathBuf::from("/workspaces/team-a");
608        let mut asset = make_confirmed_asset(
609            "/workspaces/team-a/.claude/cdk-agent.md",
610            "agents/aws/cdk",
611            "abc123",
612            Some("1.0.0"),
613        );
614        // Mark as unconfirmed
615        asset.confirmed = false;
616        asset.detection_methods = vec![DetectionMethod::Filename];
617
618        let workspaces = vec![(ws_path, vec![asset])];
619        let canonical_ids = vec!["agents/aws/cdk".to_string()];
620        let mut hashes = HashMap::new();
621        hashes.insert("agents/aws/cdk".to_string(), "abc123".to_string());
622
623        let matrix =
624            CoverageEngine::build_matrix(&canonical_ids, &workspaces, &hashes, &HashMap::new());
625
626        let cell = matrix
627            .cells
628            .get(&("agents/aws/cdk".to_string(), "team-a".to_string()))
629            .expect("cell should exist");
630        // Unconfirmed → treated as NotInstalled
631        assert_eq!(cell.status, CellStatus::NotInstalled);
632    }
633
634    // -----------------------------------------------------------------------
635    // Unit tests — infer_provider (path → Provider mapping)
636    // -----------------------------------------------------------------------
637
638    #[test]
639    fn infer_provider_maps_java_board() {
640        // Java board assets must classify as Java, not fall through to Generic,
641        // so coverage rows render and filter under the correct provider.
642        assert_eq!(
643            infer_provider("agents/java/jdk-lifecycle-and-upgrade"),
644            Provider::Java
645        );
646        assert_eq!(
647            infer_provider("skills/java/java-jdk-lifecycle-and-upgrade"),
648            Provider::Java
649        );
650    }
651
652    #[test]
653    fn infer_provider_maps_typescript_board() {
654        // The `typescript` provider is registered in two places: the strict serde
655        // enum in models::provider (which fails loudly on a missing variant when the
656        // catalog is deserialized) and this path mapping (which fails silently —
657        // an unmapped component falls through to Generic below, so the whole board
658        // would render and group as Generic with every gate still green).
659        assert_eq!(
660            infer_provider("agents/typescript/typescript-maestro-agent"),
661            Provider::Typescript
662        );
663        assert_eq!(
664            infer_provider("skills/typescript/typescript-runtime-boundary-contract"),
665            Provider::Typescript
666        );
667    }
668
669    #[test]
670    fn infer_provider_unknown_falls_back_to_generic() {
671        // An unmapped second path component still falls back to Generic.
672        assert_eq!(
673            infer_provider("agents/not-a-real-provider/foo"),
674            Provider::Generic
675        );
676    }
677
678    // -----------------------------------------------------------------------
679    // Property 12 — Coverage matrix cell classification (Req 3.1, 3.4, 10.1)
680    //
681    // For any combination of hash match/mismatch and version comparison,
682    // the cell classification must be consistent and correct.
683    // -----------------------------------------------------------------------
684
685    prop_compose! {
686        fn arb_hash()(s in "[0-9a-f]{10}") -> String { s }
687    }
688
689    prop_compose! {
690        fn arb_version_str()(
691            major in 0u8..5,
692            minor in 0u8..10,
693            patch in 0u8..10,
694        ) -> String {
695            format!("{major}.{minor}.{patch}")
696        }
697    }
698
699    proptest! {
700        #![proptest_config(proptest::test_runner::Config {
701            cases: 256,
702            ..Default::default()
703        })]
704
705        /// Property 12a: cell is `Installed` iff hash matches canonical hash.
706        #[test]
707        fn prop12a_installed_iff_hash_matches(
708            hash in arb_hash(),
709            version in arb_version_str(),
710        ) {
711            let asset = make_confirmed_asset("/ws/file.md", "agents/test", &hash, Some(&version));
712            // Same hash → Installed
713            let status = classify_cell_status(&asset, Some(&hash), Some(&version));
714            prop_assert_eq!(status, CellStatus::Installed,
715                "hash match should be Installed");
716        }
717
718        /// Property 12b: cell is `Outdated` when hash mismatches and version is behind.
719        #[test]
720        fn prop12b_outdated_when_hash_mismatch_version_behind(
721            hash_installed in arb_hash(),
722            hash_canonical in arb_hash(),
723            imajor in 0u8..5,
724            iminor in 0u8..8,
725            ipatch in 0u8..10,
726            extra_minor in 1u8..5,
727        ) {
728            prop_assume!(hash_installed != hash_canonical);
729            let canonical_minor = iminor + extra_minor;
730            prop_assume!(canonical_minor < 30);
731
732            let iv = format!("{imajor}.{iminor}.{ipatch}");
733            let cv = format!("{imajor}.{canonical_minor}.{ipatch}");
734
735            let asset = make_confirmed_asset("/ws/file.md", "agents/test", &hash_installed, Some(&iv));
736            let status = classify_cell_status(&asset, Some(&hash_canonical), Some(&cv));
737            prop_assert_eq!(status, CellStatus::Outdated,
738                "hash mismatch + installed behind → Outdated");
739        }
740
741        /// Property 12c: cell is `Drifted` when hash mismatches and version is NOT behind.
742        #[test]
743        fn prop12c_drifted_when_hash_mismatch_version_not_behind(
744            hash_installed in arb_hash(),
745            hash_canonical in arb_hash(),
746            version in arb_version_str(),
747        ) {
748            prop_assume!(hash_installed != hash_canonical);
749            // Same version (not behind) → Drifted
750            let asset = make_confirmed_asset("/ws/file.md", "agents/test", &hash_installed, Some(&version));
751            let status = classify_cell_status(&asset, Some(&hash_canonical), Some(&version));
752            prop_assert_eq!(status, CellStatus::Drifted,
753                "hash mismatch + same version → Drifted");
754        }
755
756        /// Property 12d: `NotInstalled` cells have no installed hash.
757        /// Verified by checking the matrix output for absent assets.
758        #[test]
759        fn prop12d_absent_asset_is_not_installed(
760            hash in arb_hash(),
761            version in arb_version_str(),
762        ) {
763            let ws_path = PathBuf::from("/ws");
764            // No asset installed for this workspace
765            let workspaces: Vec<(PathBuf, Vec<InstalledAsset>)> = vec![(ws_path, vec![])];
766            let canonical_ids = vec!["agents/test".to_string()];
767            let mut hashes = HashMap::new();
768            hashes.insert("agents/test".to_string(), hash.clone());
769            let mut versions = HashMap::new();
770            versions.insert("agents/test".to_string(), version.clone());
771
772            let matrix = CoverageEngine::build_matrix(
773                &canonical_ids, &workspaces, &hashes, &versions,
774            );
775
776            let cell = matrix.cells.values().next().expect("should have a cell");
777            prop_assert_eq!(&cell.status, &CellStatus::NotInstalled);
778            prop_assert!(cell.installed_hash.is_none());
779        }
780    }
781
782    proptest! {
783        #![proptest_config(proptest::test_runner::Config {
784            cases: 256,
785            ..Default::default()
786        })]
787
788        // -----------------------------------------------------------------------
789        // Property 13 — Percentage score computation (Req 3.5, 8.3)
790        // -----------------------------------------------------------------------
791
792        /// Property 13a: coverage score is always in [0.0, 100.0] and None iff
793        /// total_applicable == 0.
794        #[test]
795        fn prop13a_coverage_score_range_and_none(
796            installed in 0usize..=500,
797            total in 0usize..=500,
798        ) {
799            let installed = installed.min(total); // can't exceed total
800            match CoverageEngine::compute_coverage_score(installed, total) {
801                Option::None => prop_assert_eq!(total, 0,
802                    "None should only be returned when total==0"),
803                Some(score) => {
804                    prop_assert!(total > 0, "Some score requires total > 0");
805                    prop_assert!((0.0..=100.0).contains(&score),
806                        "score {} out of range [0, 100]", score);
807                }
808            }
809        }
810
811        /// Property 13b: round-half-up correctness — values ending in .x5 round up.
812        /// Tests the rounding helper directly via compute_coverage_score.
813        #[test]
814        fn prop13b_round_half_up_x5_boundary(
815            n in 1usize..=200,
816        ) {
817            // For n/200 we can construct exact midpoints at .x5 if we pick n carefully.
818            // We just verify the result matches the round-half-up formula.
819            let ratio = n as f64 / 200_f64;
820            let expected = round_half_up_1dp(ratio * 100.0);
821            let got = CoverageEngine::compute_coverage_score(n, 200).unwrap();
822            prop_assert!(
823                (got - expected).abs() < 1e-9,
824                "compute_coverage_score({}, 200) = {} but round_half_up gives {}",
825                n, got, expected
826            );
827        }
828
829        /// Property 13c: freshness_score is always in [0.0, 100.0].
830        #[test]
831        fn prop13c_freshness_score_range(
832            current in 0usize..=1000,
833            total in 0usize..=1000,
834        ) {
835            let current = current.min(total);
836            let score = CoverageEngine::compute_freshness_score(current, total);
837            prop_assert!(
838                (0.0..=100.0).contains(&score),
839                "freshness_score({}, {}) = {} out of range",
840                current, total, score
841            );
842        }
843    }
844}