1use std::path::{Path, PathBuf};
2
3#[derive(Debug, Clone, PartialEq, Eq, Hash)]
9pub enum HarnessDir {
10 Claude,
12 Cursor,
14 Kiro,
16 Codex,
18 Opencode,
20}
21
22impl HarnessDir {
23 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 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
47pub 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#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum LayoutMatch {
69 Matches,
72 NoMatch,
75}
76
77pub 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
101fn 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
129fn 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#[cfg(test)]
145mod tests {
146 use super::*;
147 use std::fs;
148 use tempfile::TempDir;
149
150 #[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 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 #[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 #[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 #[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 #[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 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 #[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 #[test]
430 fn validate_nonexistent_dir_returns_no_match() {
431 let tmp = TempDir::new().unwrap();
432 let ghost = tmp.path().join(".ghost");
433 assert_eq!(
435 validate_harness_layout(&HarnessDir::Claude, &ghost),
436 LayoutMatch::NoMatch
437 );
438 }
439
440 #[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}