Skip to main content

vfa_tui/workspace/
harness_layout.rs

1use std::path::{Path, PathBuf};
2
3/// Identifies a supported harness directory type.
4///
5/// Each variant maps 1:1 to a well-known directory that a downstream workspace
6/// may contain. The names are stable identifiers — use [`HarnessDir::dir_name`]
7/// to obtain the on-disk directory name.
8#[derive(Debug, Clone, PartialEq, Eq, Hash)]
9pub enum HarnessDir {
10    /// `.claude/` — Claude Code CLI agent definitions (markdown files).
11    Claude,
12    /// `.cursor/` — Cursor IDE agent configuration (JSON configs).
13    Cursor,
14    /// `.kiro/` — Kiro IDE steering files with agent references.
15    Kiro,
16    /// `.codex/` — Codex plugin.json-based agent entries.
17    Codex,
18    /// `.opencode/` — OpenCode TOML/YAML agent definitions.
19    Opencode,
20}
21
22impl HarnessDir {
23    /// Returns the directory name (without a leading `.`) as it appears on disk,
24    /// e.g. `".claude"` for [`HarnessDir::Claude`].
25    pub fn dir_name(&self) -> &'static str {
26        match self {
27            HarnessDir::Claude => ".claude",
28            HarnessDir::Cursor => ".cursor",
29            HarnessDir::Kiro => ".kiro",
30            HarnessDir::Codex => ".codex",
31            HarnessDir::Opencode => ".opencode",
32        }
33    }
34
35    /// Iterate over all five known harness variants in declaration order.
36    pub fn all() -> [HarnessDir; 5] {
37        [
38            HarnessDir::Claude,
39            HarnessDir::Cursor,
40            HarnessDir::Kiro,
41            HarnessDir::Codex,
42            HarnessDir::Opencode,
43        ]
44    }
45}
46
47/// Scans `workspace_root` for the five known harness directories and returns
48/// every one that is present as a `(HarnessDir, PathBuf)` pair.
49///
50/// Only directories that actually exist on the filesystem are returned; the
51/// order matches the declaration order in [`HarnessDir::all`].
52pub fn detect_harness_dirs(workspace_root: &Path) -> Vec<(HarnessDir, PathBuf)> {
53    HarnessDir::all()
54        .into_iter()
55        .filter_map(|h| {
56            let path = workspace_root.join(h.dir_name());
57            if path.is_dir() {
58                Some((h, path))
59            } else {
60                None
61            }
62        })
63        .collect()
64}
65
66/// Outcome of a harness-layout validation check.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum LayoutMatch {
69    /// The directory contains at least one file that matches the expected
70    /// harness layout pattern.
71    Matches,
72    /// The directory exists but contains no files that match the expected
73    /// layout, or it is empty.
74    NoMatch,
75}
76
77/// Validates whether `path` matches the expected on-disk layout for `dir`.
78///
79/// Each harness has a characteristic layout heuristic:
80///
81/// | Harness   | Recognised layout signal                                           |
82/// |-----------|-------------------------------------------------------------------|
83/// | Claude    | At least one `.md` file (agent ID encoded in filename).           |
84/// | Cursor    | At least one `.json` file (config referencing agent paths).       |
85/// | Kiro      | At least one `.md` or `.txt` file (steering files).               |
86/// | Codex     | A `plugin.json` file at the directory root.                       |
87/// | Opencode  | At least one `.toml` or `.yaml`/`.yml` file (agent definitions).  |
88///
89/// Returns [`LayoutMatch::NoMatch`] for any I/O errors so callers can treat
90/// an unreadable directory as "skip with warning".
91pub fn validate_harness_layout(dir: &HarnessDir, path: &Path) -> LayoutMatch {
92    match dir {
93        HarnessDir::Claude => has_file_with_extensions(path, &[".md"]),
94        HarnessDir::Cursor => has_file_with_extensions(path, &[".json"]),
95        HarnessDir::Kiro => has_file_with_extensions(path, &[".md", ".txt"]),
96        HarnessDir::Codex => has_exact_file(path, "plugin.json"),
97        HarnessDir::Opencode => has_file_with_extensions(path, &[".toml", ".yaml", ".yml"]),
98    }
99}
100
101// ---------------------------------------------------------------------------
102// Private helpers
103// ---------------------------------------------------------------------------
104
105/// Returns `LayoutMatch::Matches` if `dir` contains at least one regular file
106/// whose name ends with any of the given `extensions` (case-sensitive).
107/// Searches only the top level of the directory (non-recursive).
108fn has_file_with_extensions(dir: &Path, extensions: &[&str]) -> LayoutMatch {
109    let entries = match std::fs::read_dir(dir) {
110        Ok(e) => e,
111        Err(_) => return LayoutMatch::NoMatch,
112    };
113
114    for entry in entries.flatten() {
115        let path = entry.path();
116        if !path.is_file() {
117            continue;
118        }
119        if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
120            if extensions.iter().any(|ext| name.ends_with(ext)) {
121                return LayoutMatch::Matches;
122            }
123        }
124    }
125
126    LayoutMatch::NoMatch
127}
128
129/// Returns `LayoutMatch::Matches` if `dir` contains a regular file with
130/// exactly the given `filename` at the top level.
131fn has_exact_file(dir: &Path, filename: &str) -> LayoutMatch {
132    let candidate = dir.join(filename);
133    if candidate.is_file() {
134        LayoutMatch::Matches
135    } else {
136        LayoutMatch::NoMatch
137    }
138}
139
140// ---------------------------------------------------------------------------
141// Tests
142// ---------------------------------------------------------------------------
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use std::fs;
148    use tempfile::TempDir;
149
150    // -----------------------------------------------------------------------
151    // detect_harness_dirs — presence detection
152    // -----------------------------------------------------------------------
153
154    #[test]
155    fn detect_none_when_no_harness_dirs() {
156        let tmp = TempDir::new().unwrap();
157        let result = detect_harness_dirs(tmp.path());
158        assert!(
159            result.is_empty(),
160            "expected no harness dirs, got {result:?}"
161        );
162    }
163
164    #[test]
165    fn detect_all_five_harness_dirs() {
166        let tmp = TempDir::new().unwrap();
167        for h in HarnessDir::all() {
168            fs::create_dir(tmp.path().join(h.dir_name())).unwrap();
169        }
170        let result = detect_harness_dirs(tmp.path());
171        assert_eq!(
172            result.len(),
173            5,
174            "expected 5 harness dirs, got {}",
175            result.len()
176        );
177        let found: Vec<&HarnessDir> = result.iter().map(|(h, _)| h).collect();
178        assert!(found.contains(&&HarnessDir::Claude));
179        assert!(found.contains(&&HarnessDir::Cursor));
180        assert!(found.contains(&&HarnessDir::Kiro));
181        assert!(found.contains(&&HarnessDir::Codex));
182        assert!(found.contains(&&HarnessDir::Opencode));
183    }
184
185    #[test]
186    fn detect_subset_of_harness_dirs() {
187        let tmp = TempDir::new().unwrap();
188        fs::create_dir(tmp.path().join(".claude")).unwrap();
189        fs::create_dir(tmp.path().join(".kiro")).unwrap();
190        let result = detect_harness_dirs(tmp.path());
191        assert_eq!(
192            result.len(),
193            2,
194            "expected 2 harness dirs, got {}",
195            result.len()
196        );
197        let found: Vec<&HarnessDir> = result.iter().map(|(h, _)| h).collect();
198        assert!(found.contains(&&HarnessDir::Claude));
199        assert!(found.contains(&&HarnessDir::Kiro));
200        assert!(!found.contains(&&HarnessDir::Cursor));
201    }
202
203    #[test]
204    fn detect_returns_absolute_paths() {
205        let tmp = TempDir::new().unwrap();
206        fs::create_dir(tmp.path().join(".cursor")).unwrap();
207        let result = detect_harness_dirs(tmp.path());
208        assert_eq!(result.len(), 1);
209        let (_, path) = &result[0];
210        assert!(path.is_absolute(), "expected absolute path, got {path:?}");
211        assert_eq!(path, &tmp.path().join(".cursor"));
212    }
213
214    #[test]
215    fn detect_ignores_files_named_like_harness_dirs() {
216        // A file named `.claude` (not a directory) should NOT be detected.
217        let tmp = TempDir::new().unwrap();
218        fs::write(tmp.path().join(".claude"), "not a dir").unwrap();
219        let result = detect_harness_dirs(tmp.path());
220        assert!(
221            result.is_empty(),
222            "files named like harness dirs should be ignored: {result:?}"
223        );
224    }
225
226    // -----------------------------------------------------------------------
227    // validate_harness_layout — Claude (.md files)
228    // -----------------------------------------------------------------------
229
230    #[test]
231    fn claude_layout_matches_md_file() {
232        let tmp = TempDir::new().unwrap();
233        let claude_dir = tmp.path().join(".claude");
234        fs::create_dir(&claude_dir).unwrap();
235        fs::write(claude_dir.join("security-agent-v1.md"), "# agent content").unwrap();
236        assert_eq!(
237            validate_harness_layout(&HarnessDir::Claude, &claude_dir),
238            LayoutMatch::Matches
239        );
240    }
241
242    #[test]
243    fn claude_layout_no_match_wrong_extension() {
244        let tmp = TempDir::new().unwrap();
245        let claude_dir = tmp.path().join(".claude");
246        fs::create_dir(&claude_dir).unwrap();
247        fs::write(claude_dir.join("config.json"), "{}").unwrap();
248        assert_eq!(
249            validate_harness_layout(&HarnessDir::Claude, &claude_dir),
250            LayoutMatch::NoMatch
251        );
252    }
253
254    #[test]
255    fn claude_layout_no_match_empty_dir() {
256        let tmp = TempDir::new().unwrap();
257        let claude_dir = tmp.path().join(".claude");
258        fs::create_dir(&claude_dir).unwrap();
259        assert_eq!(
260            validate_harness_layout(&HarnessDir::Claude, &claude_dir),
261            LayoutMatch::NoMatch
262        );
263    }
264
265    // -----------------------------------------------------------------------
266    // validate_harness_layout — Cursor (.json files)
267    // -----------------------------------------------------------------------
268
269    #[test]
270    fn cursor_layout_matches_json_file() {
271        let tmp = TempDir::new().unwrap();
272        let cursor_dir = tmp.path().join(".cursor");
273        fs::create_dir(&cursor_dir).unwrap();
274        fs::write(cursor_dir.join("agents.json"), r#"{"agents": []}"#).unwrap();
275        assert_eq!(
276            validate_harness_layout(&HarnessDir::Cursor, &cursor_dir),
277            LayoutMatch::Matches
278        );
279    }
280
281    #[test]
282    fn cursor_layout_no_match_md_only() {
283        let tmp = TempDir::new().unwrap();
284        let cursor_dir = tmp.path().join(".cursor");
285        fs::create_dir(&cursor_dir).unwrap();
286        fs::write(cursor_dir.join("readme.md"), "docs").unwrap();
287        assert_eq!(
288            validate_harness_layout(&HarnessDir::Cursor, &cursor_dir),
289            LayoutMatch::NoMatch
290        );
291    }
292
293    // -----------------------------------------------------------------------
294    // validate_harness_layout — Kiro (.md or .txt steering files)
295    // -----------------------------------------------------------------------
296
297    #[test]
298    fn kiro_layout_matches_md_file() {
299        let tmp = TempDir::new().unwrap();
300        let kiro_dir = tmp.path().join(".kiro");
301        fs::create_dir(&kiro_dir).unwrap();
302        fs::write(kiro_dir.join("steering.md"), "## steering").unwrap();
303        assert_eq!(
304            validate_harness_layout(&HarnessDir::Kiro, &kiro_dir),
305            LayoutMatch::Matches
306        );
307    }
308
309    #[test]
310    fn kiro_layout_matches_txt_file() {
311        let tmp = TempDir::new().unwrap();
312        let kiro_dir = tmp.path().join(".kiro");
313        fs::create_dir(&kiro_dir).unwrap();
314        fs::write(kiro_dir.join("agents.txt"), "agent-ref-1\nagent-ref-2").unwrap();
315        assert_eq!(
316            validate_harness_layout(&HarnessDir::Kiro, &kiro_dir),
317            LayoutMatch::Matches
318        );
319    }
320
321    #[test]
322    fn kiro_layout_no_match_json_only() {
323        let tmp = TempDir::new().unwrap();
324        let kiro_dir = tmp.path().join(".kiro");
325        fs::create_dir(&kiro_dir).unwrap();
326        fs::write(kiro_dir.join("config.json"), "{}").unwrap();
327        assert_eq!(
328            validate_harness_layout(&HarnessDir::Kiro, &kiro_dir),
329            LayoutMatch::NoMatch
330        );
331    }
332
333    // -----------------------------------------------------------------------
334    // validate_harness_layout — Codex (plugin.json)
335    // -----------------------------------------------------------------------
336
337    #[test]
338    fn codex_layout_matches_plugin_json() {
339        let tmp = TempDir::new().unwrap();
340        let codex_dir = tmp.path().join(".codex");
341        fs::create_dir(&codex_dir).unwrap();
342        fs::write(codex_dir.join("plugin.json"), r#"{"name": "my-plugin"}"#).unwrap();
343        assert_eq!(
344            validate_harness_layout(&HarnessDir::Codex, &codex_dir),
345            LayoutMatch::Matches
346        );
347    }
348
349    #[test]
350    fn codex_layout_no_match_wrong_filename() {
351        let tmp = TempDir::new().unwrap();
352        let codex_dir = tmp.path().join(".codex");
353        fs::create_dir(&codex_dir).unwrap();
354        // "plugins.json" ≠ "plugin.json"
355        fs::write(codex_dir.join("plugins.json"), r#"{"name": "my-plugin"}"#).unwrap();
356        assert_eq!(
357            validate_harness_layout(&HarnessDir::Codex, &codex_dir),
358            LayoutMatch::NoMatch
359        );
360    }
361
362    #[test]
363    fn codex_layout_no_match_empty_dir() {
364        let tmp = TempDir::new().unwrap();
365        let codex_dir = tmp.path().join(".codex");
366        fs::create_dir(&codex_dir).unwrap();
367        assert_eq!(
368            validate_harness_layout(&HarnessDir::Codex, &codex_dir),
369            LayoutMatch::NoMatch
370        );
371    }
372
373    // -----------------------------------------------------------------------
374    // validate_harness_layout — Opencode (.toml / .yaml / .yml)
375    // -----------------------------------------------------------------------
376
377    #[test]
378    fn opencode_layout_matches_toml_file() {
379        let tmp = TempDir::new().unwrap();
380        let oc_dir = tmp.path().join(".opencode");
381        fs::create_dir(&oc_dir).unwrap();
382        fs::write(oc_dir.join("agent.toml"), "[agent]\nname = \"foo\"").unwrap();
383        assert_eq!(
384            validate_harness_layout(&HarnessDir::Opencode, &oc_dir),
385            LayoutMatch::Matches
386        );
387    }
388
389    #[test]
390    fn opencode_layout_matches_yaml_file() {
391        let tmp = TempDir::new().unwrap();
392        let oc_dir = tmp.path().join(".opencode");
393        fs::create_dir(&oc_dir).unwrap();
394        fs::write(oc_dir.join("agent.yaml"), "name: foo").unwrap();
395        assert_eq!(
396            validate_harness_layout(&HarnessDir::Opencode, &oc_dir),
397            LayoutMatch::Matches
398        );
399    }
400
401    #[test]
402    fn opencode_layout_matches_yml_file() {
403        let tmp = TempDir::new().unwrap();
404        let oc_dir = tmp.path().join(".opencode");
405        fs::create_dir(&oc_dir).unwrap();
406        fs::write(oc_dir.join("agent.yml"), "name: foo").unwrap();
407        assert_eq!(
408            validate_harness_layout(&HarnessDir::Opencode, &oc_dir),
409            LayoutMatch::Matches
410        );
411    }
412
413    #[test]
414    fn opencode_layout_no_match_json_only() {
415        let tmp = TempDir::new().unwrap();
416        let oc_dir = tmp.path().join(".opencode");
417        fs::create_dir(&oc_dir).unwrap();
418        fs::write(oc_dir.join("config.json"), "{}").unwrap();
419        assert_eq!(
420            validate_harness_layout(&HarnessDir::Opencode, &oc_dir),
421            LayoutMatch::NoMatch
422        );
423    }
424
425    // -----------------------------------------------------------------------
426    // validate_harness_layout — nonexistent / unreadable dir
427    // -----------------------------------------------------------------------
428
429    #[test]
430    fn validate_nonexistent_dir_returns_no_match() {
431        let tmp = TempDir::new().unwrap();
432        let ghost = tmp.path().join(".ghost");
433        // ghost does not exist at all
434        assert_eq!(
435            validate_harness_layout(&HarnessDir::Claude, &ghost),
436            LayoutMatch::NoMatch
437        );
438    }
439
440    // -----------------------------------------------------------------------
441    // HarnessDir helpers
442    // -----------------------------------------------------------------------
443
444    #[test]
445    fn harness_dir_names_are_correct() {
446        assert_eq!(HarnessDir::Claude.dir_name(), ".claude");
447        assert_eq!(HarnessDir::Cursor.dir_name(), ".cursor");
448        assert_eq!(HarnessDir::Kiro.dir_name(), ".kiro");
449        assert_eq!(HarnessDir::Codex.dir_name(), ".codex");
450        assert_eq!(HarnessDir::Opencode.dir_name(), ".opencode");
451    }
452
453    #[test]
454    fn harness_dir_all_returns_five_variants() {
455        assert_eq!(HarnessDir::all().len(), 5);
456    }
457}