use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use super::nested::{MarkerAnchors, nested_probe_roots};
use super::schema::{AgentCategories, GatedAgent};
use super::workspace::{ProbeBudget, probe_roots};
fn marker_present(dir: &Path, marker: &str, budget: &ProbeBudget) -> bool {
if let Some((path, needle)) = marker.split_once("::") {
return file_contains(&dir.join(path), needle, budget);
}
if let Some(ext) = marker.strip_prefix("*.") {
let suffix = format!(".{ext}");
return std::fs::read_dir(dir)
.map(|entries| {
entries
.flatten()
.any(|e| e.file_name().to_str().is_some_and(|n| n.ends_with(&suffix)))
})
.unwrap_or(false);
}
dir.join(marker).exists()
}
fn file_contains(path: &Path, needle: &str, budget: &ProbeBudget) -> bool {
super::workspace::read_bounded(path, budget).is_some_and(|body| body.contains(needle))
}
fn marker_present_in_any(roots: &[PathBuf], marker: &str, budget: &ProbeBudget) -> bool {
roots
.iter()
.any(|root| marker_present(root, marker, budget))
}
pub(crate) struct MarkerProbe {
roots: Vec<PathBuf>,
budget: ProbeBudget,
truncated: bool,
depth_limited: bool,
}
impl MarkerProbe {
pub(crate) fn new(project_dir: &Path, categories: &AgentCategories) -> Self {
let budget = ProbeBudget::new();
let declared = probe_roots(project_dir, &budget);
let mut roots = declared.roots;
let anchors = MarkerAnchors::from_categories(categories);
let nested = nested_probe_roots(project_dir, &anchors, &budget, &roots);
roots.extend(nested.roots);
Self {
roots,
budget,
truncated: declared.truncated || nested.truncated,
depth_limited: nested.depth_limited,
}
}
pub(crate) fn truncated(&self) -> bool {
self.truncated
}
pub(crate) fn depth_limited(&self) -> bool {
self.depth_limited
}
fn selects(&self, entry: &GatedAgent) -> bool {
entry
.markers
.iter()
.any(|marker| marker_present_in_any(&self.roots, marker, &self.budget))
}
pub(crate) fn detect(&self, entries: &[GatedAgent]) -> BTreeSet<String> {
entries
.iter()
.filter(|entry| self.selects(entry))
.map(|entry| entry.stem.clone())
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::manifest::framework::framework_agent_categories;
use std::fs;
use tempfile::TempDir;
fn detected_engineers(dir: &Path) -> BTreeSet<String> {
let categories = framework_agent_categories().expect("bundled manifest must be valid");
let probe = MarkerProbe::new(dir, &categories);
let mut detected = probe.detect(&categories.language);
detected.extend(probe.detect(&categories.framework));
detected
}
fn detected_platforms(dir: &Path) -> BTreeSet<String> {
let categories = framework_agent_categories().expect("bundled manifest must be valid");
MarkerProbe::new(dir, &categories).detect(&categories.platform)
}
fn touch(dir: &Path, name: &str) {
write(dir, name, "");
}
fn write(dir: &Path, name: &str, body: &str) {
let path = dir.join(name);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, body).unwrap();
}
fn package_json(deps: &[&str]) -> String {
let entries: Vec<String> = deps
.iter()
.map(|d| format!(" \"{d}\": \"1.0.0\""))
.collect();
format!(
"{{\n \"dependencies\": {{\n{}\n }}\n}}\n",
entries.join(",\n")
)
}
#[test]
fn rust_workspace_detects_only_rust_engineer() {
let tmp = TempDir::new().unwrap();
touch(tmp.path(), "Cargo.toml");
let detected = detected_engineers(tmp.path());
assert!(detected.contains("rust-engineer"), "rust must be detected");
for foreign in [
"python-engineer",
"javascript-engineer",
"typescript-engineer",
"golang-engineer",
"java-engineer",
"ruby-engineer",
"php-engineer",
"dart-engineer",
"elixir-engineer",
"phoenix-engineer",
"react-engineer",
"nextjs-engineer",
"svelte-engineer",
"tauri-engineer",
"dotnet-engineer",
] {
assert!(
!detected.contains(foreign),
"{foreign} must not be detected in a Rust-only project"
);
}
}
#[test]
fn js_project_detects_js_family() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "package.json", &package_json(&["lodash"]));
touch(tmp.path(), "tsconfig.json");
let detected = detected_engineers(tmp.path());
assert!(detected.contains("javascript-engineer"));
assert!(detected.contains("typescript-engineer"));
assert!(
!detected.contains("rust-engineer"),
"a JS project must not detect a foreign language engineer"
);
}
#[test]
fn java_project_detects_java_engineer() {
for marker in ["pom.xml", "build.gradle", "build.gradle.kts"] {
let tmp = TempDir::new().unwrap();
touch(tmp.path(), marker);
assert!(
detected_engineers(tmp.path()).contains("java-engineer"),
"`{marker}` alone must select java-engineer"
);
}
}
#[test]
fn dotnet_csproj_detects_dotnet_engineer() {
for marker in ["MyApp.csproj", "MyApp.sln", "Legacy.vbproj"] {
let tmp = TempDir::new().unwrap();
touch(tmp.path(), marker);
assert!(
detected_engineers(tmp.path()).contains("dotnet-engineer"),
"`{marker}` must match the extension glob and select dotnet-engineer"
);
}
let tmp = TempDir::new().unwrap();
touch(tmp.path(), "MyApp.csproj.bak");
assert!(
!detected_engineers(tmp.path()).contains("dotnet-engineer"),
"`.csproj.bak` does not end in `.csproj` and must not match"
);
}
#[test]
fn unknown_project_detects_nothing() {
let tmp = TempDir::new().unwrap();
touch(tmp.path(), "README.md");
assert!(detected_engineers(tmp.path()).is_empty());
}
#[test]
fn bare_js_project_detects_no_framework_engineer() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "package.json", &package_json(&["lodash"]));
let detected = detected_engineers(tmp.path());
assert!(detected.contains("javascript-engineer"));
assert!(detected.contains("typescript-engineer"));
for framework in ["react-engineer", "nextjs-engineer", "svelte-engineer"] {
assert!(
!detected.contains(framework),
"{framework} must NOT fire on a framework-free package.json"
);
}
assert!(!detected.contains("rust-engineer"));
assert!(!detected.contains("python-engineer"));
}
#[test]
fn react_dependency_detects_react_engineer() {
let tmp = TempDir::new().unwrap();
write(
tmp.path(),
"package.json",
&package_json(&["react", "react-dom"]),
);
let detected = detected_engineers(tmp.path());
assert!(detected.contains("react-engineer"));
assert!(
detected.contains("javascript-engineer"),
"language engineers still fire"
);
assert!(!detected.contains("nextjs-engineer"));
assert!(!detected.contains("svelte-engineer"));
}
#[test]
fn content_probe_does_not_match_a_longer_named_sibling() {
let tmp = TempDir::new().unwrap();
write(
tmp.path(),
"package.json",
&package_json(&["react-dom", "react-native", "@types/react"]),
);
assert!(!detected_engineers(tmp.path()).contains("react-engineer"));
let nx = TempDir::new().unwrap();
write(
nx.path(),
"package.json",
&package_json(&["next-auth", "next-themes"]),
);
assert!(!detected_engineers(nx.path()).contains("nextjs-engineer"));
let sv = TempDir::new().unwrap();
write(sv.path(), "package.json", &package_json(&["svelte-check"]));
assert!(!detected_engineers(sv.path()).contains("svelte-engineer"));
}
#[test]
fn nextjs_detects_by_config_or_dependency() {
for marker in ["next.config.js", "next.config.mjs", "next.config.ts"] {
let tmp = TempDir::new().unwrap();
touch(tmp.path(), marker);
assert!(
detected_engineers(tmp.path()).contains("nextjs-engineer"),
"{marker} must select nextjs-engineer"
);
}
let dep_only = TempDir::new().unwrap();
write(
dep_only.path(),
"package.json",
&package_json(&["next", "react"]),
);
assert!(detected_engineers(dep_only.path()).contains("nextjs-engineer"));
}
#[test]
fn svelte_detects_by_config_or_dependency() {
for marker in ["svelte.config.js", "svelte.config.ts"] {
let tmp = TempDir::new().unwrap();
touch(tmp.path(), marker);
assert!(
detected_engineers(tmp.path()).contains("svelte-engineer"),
"{marker} must select svelte-engineer"
);
}
let dep_only = TempDir::new().unwrap();
write(dep_only.path(), "package.json", &package_json(&["svelte"]));
assert!(detected_engineers(dep_only.path()).contains("svelte-engineer"));
}
#[test]
fn plain_elixir_detects_elixir_engineer_only() {
let tmp = TempDir::new().unwrap();
write(
tmp.path(),
"mix.exs",
"defmodule My.MixProject do\n defp deps, do: [{:jason, \"~> 1.4\"}]\nend\n",
);
let detected = detected_engineers(tmp.path());
assert!(detected.contains("elixir-engineer"));
assert!(
!detected.contains("phoenix-engineer"),
"a non-Phoenix Elixir project must not get the Phoenix specialist"
);
}
#[test]
fn phoenix_dependency_detects_both_elixir_and_phoenix() {
let tmp = TempDir::new().unwrap();
write(
tmp.path(),
"mix.exs",
"defp deps do\n [{:phoenix, \"~> 1.7.14\"}, {:phoenix_live_view, \"~> 1.0\"}]\nend\n",
);
let detected = detected_engineers(tmp.path());
assert!(detected.contains("phoenix-engineer"));
assert!(
detected.contains("elixir-engineer"),
"Phoenix apps are Elixir apps"
);
let live_only = TempDir::new().unwrap();
write(
live_only.path(),
"mix.exs",
"defp deps do\n [{:phoenix_live_view, \"~> 1.0\"}]\nend\n",
);
assert!(
!detected_engineers(live_only.path()).contains("phoenix-engineer"),
"the phoenix_live_view dep alone must not satisfy the phoenix probe"
);
}
#[test]
fn content_probe_matches_a_declared_dependency() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "mix.exs", "[{:phoenix, \"~> 1.7\"}]");
let budget = ProbeBudget::new();
assert!(file_contains(
&tmp.path().join("mix.exs"),
"{:phoenix,",
&budget
));
assert!(!file_contains(
&tmp.path().join("mix.exs"),
"{:ecto,",
&budget
));
assert!(
!file_contains(&tmp.path().join("absent.exs"), "{:phoenix,", &budget),
"a missing file is an absent marker, never an error"
);
assert!(
!file_contains(tmp.path(), "{:phoenix,", &budget),
"a directory is not a readable marker file"
);
}
#[test]
fn content_probe_ignores_an_oversized_file() {
let tmp = TempDir::new().unwrap();
let mut body = String::from("{:phoenix, \"~> 1.7\"}");
body.push_str(&"x".repeat(super::super::workspace::MAX_MANIFEST_BYTES as usize + 1));
write(tmp.path(), "mix.exs", &body);
let budget = ProbeBudget::new();
assert!(!file_contains(
&tmp.path().join("mix.exs"),
"{:phoenix,",
&budget
));
}
#[test]
fn npm_monorepo_member_declares_react() {
let tmp = TempDir::new().unwrap();
write(
tmp.path(),
"package.json",
r#"{"name":"root","workspaces":["apps/*","packages/*"]}"#,
);
write(
tmp.path(),
"apps/web/package.json",
r#"{"dependencies":{"react":"^18","next":"^15"}}"#,
);
touch(tmp.path(), "apps/web/next.config.js");
let detected = detected_engineers(tmp.path());
assert!(detected.contains("react-engineer"), "{detected:?}");
assert!(detected.contains("nextjs-engineer"), "{detected:?}");
assert!(
detected.contains("javascript-engineer"),
"the root package.json still selects the language engineers"
);
}
#[test]
fn monorepo_without_the_framework_still_misses_it() {
let tmp = TempDir::new().unwrap();
write(
tmp.path(),
"package.json",
r#"{"workspaces":["packages/*"]}"#,
);
write(
tmp.path(),
"packages/util/package.json",
r#"{"dependencies":{"lodash":"^4"}}"#,
);
let detected = detected_engineers(tmp.path());
assert!(detected.contains("javascript-engineer"));
for framework in ["react-engineer", "nextjs-engineer", "svelte-engineer"] {
assert!(!detected.contains(framework), "{framework} must not fire");
}
}
#[test]
fn elixir_umbrella_member_declares_phoenix() {
let tmp = TempDir::new().unwrap();
write(
tmp.path(),
"mix.exs",
"def project do\n [apps_path: \"apps\"]\nend\n",
);
write(
tmp.path(),
"apps/my_app_web/mix.exs",
"defp deps do\n [{:phoenix, \"~> 1.7\"}]\nend\n",
);
let detected = detected_engineers(tmp.path());
assert!(detected.contains("elixir-engineer"), "root mix.exs");
assert!(
detected.contains("phoenix-engineer"),
"the umbrella member's phoenix dep must be found: {detected:?}"
);
}
#[test]
fn monorepo_member_platform_marker_is_found() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "package.json", r#"{"workspaces":["apps/*"]}"#);
write(tmp.path(), "apps/web/vercel.json", "{}");
let detected = detected_platforms(tmp.path());
assert!(detected.contains("vercel-ops"), "{detected:?}");
assert!(!detected.contains("gcp-ops"));
}
#[test]
fn vercel_marker_detects_vercel_ops() {
for marker in ["vercel.json", ".vercelignore", ".vercel/project.json"] {
let tmp = TempDir::new().unwrap();
touch(tmp.path(), marker);
let detected = detected_platforms(tmp.path());
assert!(
detected.contains("vercel-ops"),
"vercel-ops must be detected for {marker}"
);
assert!(
!detected.contains("gcp-ops"),
"gcp-ops must NOT be detected for {marker}"
);
}
}
#[test]
fn gcp_marker_detects_gcp_ops() {
for marker in [
"app.yaml",
"cloudbuild.yaml",
"cloudbuild.yml",
".gcloudignore",
] {
let tmp = TempDir::new().unwrap();
touch(tmp.path(), marker);
let detected = detected_platforms(tmp.path());
assert!(
detected.contains("gcp-ops"),
"gcp-ops must be detected for {marker}"
);
assert!(!detected.contains("vercel-ops"));
}
}
#[test]
fn no_platform_marker_detects_nothing() {
let tmp = TempDir::new().unwrap();
touch(tmp.path(), "Cargo.toml");
touch(tmp.path(), "package.json");
assert!(
detected_platforms(tmp.path()).is_empty(),
"no platform marker → empty platform set"
);
assert!(
!detected_engineers(tmp.path()).is_empty(),
"the same project DOES detect languages — so the empty platform set \
cannot be an artifact of an unreadable directory"
);
}
#[test]
fn an_entry_with_no_markers_never_selects() {
let tmp = TempDir::new().unwrap();
touch(tmp.path(), "Cargo.toml");
let ungated = GatedAgent {
stem: "ungated-engineer".to_string(),
markers: Vec::new(),
};
let categories = framework_agent_categories().expect("bundled manifest must be valid");
assert!(
MarkerProbe::new(tmp.path(), &categories)
.detect(&[ungated])
.is_empty(),
"an entry declaring no markers can never be selected"
);
}
}