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