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 "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#[cfg(test)]
358mod tests {
359 use super::*;
360 use crate::federation::scanner::DetectionMethod;
361 use proptest::prelude::*;
362
363 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 #[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 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 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 #[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 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 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 #[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 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 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 let matrix =
596 CoverageEngine::build_matrix(&[], &workspaces, &HashMap::new(), &HashMap::new());
597 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 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 assert_eq!(cell.status, CellStatus::NotInstalled);
632 }
633
634 #[test]
639 fn infer_provider_maps_java_board() {
640 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 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 assert_eq!(
673 infer_provider("agents/not-a-real-provider/foo"),
674 Provider::Generic
675 );
676 }
677
678 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 #[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 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 #[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 #[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 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 #[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 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 #[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); 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 #[test]
814 fn prop13b_round_half_up_x5_boundary(
815 n in 1usize..=200,
816 ) {
817 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 #[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}