use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum HarnessDir {
Claude,
Cursor,
Kiro,
Codex,
Opencode,
}
impl HarnessDir {
pub fn dir_name(&self) -> &'static str {
match self {
HarnessDir::Claude => ".claude",
HarnessDir::Cursor => ".cursor",
HarnessDir::Kiro => ".kiro",
HarnessDir::Codex => ".codex",
HarnessDir::Opencode => ".opencode",
}
}
pub fn all() -> [HarnessDir; 5] {
[
HarnessDir::Claude,
HarnessDir::Cursor,
HarnessDir::Kiro,
HarnessDir::Codex,
HarnessDir::Opencode,
]
}
}
pub fn detect_harness_dirs(workspace_root: &Path) -> Vec<(HarnessDir, PathBuf)> {
HarnessDir::all()
.into_iter()
.filter_map(|h| {
let path = workspace_root.join(h.dir_name());
if path.is_dir() {
Some((h, path))
} else {
None
}
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LayoutMatch {
Matches,
NoMatch,
}
pub fn validate_harness_layout(dir: &HarnessDir, path: &Path) -> LayoutMatch {
match dir {
HarnessDir::Claude => has_file_with_extensions(path, &[".md"]),
HarnessDir::Cursor => has_file_with_extensions(path, &[".json"]),
HarnessDir::Kiro => has_file_with_extensions(path, &[".md", ".txt"]),
HarnessDir::Codex => has_exact_file(path, "plugin.json"),
HarnessDir::Opencode => has_file_with_extensions(path, &[".toml", ".yaml", ".yml"]),
}
}
fn has_file_with_extensions(dir: &Path, extensions: &[&str]) -> LayoutMatch {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return LayoutMatch::NoMatch,
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if extensions.iter().any(|ext| name.ends_with(ext)) {
return LayoutMatch::Matches;
}
}
}
LayoutMatch::NoMatch
}
fn has_exact_file(dir: &Path, filename: &str) -> LayoutMatch {
let candidate = dir.join(filename);
if candidate.is_file() {
LayoutMatch::Matches
} else {
LayoutMatch::NoMatch
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn detect_none_when_no_harness_dirs() {
let tmp = TempDir::new().unwrap();
let result = detect_harness_dirs(tmp.path());
assert!(
result.is_empty(),
"expected no harness dirs, got {result:?}"
);
}
#[test]
fn detect_all_five_harness_dirs() {
let tmp = TempDir::new().unwrap();
for h in HarnessDir::all() {
fs::create_dir(tmp.path().join(h.dir_name())).unwrap();
}
let result = detect_harness_dirs(tmp.path());
assert_eq!(
result.len(),
5,
"expected 5 harness dirs, got {}",
result.len()
);
let found: Vec<&HarnessDir> = result.iter().map(|(h, _)| h).collect();
assert!(found.contains(&&HarnessDir::Claude));
assert!(found.contains(&&HarnessDir::Cursor));
assert!(found.contains(&&HarnessDir::Kiro));
assert!(found.contains(&&HarnessDir::Codex));
assert!(found.contains(&&HarnessDir::Opencode));
}
#[test]
fn detect_subset_of_harness_dirs() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join(".claude")).unwrap();
fs::create_dir(tmp.path().join(".kiro")).unwrap();
let result = detect_harness_dirs(tmp.path());
assert_eq!(
result.len(),
2,
"expected 2 harness dirs, got {}",
result.len()
);
let found: Vec<&HarnessDir> = result.iter().map(|(h, _)| h).collect();
assert!(found.contains(&&HarnessDir::Claude));
assert!(found.contains(&&HarnessDir::Kiro));
assert!(!found.contains(&&HarnessDir::Cursor));
}
#[test]
fn detect_returns_absolute_paths() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join(".cursor")).unwrap();
let result = detect_harness_dirs(tmp.path());
assert_eq!(result.len(), 1);
let (_, path) = &result[0];
assert!(path.is_absolute(), "expected absolute path, got {path:?}");
assert_eq!(path, &tmp.path().join(".cursor"));
}
#[test]
fn detect_ignores_files_named_like_harness_dirs() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join(".claude"), "not a dir").unwrap();
let result = detect_harness_dirs(tmp.path());
assert!(
result.is_empty(),
"files named like harness dirs should be ignored: {result:?}"
);
}
#[test]
fn claude_layout_matches_md_file() {
let tmp = TempDir::new().unwrap();
let claude_dir = tmp.path().join(".claude");
fs::create_dir(&claude_dir).unwrap();
fs::write(claude_dir.join("security-agent-v1.md"), "# agent content").unwrap();
assert_eq!(
validate_harness_layout(&HarnessDir::Claude, &claude_dir),
LayoutMatch::Matches
);
}
#[test]
fn claude_layout_no_match_wrong_extension() {
let tmp = TempDir::new().unwrap();
let claude_dir = tmp.path().join(".claude");
fs::create_dir(&claude_dir).unwrap();
fs::write(claude_dir.join("config.json"), "{}").unwrap();
assert_eq!(
validate_harness_layout(&HarnessDir::Claude, &claude_dir),
LayoutMatch::NoMatch
);
}
#[test]
fn claude_layout_no_match_empty_dir() {
let tmp = TempDir::new().unwrap();
let claude_dir = tmp.path().join(".claude");
fs::create_dir(&claude_dir).unwrap();
assert_eq!(
validate_harness_layout(&HarnessDir::Claude, &claude_dir),
LayoutMatch::NoMatch
);
}
#[test]
fn cursor_layout_matches_json_file() {
let tmp = TempDir::new().unwrap();
let cursor_dir = tmp.path().join(".cursor");
fs::create_dir(&cursor_dir).unwrap();
fs::write(cursor_dir.join("agents.json"), r#"{"agents": []}"#).unwrap();
assert_eq!(
validate_harness_layout(&HarnessDir::Cursor, &cursor_dir),
LayoutMatch::Matches
);
}
#[test]
fn cursor_layout_no_match_md_only() {
let tmp = TempDir::new().unwrap();
let cursor_dir = tmp.path().join(".cursor");
fs::create_dir(&cursor_dir).unwrap();
fs::write(cursor_dir.join("readme.md"), "docs").unwrap();
assert_eq!(
validate_harness_layout(&HarnessDir::Cursor, &cursor_dir),
LayoutMatch::NoMatch
);
}
#[test]
fn kiro_layout_matches_md_file() {
let tmp = TempDir::new().unwrap();
let kiro_dir = tmp.path().join(".kiro");
fs::create_dir(&kiro_dir).unwrap();
fs::write(kiro_dir.join("steering.md"), "## steering").unwrap();
assert_eq!(
validate_harness_layout(&HarnessDir::Kiro, &kiro_dir),
LayoutMatch::Matches
);
}
#[test]
fn kiro_layout_matches_txt_file() {
let tmp = TempDir::new().unwrap();
let kiro_dir = tmp.path().join(".kiro");
fs::create_dir(&kiro_dir).unwrap();
fs::write(kiro_dir.join("agents.txt"), "agent-ref-1\nagent-ref-2").unwrap();
assert_eq!(
validate_harness_layout(&HarnessDir::Kiro, &kiro_dir),
LayoutMatch::Matches
);
}
#[test]
fn kiro_layout_no_match_json_only() {
let tmp = TempDir::new().unwrap();
let kiro_dir = tmp.path().join(".kiro");
fs::create_dir(&kiro_dir).unwrap();
fs::write(kiro_dir.join("config.json"), "{}").unwrap();
assert_eq!(
validate_harness_layout(&HarnessDir::Kiro, &kiro_dir),
LayoutMatch::NoMatch
);
}
#[test]
fn codex_layout_matches_plugin_json() {
let tmp = TempDir::new().unwrap();
let codex_dir = tmp.path().join(".codex");
fs::create_dir(&codex_dir).unwrap();
fs::write(codex_dir.join("plugin.json"), r#"{"name": "my-plugin"}"#).unwrap();
assert_eq!(
validate_harness_layout(&HarnessDir::Codex, &codex_dir),
LayoutMatch::Matches
);
}
#[test]
fn codex_layout_no_match_wrong_filename() {
let tmp = TempDir::new().unwrap();
let codex_dir = tmp.path().join(".codex");
fs::create_dir(&codex_dir).unwrap();
fs::write(codex_dir.join("plugins.json"), r#"{"name": "my-plugin"}"#).unwrap();
assert_eq!(
validate_harness_layout(&HarnessDir::Codex, &codex_dir),
LayoutMatch::NoMatch
);
}
#[test]
fn codex_layout_no_match_empty_dir() {
let tmp = TempDir::new().unwrap();
let codex_dir = tmp.path().join(".codex");
fs::create_dir(&codex_dir).unwrap();
assert_eq!(
validate_harness_layout(&HarnessDir::Codex, &codex_dir),
LayoutMatch::NoMatch
);
}
#[test]
fn opencode_layout_matches_toml_file() {
let tmp = TempDir::new().unwrap();
let oc_dir = tmp.path().join(".opencode");
fs::create_dir(&oc_dir).unwrap();
fs::write(oc_dir.join("agent.toml"), "[agent]\nname = \"foo\"").unwrap();
assert_eq!(
validate_harness_layout(&HarnessDir::Opencode, &oc_dir),
LayoutMatch::Matches
);
}
#[test]
fn opencode_layout_matches_yaml_file() {
let tmp = TempDir::new().unwrap();
let oc_dir = tmp.path().join(".opencode");
fs::create_dir(&oc_dir).unwrap();
fs::write(oc_dir.join("agent.yaml"), "name: foo").unwrap();
assert_eq!(
validate_harness_layout(&HarnessDir::Opencode, &oc_dir),
LayoutMatch::Matches
);
}
#[test]
fn opencode_layout_matches_yml_file() {
let tmp = TempDir::new().unwrap();
let oc_dir = tmp.path().join(".opencode");
fs::create_dir(&oc_dir).unwrap();
fs::write(oc_dir.join("agent.yml"), "name: foo").unwrap();
assert_eq!(
validate_harness_layout(&HarnessDir::Opencode, &oc_dir),
LayoutMatch::Matches
);
}
#[test]
fn opencode_layout_no_match_json_only() {
let tmp = TempDir::new().unwrap();
let oc_dir = tmp.path().join(".opencode");
fs::create_dir(&oc_dir).unwrap();
fs::write(oc_dir.join("config.json"), "{}").unwrap();
assert_eq!(
validate_harness_layout(&HarnessDir::Opencode, &oc_dir),
LayoutMatch::NoMatch
);
}
#[test]
fn validate_nonexistent_dir_returns_no_match() {
let tmp = TempDir::new().unwrap();
let ghost = tmp.path().join(".ghost");
assert_eq!(
validate_harness_layout(&HarnessDir::Claude, &ghost),
LayoutMatch::NoMatch
);
}
#[test]
fn harness_dir_names_are_correct() {
assert_eq!(HarnessDir::Claude.dir_name(), ".claude");
assert_eq!(HarnessDir::Cursor.dir_name(), ".cursor");
assert_eq!(HarnessDir::Kiro.dir_name(), ".kiro");
assert_eq!(HarnessDir::Codex.dir_name(), ".codex");
assert_eq!(HarnessDir::Opencode.dir_name(), ".opencode");
}
#[test]
fn harness_dir_all_returns_five_variants() {
assert_eq!(HarnessDir::all().len(), 5);
}
}