Skip to main content

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}