use std::collections::BTreeSet;
use std::path::Path;
use super::schema::{AgentSet, ContentSource};
struct LangEngineer {
stem: &'static str,
markers: &'static [&'static str],
}
const LANGUAGE_ENGINEERS: &[LangEngineer] = &[
LangEngineer {
stem: "rust-engineer",
markers: &["Cargo.toml"],
},
LangEngineer {
stem: "python-engineer",
markers: &[
"pyproject.toml",
"setup.py",
"setup.cfg",
"requirements.txt",
"Pipfile",
],
},
LangEngineer {
stem: "golang-engineer",
markers: &["go.mod"],
},
LangEngineer {
stem: "java-engineer",
markers: &[
"pom.xml",
"build.gradle",
"build.gradle.kts",
"settings.gradle",
],
},
LangEngineer {
stem: "ruby-engineer",
markers: &["Gemfile", "Gemfile.lock", ".ruby-version"],
},
LangEngineer {
stem: "php-engineer",
markers: &["composer.json"],
},
LangEngineer {
stem: "dart-engineer",
markers: &["pubspec.yaml"],
},
LangEngineer {
stem: "phoenix-engineer",
markers: &["mix.exs"],
},
LangEngineer {
stem: "dotnet-engineer",
markers: &[
"*.sln",
"*.csproj",
"*.vbproj",
"global.json",
"Directory.Build.props",
],
},
LangEngineer {
stem: "javascript-engineer",
markers: &["package.json"],
},
LangEngineer {
stem: "typescript-engineer",
markers: &["tsconfig.json", "package.json"],
},
LangEngineer {
stem: "react-engineer",
markers: &["package.json"],
},
LangEngineer {
stem: "nextjs-engineer",
markers: &[
"next.config.js",
"next.config.mjs",
"next.config.ts",
"package.json",
],
},
LangEngineer {
stem: "svelte-engineer",
markers: &["svelte.config.js", "svelte.config.ts", "package.json"],
},
LangEngineer {
stem: "tauri-engineer",
markers: &["src-tauri/tauri.conf.json", "tauri.conf.json"],
},
];
fn marker_present(project_dir: &Path, marker: &str) -> bool {
if let Some(ext) = marker.strip_prefix("*.") {
let suffix = format!(".{ext}");
std::fs::read_dir(project_dir)
.map(|entries| {
entries
.flatten()
.any(|e| e.file_name().to_str().is_some_and(|n| n.ends_with(&suffix)))
})
.unwrap_or(false)
} else {
project_dir.join(marker).exists()
}
}
pub(crate) fn detected_engineers(project_dir: &Path) -> BTreeSet<&'static str> {
LANGUAGE_ENGINEERS
.iter()
.filter(|le| le.markers.iter().any(|m| marker_present(project_dir, m)))
.map(|le| le.stem)
.collect()
}
pub fn language_agent_scope(project_dir: &Path) -> Option<AgentSet> {
let kept = detected_engineers(project_dir);
if kept.is_empty() {
return None;
}
let mut exclude: Vec<String> = LANGUAGE_ENGINEERS
.iter()
.map(|le| le.stem)
.filter(|stem| !kept.contains(stem))
.map(str::to_owned)
.collect();
exclude.sort_unstable();
exclude.dedup();
Some(AgentSet {
include: Vec::new(),
exclude,
ignore_staleness: Vec::new(),
source: ContentSource::Bundled,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn touch(dir: &Path, name: &str) {
let path = dir.join(name);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, "").unwrap();
}
#[test]
fn rust_workspace_scopes_to_rust_engineer() {
let tmp = TempDir::new().unwrap();
touch(tmp.path(), "Cargo.toml");
let scope = language_agent_scope(tmp.path()).expect("rust project is scoped");
assert!(
scope.include.is_empty(),
"empty include = keep all agnostic"
);
assert!(
!scope.exclude.contains(&"rust-engineer".to_string()),
"rust-engineer must be kept"
);
for foreign in [
"python-engineer",
"javascript-engineer",
"typescript-engineer",
"golang-engineer",
"java-engineer",
"ruby-engineer",
"php-engineer",
"dart-engineer",
"phoenix-engineer",
"react-engineer",
"nextjs-engineer",
"svelte-engineer",
"tauri-engineer",
"dotnet-engineer",
] {
assert!(
scope.exclude.contains(&foreign.to_string()),
"{foreign} must be excluded from a Rust-only project"
);
}
assert_eq!(scope.source, ContentSource::Bundled);
}
#[test]
fn unknown_project_is_unscoped() {
let tmp = TempDir::new().unwrap();
touch(tmp.path(), "README.md");
assert!(
language_agent_scope(tmp.path()).is_none(),
"unknown project type must not be scoped"
);
}
#[test]
fn js_project_keeps_js_family() {
let tmp = TempDir::new().unwrap();
touch(tmp.path(), "package.json");
let scope = language_agent_scope(tmp.path()).expect("js project is scoped");
for kept in [
"javascript-engineer",
"typescript-engineer",
"react-engineer",
"nextjs-engineer",
"svelte-engineer",
] {
assert!(
!scope.exclude.contains(&kept.to_string()),
"{kept} must be kept for a package.json project"
);
}
assert!(scope.exclude.contains(&"rust-engineer".to_string()));
assert!(scope.exclude.contains(&"python-engineer".to_string()));
}
#[test]
fn polyglot_project_keeps_both() {
let tmp = TempDir::new().unwrap();
touch(tmp.path(), "Cargo.toml");
touch(tmp.path(), "pyproject.toml");
let scope = language_agent_scope(tmp.path()).expect("polyglot is scoped");
assert!(!scope.exclude.contains(&"rust-engineer".to_string()));
assert!(!scope.exclude.contains(&"python-engineer".to_string()));
assert!(scope.exclude.contains(&"golang-engineer".to_string()));
}
#[test]
fn dotnet_csproj_scopes_to_dotnet_engineer() {
let tmp = TempDir::new().unwrap();
touch(tmp.path(), "MyApp.csproj");
let scope = language_agent_scope(tmp.path()).expect("dotnet project is scoped");
assert!(
!scope.exclude.contains(&"dotnet-engineer".to_string()),
"dotnet-engineer must be kept for a *.csproj project"
);
assert!(scope.exclude.contains(&"rust-engineer".to_string()));
assert!(scope.exclude.contains(&"golang-engineer".to_string()));
}
#[test]
fn dotnet_vbproj_and_exact_markers_scope_to_dotnet_engineer() {
for marker in ["Legacy.vbproj", "global.json", "Directory.Build.props"] {
let tmp = TempDir::new().unwrap();
touch(tmp.path(), marker);
let scope =
language_agent_scope(tmp.path()).unwrap_or_else(|| panic!("{marker} is scoped"));
assert!(
!scope.exclude.contains(&"dotnet-engineer".to_string()),
"dotnet-engineer must be kept for a {marker} project"
);
}
}
#[test]
fn tauri_marker_scopes_without_bare_package_json() {
let tmp = TempDir::new().unwrap();
touch(tmp.path(), "src-tauri/tauri.conf.json");
let scope = language_agent_scope(tmp.path()).expect("tauri project is scoped");
assert!(
!scope.exclude.contains(&"tauri-engineer".to_string()),
"tauri-engineer must be kept"
);
}
}