vfa_tui/federation/scanner.rs
1//! Workspace scanner — multi-strategy installed-asset detection.
2//!
3//! # Design overview
4//!
5//! [`WorkspaceScanner`] walks each workspace's harness directories and applies
6//! three detection strategies per file (Req 7.2):
7//!
8//! (a) **Filename** — the file's basename matches a canonical asset `path`
9//! basename AND the harness-directory layout is valid for that harness.
10//! (b) **MetadataComment** — the first lines contain a `# VFA-EXPORT: {json}`
11//! comment with a parseable asset `id` (Req 7.7).
12//! (c) **ContentSignature** — a conservative line-overlap ratio between the
13//! file's first 50 lines and the canonical template's first 50 lines
14//! exceeds a threshold (Req 7.2 / 7.3).
15//!
16//! An asset is "confirmed installed" when **at least two** distinct strategies
17//! agree (Req 7.2). A SHA-256 hash is computed for every matched file (Req 7.3).
18//!
19//! Scanning is parallelised via tokio tasks bounded by a [`tokio::sync::Semaphore`]
20//! at the configured concurrency limit (default 8, Req 23.1).
21
22#![deny(warnings)]
23
24use std::collections::HashMap;
25use std::path::{Path, PathBuf};
26use std::sync::Arc;
27
28use serde::{Deserialize, Serialize};
29use sha2::{Digest, Sha256};
30use tokio::sync::Semaphore;
31use tracing::warn;
32
33use crate::models::workspace::ResolvedWorkspace;
34use crate::workspace::harness_layout::{
35 detect_harness_dirs, validate_harness_layout, HarnessDir, LayoutMatch,
36};
37
38// ---------------------------------------------------------------------------
39// DetectionMethod
40// ---------------------------------------------------------------------------
41
42/// Which strategy triggered a match for an installed file.
43#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum DetectionMethod {
46 /// File basename matches a canonical asset path basename, and the harness
47 /// layout validates (Req 7.2 strategy a).
48 Filename,
49 /// File contains a `# VFA-EXPORT: {json}` metadata comment (Req 7.7).
50 MetadataComment,
51 /// First-50-line content signature overlaps with a canonical template
52 /// above the similarity threshold (Req 7.2 strategy c).
53 ContentSignature,
54}
55
56// ---------------------------------------------------------------------------
57// ExportMeta — parsed VFA-EXPORT header
58// ---------------------------------------------------------------------------
59
60/// Parsed content of a `# VFA-EXPORT: {json}` metadata line.
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
62pub struct ExportMeta {
63 /// Asset identifier (required).
64 pub id: String,
65 /// Installed version, if present.
66 pub version: Option<String>,
67 /// ISO timestamp written by the export CLI, if present.
68 pub installed_at: Option<String>,
69}
70
71// ---------------------------------------------------------------------------
72// InstalledAsset
73// ---------------------------------------------------------------------------
74
75/// A single asset detected as installed in a workspace.
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77pub struct InstalledAsset {
78 /// Absolute path to the installed file inside the workspace.
79 pub workspace_path: PathBuf,
80 /// Canonical asset identifier (e.g. `"agents/aws/cdk-agent"`).
81 pub asset_id: String,
82 /// Version extracted via VFA-EXPORT metadata or frontmatter, if any.
83 pub installed_version: Option<String>,
84 /// SHA-256 hex digest of the file bytes.
85 pub content_hash: String,
86 /// Which strategies fired for this file.
87 pub detection_methods: Vec<DetectionMethod>,
88 /// `true` when ≥2 distinct strategies agreed (Req 7.2).
89 pub confirmed: bool,
90 /// Harness directory name (e.g. `".claude"`).
91 pub harness: String,
92}
93
94// ---------------------------------------------------------------------------
95// Confirmation rule (pure helper — property-testable)
96// ---------------------------------------------------------------------------
97
98/// Returns `true` when `methods` contains at least two **distinct** detection
99/// signals, satisfying the "≥2 confirming strategies" rule (Req 7.2).
100///
101/// Duplicates are collapsed before counting so that accidentally applying the
102/// same strategy twice does not inflate the count.
103pub fn is_confirmed(methods: &[DetectionMethod]) -> bool {
104 // Collect unique strategies.
105 let unique: std::collections::HashSet<_> = methods.iter().collect();
106 unique.len() >= 2
107}
108
109// ---------------------------------------------------------------------------
110// CatalogIndex — lightweight precomputed lookup structure
111// ---------------------------------------------------------------------------
112
113/// Precomputed index derived from a [`crate::catalog::store::CatalogStore`]
114/// snapshot so the scanner can work with owned data inside spawned tasks.
115#[derive(Debug, Clone)]
116pub struct CatalogIndex {
117 /// Map from file basename (e.g. `"cdk-agent.md"`) → canonical asset ID.
118 pub basename_to_id: HashMap<String, String>,
119 /// Map from canonical asset ID → first-50-lines of canonical content
120 /// (when the canonical file is readable on disk).
121 pub id_to_template: HashMap<String, String>,
122}
123
124impl CatalogIndex {
125 /// Build a [`CatalogIndex`] from parallel slices of (asset_path, asset_id,
126 /// optional_canonical_content).
127 ///
128 /// `asset_path` is the catalog `path` field (e.g. `"agents/aws/cdk-agent"`);
129 /// the basename used for filename matching is its last path component with
130 /// any supported extension appended for each harness.
131 pub fn new(entries: impl IntoIterator<Item = (String, String, Option<String>)>) -> Self {
132 let mut basename_to_id = HashMap::new();
133 let mut id_to_template = HashMap::new();
134
135 for (path, id, maybe_content) in entries {
136 // Extract basename of the canonical path (without extension).
137 let stem = PathBuf::from(&path)
138 .file_stem()
139 .and_then(|s| s.to_str())
140 .unwrap_or(&path)
141 .to_string();
142
143 // Register with several extensions so all harness types can match.
144 for ext in &[".md", ".json", ".toml", ".yaml", ".yml"] {
145 let key = format!("{stem}{ext}");
146 basename_to_id.entry(key).or_insert_with(|| id.clone());
147 }
148 // Also register the bare stem (no extension).
149 basename_to_id
150 .entry(stem.clone())
151 .or_insert_with(|| id.clone());
152
153 if let Some(content) = maybe_content {
154 id_to_template.insert(id, first_n_lines(&content, 50).to_string());
155 }
156 }
157
158 Self {
159 basename_to_id,
160 id_to_template,
161 }
162 }
163}
164
165// ---------------------------------------------------------------------------
166// WorkspaceScanner
167// ---------------------------------------------------------------------------
168
169/// Scans downstream workspaces for installed catalog assets.
170///
171/// Construct with [`WorkspaceScanner::new`]. The scanner is `Clone + Send`
172/// and safe to share across tokio tasks via `Arc`.
173#[derive(Debug, Clone)]
174pub struct WorkspaceScanner {
175 /// Maximum number of workspaces scanned concurrently.
176 pub concurrency: usize,
177}
178
179impl WorkspaceScanner {
180 /// Create a scanner with the given concurrency limit.
181 ///
182 /// `concurrency` controls how many workspace scans run simultaneously via
183 /// tokio tasks. The default recommended value is `8` (Req 23.1).
184 pub fn new(concurrency: usize) -> Self {
185 Self {
186 concurrency: concurrency.max(1),
187 }
188 }
189
190 // -----------------------------------------------------------------------
191 // parse_export_metadata
192 // -----------------------------------------------------------------------
193
194 /// Parse the `# VFA-EXPORT: {json}` metadata comment format (Req 7.7).
195 ///
196 /// Searches `content` line-by-line (up to 20 lines) for a line whose
197 /// trimmed form starts with `# VFA-EXPORT:` (case-sensitive). The JSON
198 /// payload after the colon is parsed with `serde_json`.
199 ///
200 /// Returns `None` for any malformed or missing line without panicking.
201 pub fn parse_export_metadata(content: &str) -> Option<ExportMeta> {
202 const PREFIX: &str = "VFA-EXPORT:";
203 for line in content.lines().take(20) {
204 let trimmed = line.trim();
205 // Accept `# VFA-EXPORT:` and `// VFA-EXPORT:` variants.
206 let after_hash = if let Some(rest) = trimmed.strip_prefix('#') {
207 rest.trim_start()
208 } else if let Some(rest) = trimmed.strip_prefix("//") {
209 rest.trim_start()
210 } else {
211 continue;
212 };
213 if let Some(json_payload) = after_hash.strip_prefix(PREFIX) {
214 let json_payload = json_payload.trim();
215 return serde_json::from_str::<ExportMeta>(json_payload).ok();
216 }
217 }
218 None
219 }
220
221 // -----------------------------------------------------------------------
222 // match_content_signature
223 // -----------------------------------------------------------------------
224
225 /// Conservative content-similarity heuristic for the first 50 lines.
226 ///
227 /// **Heuristic:** normalise both sides (trim whitespace, lowercase), split
228 /// into a set of non-empty lines, and compute the Jaccard-like overlap:
229 ///
230 /// ```text
231 /// overlap = |file_lines ∩ template_lines| / |template_lines|
232 /// ```
233 ///
234 /// Returns `true` when `overlap >= 0.40` AND both sides have at least 3
235 /// non-empty lines. This threshold is deliberately conservative to avoid
236 /// false positives on short boilerplate. Callers should treat this as a
237 /// supporting signal only; confirmation requires a second agreeing strategy.
238 pub fn match_content_signature(first_50_lines: &str, canonical_first_50: &str) -> bool {
239 let file_set: std::collections::HashSet<String> = first_50_lines
240 .lines()
241 .map(|l| l.trim().to_lowercase())
242 .filter(|l| !l.is_empty())
243 .collect();
244
245 let template_set: std::collections::HashSet<String> = canonical_first_50
246 .lines()
247 .map(|l| l.trim().to_lowercase())
248 .filter(|l| !l.is_empty())
249 .collect();
250
251 if file_set.len() < 3 || template_set.len() < 3 {
252 return false;
253 }
254
255 let overlap = file_set.intersection(&template_set).count();
256 let ratio = overlap as f64 / template_set.len() as f64;
257 ratio >= 0.40
258 }
259
260 // -----------------------------------------------------------------------
261 // scan_harness_dir
262 // -----------------------------------------------------------------------
263
264 /// Scan a single harness directory and return detected assets.
265 ///
266 /// Per-harness file-extension filters (Req 7.8):
267 ///
268 /// | Harness | Extensions scanned |
269 /// |-----------|-------------------------------|
270 /// | Claude | `*.md` |
271 /// | Cursor | `*.json` |
272 /// | Kiro | `*.md` |
273 /// | Codex | `plugin.json` only |
274 /// | Opencode | `*.toml`, `*.yaml`, `*.yml` |
275 ///
276 /// Validates the layout before walking (Req 7.6); returns empty vec with a
277 /// `warn!` if the layout does not match any known pattern.
278 pub fn scan_harness_dir(
279 &self,
280 _workspace_path: &Path,
281 harness: &HarnessDir,
282 dir: &Path,
283 index: &CatalogIndex,
284 ) -> Vec<InstalledAsset> {
285 // Validate layout first (Req 7.6).
286 if validate_harness_layout(harness, dir) == LayoutMatch::NoMatch {
287 warn!(
288 harness = harness.dir_name(),
289 path = %dir.display(),
290 "harness directory does not match expected layout — skipping"
291 );
292 return vec![];
293 }
294
295 let entries = match std::fs::read_dir(dir) {
296 Ok(e) => e,
297 Err(err) => {
298 warn!(path = %dir.display(), error = %err, "could not read harness directory");
299 return vec![];
300 }
301 };
302
303 let mut results = Vec::new();
304
305 for entry in entries.flatten() {
306 let file_path = entry.path();
307 if !file_path.is_file() {
308 continue;
309 }
310
311 // Apply harness-specific file-extension filter (Req 7.8).
312 if !self.file_matches_harness(&file_path, harness) {
313 continue;
314 }
315
316 // Read file bytes; skip on I/O error.
317 let bytes = match std::fs::read(&file_path) {
318 Ok(b) => b,
319 Err(err) => {
320 warn!(path = %file_path.display(), error = %err, "could not read file");
321 continue;
322 }
323 };
324
325 // Compute SHA-256 (Req 7.3).
326 let content_hash = sha256_hex(&bytes);
327
328 let content = String::from_utf8_lossy(&bytes);
329 let first_50 = first_n_lines(&content, 50);
330
331 let basename = file_path
332 .file_name()
333 .and_then(|n| n.to_str())
334 .unwrap_or("")
335 .to_string();
336
337 let mut methods: Vec<DetectionMethod> = Vec::new();
338 let mut asset_id: Option<String> = None;
339 let mut installed_version: Option<String> = None;
340
341 // --- Strategy (a): Filename + layout ---
342 if let Some(id) = index.basename_to_id.get(&basename) {
343 methods.push(DetectionMethod::Filename);
344 asset_id = Some(id.clone());
345 }
346
347 // --- Strategy (b): VFA-EXPORT metadata comment (Req 7.7) ---
348 if let Some(meta) = Self::parse_export_metadata(&content) {
349 methods.push(DetectionMethod::MetadataComment);
350 // Metadata comment is authoritative for ID if filename didn't fire.
351 if asset_id.is_none() {
352 asset_id = Some(meta.id.clone());
353 }
354 if meta.version.is_some() {
355 installed_version = meta.version;
356 }
357 // Confirm consistency: if both fired, prefer the metadata ID.
358 else {
359 asset_id = Some(meta.id);
360 }
361 }
362
363 // --- Strategy (c): Content signature ---
364 if let Some(ref id) = asset_id.clone() {
365 if let Some(template) = index.id_to_template.get(id) {
366 if Self::match_content_signature(first_50, template) {
367 methods.push(DetectionMethod::ContentSignature);
368 }
369 }
370 } else {
371 // No id yet — try content signature against all templates.
372 for (tid, template) in &index.id_to_template {
373 if Self::match_content_signature(first_50, template) {
374 methods.push(DetectionMethod::ContentSignature);
375 if asset_id.is_none() {
376 asset_id = Some(tid.clone());
377 }
378 break;
379 }
380 }
381 }
382
383 // Only emit an asset if at least one strategy fired and we know the ID.
384 if let Some(id) = asset_id {
385 // Dedup methods before confirmation check.
386 methods.sort_by_key(|m| format!("{m:?}"));
387 methods.dedup();
388 let confirmed = is_confirmed(&methods);
389
390 results.push(InstalledAsset {
391 workspace_path: file_path,
392 asset_id: id,
393 installed_version,
394 content_hash,
395 detection_methods: methods,
396 confirmed,
397 harness: harness.dir_name().to_string(),
398 });
399 }
400 }
401
402 results
403 }
404
405 // -----------------------------------------------------------------------
406 // scan_workspace
407 // -----------------------------------------------------------------------
408
409 /// Scan a single resolved workspace, returning all detected assets sorted
410 /// by `asset_id` (Req 27.2).
411 ///
412 /// Harness directories that do not match any known layout are warned and
413 /// skipped (Req 7.6).
414 ///
415 /// Bug #2: Recursively checks for `agents/` and `skills/` subdirectories within
416 /// each harness root (e.g., `.claude/agents/`, `.cursor/agents/`, etc.).
417 pub fn scan_workspace(
418 &self,
419 ws: &ResolvedWorkspace,
420 index: &CatalogIndex,
421 ) -> Vec<InstalledAsset> {
422 let harness_dirs = detect_harness_dirs(&ws.canonical_path);
423
424 let ws_root = &ws.canonical_path;
425 let mut all: Vec<InstalledAsset> = harness_dirs
426 .iter()
427 .flat_map(|(harness, dir)| self.scan_harness_dir(ws_root, harness, dir, index))
428 .collect();
429
430 // Bug #2: Recursively check for agents/ and skills/ subdirectories within each harness root.
431 // Example: .claude/agents/, .cursor/agents/, etc.
432 for (harness, dir) in &harness_dirs {
433 for subdir_name in &["agents", "skills"] {
434 let subdir = dir.join(subdir_name);
435 if subdir.is_dir() {
436 let sub_assets = self.scan_harness_dir(ws_root, harness, &subdir, index);
437 all.extend(sub_assets);
438 }
439 }
440 }
441
442 all.sort_by(|a, b| a.asset_id.cmp(&b.asset_id));
443 all
444 }
445
446 // -----------------------------------------------------------------------
447 // scan_all
448 // -----------------------------------------------------------------------
449
450 /// Scan all workspaces in parallel (up to `self.concurrency` simultaneous
451 /// tasks) and return a map from workspace canonical path → installed assets.
452 ///
453 /// Uses a [`Semaphore`] to bound concurrency (Req 23.1). Each workspace
454 /// scan runs in its own `tokio::spawn` task so I/O does not block the
455 /// executor.
456 ///
457 /// Offline/unavailable workspaces are skipped silently here; callers should
458 /// pre-filter or mark them based on [`ResolvedWorkspace::status`].
459 pub async fn scan_all(
460 &self,
461 workspaces: &[ResolvedWorkspace],
462 index: &CatalogIndex,
463 ) -> HashMap<PathBuf, Vec<InstalledAsset>> {
464 let sem = Arc::new(Semaphore::new(self.concurrency));
465 let scanner = Arc::new(self.clone());
466 let index = Arc::new(index.clone());
467
468 let mut handles = Vec::with_capacity(workspaces.len());
469
470 for ws in workspaces {
471 let sem = Arc::clone(&sem);
472 let scanner = Arc::clone(&scanner);
473 let index = Arc::clone(&index);
474 let ws_owned = ws.clone();
475
476 let handle = tokio::spawn(async move {
477 // Acquire permit — released when `_permit` drops.
478 let _permit = sem.acquire().await.expect("semaphore closed");
479 let assets = scanner.scan_workspace(&ws_owned, &index);
480 (ws_owned.canonical_path.clone(), assets)
481 });
482
483 handles.push(handle);
484 }
485
486 let mut result = HashMap::new();
487 for handle in handles {
488 match handle.await {
489 Ok((path, assets)) => {
490 result.insert(path, assets);
491 }
492 Err(err) => {
493 warn!(error = %err, "workspace scan task panicked");
494 }
495 }
496 }
497
498 result
499 }
500
501 // -----------------------------------------------------------------------
502 // Private helpers
503 // -----------------------------------------------------------------------
504
505 /// Returns `true` if the file at `path` matches the harness-specific
506 /// extension filter (Req 7.8).
507 fn file_matches_harness(&self, path: &Path, harness: &HarnessDir) -> bool {
508 let name = match path.file_name().and_then(|n| n.to_str()) {
509 Some(n) => n,
510 None => return false,
511 };
512 match harness {
513 HarnessDir::Claude => name.ends_with(".md"),
514 HarnessDir::Cursor => name.ends_with(".json"),
515 HarnessDir::Kiro => name.ends_with(".md"),
516 HarnessDir::Codex => name == "plugin.json",
517 HarnessDir::Opencode => {
518 name.ends_with(".toml") || name.ends_with(".yaml") || name.ends_with(".yml")
519 }
520 }
521 }
522}
523
524// ---------------------------------------------------------------------------
525// Private free functions
526// ---------------------------------------------------------------------------
527
528/// Compute the SHA-256 hex digest of a byte slice.
529fn sha256_hex(bytes: &[u8]) -> String {
530 use std::fmt::Write as _;
531 let mut hasher = Sha256::new();
532 hasher.update(bytes);
533 let result = hasher.finalize();
534 result.iter().fold(String::new(), |mut s, b| {
535 let _ = write!(s, "{b:02x}");
536 s
537 })
538}
539
540/// Return the first `n` lines of `s` as a `&str` slice (no allocation when
541/// `s` already has fewer than `n` lines).
542fn first_n_lines(s: &str, n: usize) -> &str {
543 let mut end = 0;
544 let mut count = 0;
545 for (i, c) in s.char_indices() {
546 if c == '\n' {
547 count += 1;
548 if count >= n {
549 end = i;
550 break;
551 }
552 }
553 end = i + c.len_utf8();
554 }
555 &s[..end]
556}
557
558// ---------------------------------------------------------------------------
559// Tests
560// ---------------------------------------------------------------------------
561
562#[cfg(test)]
563mod tests {
564 use super::*;
565
566 // -----------------------------------------------------------------------
567 // Unit tests — parse_export_metadata
568 // -----------------------------------------------------------------------
569
570 #[test]
571 fn parse_export_metadata_basic() {
572 let line = r#"# VFA-EXPORT: {"id":"agents/aws/cdk-agent","version":"1.2.0","installed_at":"2024-01-01"}"#;
573 let meta = WorkspaceScanner::parse_export_metadata(line).expect("should parse");
574 assert_eq!(meta.id, "agents/aws/cdk-agent");
575 assert_eq!(meta.version.as_deref(), Some("1.2.0"));
576 assert_eq!(meta.installed_at.as_deref(), Some("2024-01-01"));
577 }
578
579 #[test]
580 fn parse_export_metadata_no_version() {
581 let line = r#"# VFA-EXPORT: {"id":"skills/openai/summarize"}"#;
582 let meta = WorkspaceScanner::parse_export_metadata(line).expect("should parse");
583 assert_eq!(meta.id, "skills/openai/summarize");
584 assert!(meta.version.is_none());
585 }
586
587 #[test]
588 fn parse_export_metadata_not_in_first_20_lines() {
589 let content = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\n\
590 line11\nline12\nline13\nline14\nline15\nline16\nline17\nline18\nline19\nline20\n\
591 # VFA-EXPORT: {\"id\":\"too-late\"}";
592 // Should NOT find the VFA-EXPORT because it's on line 21.
593 assert!(WorkspaceScanner::parse_export_metadata(content).is_none());
594 }
595
596 #[test]
597 fn parse_export_metadata_malformed_json_returns_none() {
598 let line = "# VFA-EXPORT: {not valid json}";
599 assert!(WorkspaceScanner::parse_export_metadata(line).is_none());
600 }
601
602 #[test]
603 fn parse_export_metadata_empty_input_returns_none() {
604 assert!(WorkspaceScanner::parse_export_metadata("").is_none());
605 }
606
607 #[test]
608 fn parse_export_metadata_with_leading_whitespace() {
609 let line = r#" # VFA-EXPORT: {"id":"agents/gcp/vertex-agent"}"#;
610 let meta = WorkspaceScanner::parse_export_metadata(line).expect("should parse");
611 assert_eq!(meta.id, "agents/gcp/vertex-agent");
612 }
613
614 // -----------------------------------------------------------------------
615 // Unit tests — match_content_signature
616 // -----------------------------------------------------------------------
617
618 #[test]
619 fn match_content_signature_identical_returns_true() {
620 let content =
621 "# Header\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10";
622 assert!(WorkspaceScanner::match_content_signature(content, content));
623 }
624
625 #[test]
626 fn match_content_signature_completely_different_returns_false() {
627 let a = "apple\nbanana\ncherry\ndate\nelderberry";
628 let b = "foo\nbar\nbaz\nqux\nquux";
629 assert!(!WorkspaceScanner::match_content_signature(a, b));
630 }
631
632 #[test]
633 fn match_content_signature_too_short_returns_false() {
634 let short = "line1\nline2";
635 assert!(!WorkspaceScanner::match_content_signature(short, short));
636 }
637
638 // -----------------------------------------------------------------------
639 // Unit tests — is_confirmed
640 // -----------------------------------------------------------------------
641
642 #[test]
643 fn is_confirmed_zero_methods() {
644 assert!(!is_confirmed(&[]));
645 }
646
647 #[test]
648 fn is_confirmed_one_method() {
649 assert!(!is_confirmed(&[DetectionMethod::Filename]));
650 }
651
652 #[test]
653 fn is_confirmed_two_distinct_methods() {
654 assert!(is_confirmed(&[
655 DetectionMethod::Filename,
656 DetectionMethod::MetadataComment,
657 ]));
658 }
659
660 #[test]
661 fn is_confirmed_two_identical_methods_not_confirmed() {
662 assert!(!is_confirmed(&[
663 DetectionMethod::Filename,
664 DetectionMethod::Filename,
665 ]));
666 }
667
668 #[test]
669 fn is_confirmed_three_methods() {
670 assert!(is_confirmed(&[
671 DetectionMethod::Filename,
672 DetectionMethod::MetadataComment,
673 DetectionMethod::ContentSignature,
674 ]));
675 }
676
677 // -----------------------------------------------------------------------
678 // Integration tests — scan_harness_dir with tempfile
679 // -----------------------------------------------------------------------
680
681 #[test]
682 fn scan_harness_dir_confirmed_with_filename_and_metadata() {
683 use std::fs;
684 use tempfile::TempDir;
685
686 let tmp = TempDir::new().unwrap();
687 let claude_dir = tmp.path().join(".claude");
688 fs::create_dir(&claude_dir).unwrap();
689
690 // File whose basename matches a canonical asset AND has VFA-EXPORT.
691 let content = "# VFA-EXPORT: {\"id\":\"agents/aws/cdk-agent\",\"version\":\"1.0.0\"}\n\
692 # CDK Agent\nsome content here\n";
693 fs::write(claude_dir.join("cdk-agent.md"), content).unwrap();
694
695 // Build index where basename "cdk-agent.md" → "agents/aws/cdk-agent".
696 let index = CatalogIndex::new(vec![(
697 "agents/aws/cdk-agent".to_string(),
698 "agents/aws/cdk-agent".to_string(),
699 None,
700 )]);
701
702 let scanner = WorkspaceScanner::new(1);
703 let assets = scanner.scan_harness_dir(tmp.path(), &HarnessDir::Claude, &claude_dir, &index);
704
705 assert_eq!(assets.len(), 1);
706 let asset = &assets[0];
707 assert_eq!(asset.asset_id, "agents/aws/cdk-agent");
708 assert!(
709 asset.confirmed,
710 "should be confirmed with filename+metadata"
711 );
712 assert!(asset.detection_methods.contains(&DetectionMethod::Filename));
713 assert!(asset
714 .detection_methods
715 .contains(&DetectionMethod::MetadataComment));
716 }
717
718 #[test]
719 fn scan_harness_dir_filename_only_not_confirmed() {
720 use std::fs;
721 use tempfile::TempDir;
722
723 let tmp = TempDir::new().unwrap();
724 let claude_dir = tmp.path().join(".claude");
725 fs::create_dir(&claude_dir).unwrap();
726
727 // File whose basename matches but has NO VFA-EXPORT and no template.
728 let content = "# Some Agent\nThis is just some text without metadata.\n";
729 fs::write(claude_dir.join("cdk-agent.md"), content).unwrap();
730
731 let index = CatalogIndex::new(vec![(
732 "agents/aws/cdk-agent".to_string(),
733 "agents/aws/cdk-agent".to_string(),
734 None, // no template → content signature can't fire
735 )]);
736
737 let scanner = WorkspaceScanner::new(1);
738 let assets = scanner.scan_harness_dir(tmp.path(), &HarnessDir::Claude, &claude_dir, &index);
739
740 assert_eq!(assets.len(), 1);
741 let asset = &assets[0];
742 assert_eq!(asset.asset_id, "agents/aws/cdk-agent");
743 assert!(
744 !asset.confirmed,
745 "only filename match — should not be confirmed"
746 );
747 assert_eq!(asset.detection_methods, vec![DetectionMethod::Filename]);
748 }
749
750 // -----------------------------------------------------------------------
751 // Async integration test — scan_all over two temp workspaces
752 // -----------------------------------------------------------------------
753
754 #[tokio::test]
755 async fn scan_all_aggregates_two_workspaces() {
756 use crate::models::workspace::{ResolvedWorkspace, WorkspaceStatus};
757 use std::fs;
758 use tempfile::TempDir;
759
760 let tmp1 = TempDir::new().unwrap();
761 let tmp2 = TempDir::new().unwrap();
762
763 // Workspace 1 — has a .claude dir with a confirmed asset.
764 let claude1 = tmp1.path().join(".claude");
765 fs::create_dir(&claude1).unwrap();
766 fs::write(
767 claude1.join("cdk-agent.md"),
768 "# VFA-EXPORT: {\"id\":\"agents/aws/cdk-agent\",\"version\":\"2.0.0\"}\n\
769 # CDK Agent\ncontent here\n",
770 )
771 .unwrap();
772
773 // Workspace 2 — has a .claude dir with a different confirmed asset.
774 let claude2 = tmp2.path().join(".claude");
775 fs::create_dir(&claude2).unwrap();
776 fs::write(
777 claude2.join("vertex-agent.md"),
778 "# VFA-EXPORT: {\"id\":\"agents/gcp/vertex-agent\"}\n\
779 # Vertex Agent\ncontent here\n",
780 )
781 .unwrap();
782
783 let index = CatalogIndex::new(vec![
784 (
785 "agents/aws/cdk-agent".to_string(),
786 "agents/aws/cdk-agent".to_string(),
787 None,
788 ),
789 (
790 "agents/gcp/vertex-agent".to_string(),
791 "agents/gcp/vertex-agent".to_string(),
792 None,
793 ),
794 ]);
795
796 let ws1 = ResolvedWorkspace {
797 canonical_path: tmp1.path().to_path_buf(),
798 name: "ws1".to_string(),
799 team: None,
800 tags: vec![],
801 status: WorkspaceStatus::Available,
802 };
803 let ws2 = ResolvedWorkspace {
804 canonical_path: tmp2.path().to_path_buf(),
805 name: "ws2".to_string(),
806 team: None,
807 tags: vec![],
808 status: WorkspaceStatus::Available,
809 };
810
811 let scanner = WorkspaceScanner::new(4);
812 let results = scanner.scan_all(&[ws1, ws2], &index).await;
813
814 assert_eq!(results.len(), 2, "should have results for both workspaces");
815
816 let ws1_assets = results.get(tmp1.path()).expect("ws1 should be in results");
817 assert_eq!(ws1_assets.len(), 1);
818 assert_eq!(ws1_assets[0].asset_id, "agents/aws/cdk-agent");
819
820 let ws2_assets = results.get(tmp2.path()).expect("ws2 should be in results");
821 assert_eq!(ws2_assets.len(), 1);
822 assert_eq!(ws2_assets[0].asset_id, "agents/gcp/vertex-agent");
823 }
824
825 // -----------------------------------------------------------------------
826 // Property tests — Task 5.6
827 // -----------------------------------------------------------------------
828
829 #[cfg(test)]
830 mod property_tests {
831 use super::*;
832 use proptest::prelude::*;
833
834 // -------------------------------------------------------------------
835 // Property 18 — Multi-strategy detection confirmation (Req 7.2)
836 //
837 // For any combination of 0–3 detection signals, `is_confirmed` should
838 // return true iff at least 2 DISTINCT signals are present.
839 // -------------------------------------------------------------------
840
841 prop_compose! {
842 /// Generate a Vec<DetectionMethod> with 0..=3 entries, possibly
843 /// containing duplicates (since real scanning could fire the same
844 /// strategy twice through different code paths).
845 fn arb_methods()(
846 include_filename in any::<bool>(),
847 include_metadata in any::<bool>(),
848 include_content in any::<bool>(),
849 dup_filename in any::<bool>(),
850 ) -> Vec<DetectionMethod> {
851 let mut methods = Vec::new();
852 if include_filename { methods.push(DetectionMethod::Filename); }
853 if include_metadata { methods.push(DetectionMethod::MetadataComment); }
854 if include_content { methods.push(DetectionMethod::ContentSignature); }
855 // Optionally duplicate Filename to test dedup.
856 if dup_filename && include_filename {
857 methods.push(DetectionMethod::Filename);
858 }
859 methods
860 }
861 }
862
863 proptest! {
864 #![proptest_config(proptest::test_runner::Config {
865 cases: 256,
866 ..Default::default()
867 })]
868
869 /// Property 18: confirmed == (number of distinct signals >= 2).
870 #[test]
871 fn prop18_confirmation_rule(methods in arb_methods()) {
872 let distinct: std::collections::HashSet<_> = methods.iter().collect();
873 let expected = distinct.len() >= 2;
874 prop_assert_eq!(
875 is_confirmed(&methods),
876 expected,
877 "is_confirmed({:?}) should be {} (distinct={})",
878 methods, expected, distinct.len()
879 );
880 }
881 }
882
883 // -------------------------------------------------------------------
884 // Property 19 — VFA-EXPORT metadata parsing (Req 7.7)
885 //
886 // For any valid {id, version, installed_at} serialised into a
887 // `# VFA-EXPORT: {json}` line, parse_export_metadata recovers the id
888 // and version. Arbitrary non-matching lines return None without panic.
889 // -------------------------------------------------------------------
890
891 prop_compose! {
892 /// Generate a valid asset ID: non-empty, no control chars.
893 fn arb_asset_id()(
894 s in "[a-z][a-z0-9_-]{1,20}(/[a-z][a-z0-9_-]{1,20}){0,3}"
895 ) -> String {
896 s
897 }
898 }
899
900 prop_compose! {
901 /// Generate an optional semver-like version string.
902 fn arb_version()(
903 major in 0u8..10,
904 minor in 0u8..20,
905 patch in 0u8..30,
906 include in any::<bool>(),
907 ) -> Option<String> {
908 if include {
909 Some(format!("{major}.{minor}.{patch}"))
910 } else {
911 None
912 }
913 }
914 }
915
916 proptest! {
917 #![proptest_config(proptest::test_runner::Config {
918 cases: 256,
919 ..Default::default()
920 })]
921
922 /// Property 19a: round-trip — serialised VFA-EXPORT line parses back.
923 #[test]
924 fn prop19a_vfa_export_round_trip(
925 id in arb_asset_id(),
926 version in arb_version(),
927 installed_at in prop::option::of("[0-9]{4}-[0-9]{2}-[0-9]{2}"),
928 ) {
929 let meta_orig = ExportMeta {
930 id: id.clone(),
931 version: version.clone(),
932 installed_at: installed_at.clone(),
933 };
934 let json = serde_json::to_string(&meta_orig).unwrap();
935 let line = format!("# VFA-EXPORT: {json}");
936
937 let parsed = WorkspaceScanner::parse_export_metadata(&line);
938 prop_assert!(parsed.is_some(), "should parse valid VFA-EXPORT line");
939 let parsed = parsed.unwrap();
940 prop_assert_eq!(&parsed.id, &id);
941 prop_assert_eq!(&parsed.version, &version);
942 }
943
944 /// Property 19b: arbitrary non-matching text never panics and
945 /// returns None when there is no VFA-EXPORT line.
946 #[test]
947 fn prop19b_non_matching_returns_none_no_panic(
948 // Generate arbitrary text that explicitly does NOT contain
949 // the "VFA-EXPORT:" substring.
950 s in "[^V]{0,200}"
951 ) {
952 // Must not contain "VFA-EXPORT:" (filter at strategy level).
953 prop_assume!(!s.contains("VFA-EXPORT:"));
954 // Must not panic.
955 let result = WorkspaceScanner::parse_export_metadata(&s);
956 prop_assert!(
957 result.is_none(),
958 "non-VFA-EXPORT input should return None, got {:?}", result
959 );
960 }
961 }
962 }
963}