1#![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
55pub 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
77pub struct CoverageEngine;
86
87impl CoverageEngine {
88 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 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 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 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 let mut cells: HashMap<(String, String), CoverageCell> = HashMap::new();
150
151 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; 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 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 }
201
202 CoverageMatrix {
203 rows,
204 columns: workspace_names,
205 cells,
206 workspace_scores,
207 }
208 }
209
210 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 pub fn compute_freshness_score(current_count: usize, total_with_versions: usize) -> f64 {
242 freshness_score(current_count, total_with_versions)
243 }
244}
245
246fn classify_cell_status(
257 asset: &InstalledAsset,
258 canonical_hash: Option<&str>,
259 canonical_version: Option<&str>,
260) -> CellStatus {
261 if let Some(chash) = canonical_hash {
263 if asset.content_hash == chash {
264 return CellStatus::Installed;
265 }
266 } else {
267 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 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 CellStatus::Drifted
287}
288
289fn 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 }
306}
307
308fn 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
326fn 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#[cfg(test)]
353mod tests {
354 use super::*;
355 use crate::federation::scanner::DetectionMethod;
356 use proptest::prelude::*;
357
358 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 #[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 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 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 #[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 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 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 #[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 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 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 let matrix =
591 CoverageEngine::build_matrix(&[], &workspaces, &HashMap::new(), &HashMap::new());
592 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 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 assert_eq!(cell.status, CellStatus::NotInstalled);
627 }
628
629 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 #[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 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 #[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 #[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 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 #[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 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 #[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); 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 #[test]
765 fn prop13b_round_half_up_x5_boundary(
766 n in 1usize..=200,
767 ) {
768 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 #[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}