use std::path::{Path, PathBuf};
use crate::detector::{Detector, DetectorHit, DetectorRegistry};
use crate::kind::KindId;
use crate::workspace::WorkspaceKindId;
fn signal_if_exists(dir: &Path, name: &str, out: &mut Vec<String>) -> bool {
if dir.join(name).exists() {
out.push(name.to_string());
true
} else {
false
}
}
fn any_exists(dir: &Path, names: &[&str], out: &mut Vec<String>) -> bool {
let mut found = false;
for n in names {
if signal_if_exists(dir, n, out) {
found = true;
}
}
found
}
fn has_extension(dir: &Path, ext: &str) -> bool {
let Ok(read) = std::fs::read_dir(dir) else {
return false;
};
for entry in read.flatten() {
if entry.path().extension().map(|e| e == ext).unwrap_or(false) {
return true;
}
}
false
}
fn expand_member(root: &Path, pattern: &str) -> Vec<PathBuf> {
let pattern = pattern.trim();
if pattern.is_empty() {
return Vec::new();
}
if !pattern.contains('*') {
let p = root.join(pattern);
return if p.is_dir() { vec![p] } else { Vec::new() };
}
if let Some(prefix) = pattern.strip_suffix("/*") {
let parent = if prefix.is_empty() {
root.to_path_buf()
} else {
root.join(prefix)
};
let Ok(entries) = std::fs::read_dir(&parent) else {
return Vec::new();
};
let mut out: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect();
out.sort();
return out;
}
Vec::new()
}
fn read_toml(path: &Path) -> Option<toml::Value> {
let text = std::fs::read_to_string(path).ok()?;
toml::from_str(&text).ok()
}
fn read_json(path: &Path) -> Option<serde_json::Value> {
let text = std::fs::read_to_string(path).ok()?;
let cleaned: String = text
.lines()
.map(|l| {
if let Some(idx) = l.find("//") {
&l[..idx]
} else {
l
}
})
.collect::<Vec<_>>()
.join("\n");
serde_json::from_str(&cleaned).ok()
}
fn extract_yaml_packages(text: &str) -> Vec<String> {
let mut out = Vec::new();
let mut in_block = false;
for raw in text.lines() {
let line = raw.trim_end_matches('\r');
let trimmed = line.trim_start();
if !in_block {
if let Some(rest) = trimmed.strip_prefix("packages:") {
let rest = rest.trim();
if rest.starts_with('[') {
let inner = rest.trim_start_matches('[').trim_end_matches(']');
for item in inner.split(',') {
let s = item.trim().trim_matches(|c| c == '"' || c == '\'');
if !s.is_empty() {
out.push(s.to_string());
}
}
return out;
}
in_block = true;
}
continue;
}
if trimmed.starts_with("- ") {
let s = trimmed[2..].trim().trim_matches(|c| c == '"' || c == '\'');
if !s.is_empty() {
out.push(s.to_string());
}
continue;
}
if trimmed.is_empty() {
continue;
}
if !line.starts_with(' ') && !line.starts_with('\t') {
break;
}
break;
}
out
}
#[derive(Default)]
pub struct CargoDetector;
impl Detector for CargoDetector {
fn name(&self) -> &str { "cargo" }
fn priority(&self) -> i32 { 100 }
fn declared_project_kind(&self) -> Option<KindId> { Some(KindId::RUST) }
fn declared_workspace_kind(&self) -> Option<WorkspaceKindId> { Some(WorkspaceKindId::Cargo) }
fn detect(&self, dir: &Path) -> Option<DetectorHit> {
let manifest = dir.join("Cargo.toml");
if !manifest.is_file() {
return None;
}
let signals = vec!["Cargo.toml".to_string()];
let parsed = read_toml(&manifest);
let has_package = parsed
.as_ref()
.and_then(|v| v.get("package"))
.is_some();
let workspace_table = parsed.as_ref().and_then(|v| v.get("workspace"));
let has_workspace = workspace_table.is_some();
let members: Vec<PathBuf> = workspace_table
.and_then(|w| w.get("members"))
.and_then(|m| m.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.flat_map(|p| expand_member(dir, p))
.collect()
})
.unwrap_or_default();
match (has_package, has_workspace) {
(_, false) if parsed.is_none() => Some(DetectorHit::Project {
kind: KindId::RUST,
signals,
}),
(true, false) => Some(DetectorHit::Project {
kind: KindId::RUST,
signals,
}),
(false, true) => Some(DetectorHit::Workspace {
kind: WorkspaceKindId::Cargo,
members,
signals,
}),
(true, true) => Some(DetectorHit::Both {
project_kind: KindId::RUST,
workspace_kind: WorkspaceKindId::Cargo,
members,
signals,
}),
(false, false) => Some(DetectorHit::Project {
kind: KindId::RUST,
signals,
}),
}
}
}
#[derive(Default)]
pub struct BunDetector;
impl Detector for BunDetector {
fn name(&self) -> &str { "bun" }
fn priority(&self) -> i32 { 80 }
fn declared_project_kind(&self) -> Option<KindId> { Some(KindId::BUN) }
fn detect(&self, dir: &Path) -> Option<DetectorHit> {
let mut signals = Vec::new();
if any_exists(dir, &["bun.lock", "bun.lockb", "bunfig.toml"], &mut signals) {
Some(DetectorHit::Project {
kind: KindId::BUN,
signals,
})
} else {
None
}
}
}
#[derive(Default)]
pub struct DenoDetector;
impl Detector for DenoDetector {
fn name(&self) -> &str { "deno" }
fn priority(&self) -> i32 { 70 }
fn declared_project_kind(&self) -> Option<KindId> { Some(KindId::DENO) }
fn declared_workspace_kind(&self) -> Option<WorkspaceKindId> { Some(WorkspaceKindId::Deno) }
fn detect(&self, dir: &Path) -> Option<DetectorHit> {
let mut signals = Vec::new();
if !any_exists(dir, &["deno.json", "deno.jsonc", "deno.lock"], &mut signals) {
return None;
}
let manifest = if dir.join("deno.json").is_file() {
dir.join("deno.json")
} else {
dir.join("deno.jsonc")
};
let members: Vec<PathBuf> = read_json(&manifest)
.as_ref()
.and_then(|v| v.get("workspace"))
.and_then(|w| w.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.flat_map(|p| expand_member(dir, p.trim_start_matches("./")))
.collect()
})
.unwrap_or_default();
if !members.is_empty() {
return Some(DetectorHit::Both {
project_kind: KindId::DENO,
workspace_kind: WorkspaceKindId::Deno,
members,
signals,
});
}
Some(DetectorHit::Project {
kind: KindId::DENO,
signals,
})
}
}
#[derive(Default)]
pub struct NodeDetector;
impl Detector for NodeDetector {
fn name(&self) -> &str { "node" }
fn priority(&self) -> i32 { 50 }
fn declared_project_kind(&self) -> Option<KindId> { Some(KindId::NODE) }
fn detect(&self, dir: &Path) -> Option<DetectorHit> {
let mut signals = Vec::new();
if signal_if_exists(dir, "package.json", &mut signals) {
Some(DetectorHit::Project {
kind: KindId::NODE,
signals,
})
} else {
None
}
}
}
#[derive(Default)]
pub struct PythonDetector;
impl Detector for PythonDetector {
fn name(&self) -> &str { "python" }
fn priority(&self) -> i32 { 40 }
fn declared_project_kind(&self) -> Option<KindId> { Some(KindId::PYTHON) }
fn detect(&self, dir: &Path) -> Option<DetectorHit> {
let mut signals = Vec::new();
if any_exists(
dir,
&["pyproject.toml", "requirements.txt", "setup.py", "setup.cfg"],
&mut signals,
) {
Some(DetectorHit::Project {
kind: KindId::PYTHON,
signals,
})
} else {
None
}
}
}
#[derive(Default)]
pub struct LuaDetector;
impl Detector for LuaDetector {
fn name(&self) -> &str { "lua" }
fn priority(&self) -> i32 { 30 }
fn declared_project_kind(&self) -> Option<KindId> { Some(KindId::LUA) }
fn detect(&self, dir: &Path) -> Option<DetectorHit> {
let mut signals = Vec::new();
let luarc = signal_if_exists(dir, ".luarc.json", &mut signals);
let rockspec = has_extension(dir, "rockspec");
if rockspec {
signals.push("*.rockspec".to_string());
}
if luarc || rockspec {
Some(DetectorHit::Project {
kind: KindId::LUA,
signals,
})
} else {
None
}
}
}
#[derive(Default)]
pub struct CppDetector;
impl Detector for CppDetector {
fn name(&self) -> &str { "cpp" }
fn priority(&self) -> i32 { 20 }
fn declared_project_kind(&self) -> Option<KindId> { Some(KindId::CPP) }
fn detect(&self, dir: &Path) -> Option<DetectorHit> {
let mut signals = Vec::new();
if !signal_if_exists(dir, "CMakeLists.txt", &mut signals) {
return None;
}
if has_extension(dir, "cpp") || has_extension(dir, "cc") || has_extension(dir, "cxx") {
Some(DetectorHit::Project {
kind: KindId::CPP,
signals,
})
} else {
None
}
}
}
#[derive(Default)]
pub struct CDetector;
impl Detector for CDetector {
fn name(&self) -> &str { "c" }
fn priority(&self) -> i32 { 10 }
fn declared_project_kind(&self) -> Option<KindId> { Some(KindId::C) }
fn detect(&self, dir: &Path) -> Option<DetectorHit> {
let mut signals = Vec::new();
if !signal_if_exists(dir, "CMakeLists.txt", &mut signals) {
return None;
}
if has_extension(dir, "cpp") || has_extension(dir, "cc") || has_extension(dir, "cxx") {
return None;
}
if has_extension(dir, "c") {
Some(DetectorHit::Project {
kind: KindId::C,
signals,
})
} else {
None
}
}
}
fn read_pkg_workspaces(dir: &Path) -> Option<Vec<String>> {
let v = read_json(&dir.join("package.json"))?;
let ws = v.get("workspaces")?;
if let Some(arr) = ws.as_array() {
return Some(
arr.iter()
.filter_map(|x| x.as_str().map(|s| s.to_string()))
.collect(),
);
}
ws.get("packages")
.and_then(|p| p.as_array())
.map(|arr| {
arr.iter()
.filter_map(|x| x.as_str().map(|s| s.to_string()))
.collect()
})
}
fn has_any(dir: &Path, names: &[&str]) -> bool {
names.iter().any(|n| dir.join(n).exists())
}
#[derive(Default)]
pub struct BunWorkspaceDetector;
impl Detector for BunWorkspaceDetector {
fn name(&self) -> &str { "bun-workspace" }
fn priority(&self) -> i32 { 80 }
fn declared_workspace_kind(&self) -> Option<WorkspaceKindId> { Some(WorkspaceKindId::Bun) }
fn detect(&self, dir: &Path) -> Option<DetectorHit> {
if !has_any(dir, &["bun.lockb", "bun.lock"]) {
return None;
}
let patterns = read_pkg_workspaces(dir)?;
if patterns.is_empty() {
return None;
}
let mut signals = Vec::new();
let _ = any_exists(dir, &["bun.lockb", "bun.lock", "package.json"], &mut signals);
let members = patterns
.iter()
.flat_map(|p| expand_member(dir, p))
.collect();
Some(DetectorHit::Workspace {
kind: WorkspaceKindId::Bun,
members,
signals,
})
}
}
#[derive(Default)]
pub struct PnpmWorkspaceDetector;
impl Detector for PnpmWorkspaceDetector {
fn name(&self) -> &str { "pnpm-workspace" }
fn priority(&self) -> i32 { 70 }
fn declared_workspace_kind(&self) -> Option<WorkspaceKindId> { Some(WorkspaceKindId::Pnpm) }
fn detect(&self, dir: &Path) -> Option<DetectorHit> {
let manifest = dir.join("pnpm-workspace.yaml");
if !manifest.is_file() {
return None;
}
let text = std::fs::read_to_string(&manifest).ok().unwrap_or_default();
let patterns = extract_yaml_packages(&text);
let members = patterns
.iter()
.flat_map(|p| expand_member(dir, p))
.collect();
Some(DetectorHit::Workspace {
kind: WorkspaceKindId::Pnpm,
members,
signals: vec!["pnpm-workspace.yaml".to_string()],
})
}
}
#[derive(Default)]
pub struct YarnWorkspaceDetector;
impl Detector for YarnWorkspaceDetector {
fn name(&self) -> &str { "yarn-workspace" }
fn priority(&self) -> i32 { 60 }
fn declared_workspace_kind(&self) -> Option<WorkspaceKindId> { Some(WorkspaceKindId::Yarn) }
fn detect(&self, dir: &Path) -> Option<DetectorHit> {
if has_any(dir, &["bun.lockb", "bun.lock"]) {
return None;
}
let yarnrc = dir.join(".yarnrc.yml").is_file();
let yarn_dir = dir.join(".yarn").is_dir();
if !yarnrc && !yarn_dir {
return None;
}
let patterns = read_pkg_workspaces(dir)?;
if patterns.is_empty() {
return None;
}
let mut signals = Vec::new();
if yarnrc {
signals.push(".yarnrc.yml".to_string());
}
if yarn_dir {
signals.push(".yarn/".to_string());
}
signals.push("package.json".to_string());
let members = patterns
.iter()
.flat_map(|p| expand_member(dir, p))
.collect();
Some(DetectorHit::Workspace {
kind: WorkspaceKindId::Yarn,
members,
signals,
})
}
}
#[derive(Default)]
pub struct NpmWorkspaceDetector;
impl Detector for NpmWorkspaceDetector {
fn name(&self) -> &str { "npm-workspace" }
fn priority(&self) -> i32 { 50 }
fn declared_workspace_kind(&self) -> Option<WorkspaceKindId> { Some(WorkspaceKindId::Npm) }
fn detect(&self, dir: &Path) -> Option<DetectorHit> {
if has_any(
dir,
&[
"bun.lockb",
"bun.lock",
".yarnrc.yml",
".yarn",
"pnpm-workspace.yaml",
],
) {
return None;
}
let patterns = read_pkg_workspaces(dir)?;
if patterns.is_empty() {
return None;
}
let members = patterns
.iter()
.flat_map(|p| expand_member(dir, p))
.collect();
Some(DetectorHit::Workspace {
kind: WorkspaceKindId::Npm,
members,
signals: vec!["package.json".to_string()],
})
}
}
#[derive(Default)]
pub struct GoWorkspaceDetector;
impl Detector for GoWorkspaceDetector {
fn name(&self) -> &str { "go-workspace" }
fn priority(&self) -> i32 { 50 }
fn declared_workspace_kind(&self) -> Option<WorkspaceKindId> { Some(WorkspaceKindId::Go) }
fn detect(&self, dir: &Path) -> Option<DetectorHit> {
let manifest = dir.join("go.work");
if !manifest.is_file() {
return None;
}
let text = std::fs::read_to_string(&manifest).ok().unwrap_or_default();
let members = parse_go_work_uses(&text)
.into_iter()
.flat_map(|p| {
let stripped = p.trim_start_matches("./").to_string();
expand_member(dir, &stripped)
})
.collect();
Some(DetectorHit::Workspace {
kind: WorkspaceKindId::Go,
members,
signals: vec!["go.work".to_string()],
})
}
}
fn parse_go_work_uses(text: &str) -> Vec<String> {
let mut out = Vec::new();
let mut in_block = false;
for raw in text.lines() {
let line = raw.split("//").next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
if in_block {
if line == ")" {
in_block = false;
continue;
}
let s = line.trim_matches(|c| c == '"' || c == '\'').to_string();
if !s.is_empty() {
out.push(s);
}
continue;
}
if let Some(rest) = line.strip_prefix("use ") {
let rest = rest.trim();
if rest == "(" {
in_block = true;
continue;
}
let s = rest
.trim_start_matches('(')
.trim_end_matches(')')
.trim()
.trim_matches(|c| c == '"' || c == '\'')
.to_string();
if !s.is_empty() {
out.push(s);
}
}
}
out
}
#[derive(Default)]
pub struct LernaWorkspaceDetector;
impl Detector for LernaWorkspaceDetector {
fn name(&self) -> &str { "lerna-workspace" }
fn priority(&self) -> i32 { 40 }
fn declared_workspace_kind(&self) -> Option<WorkspaceKindId> { Some(WorkspaceKindId::Lerna) }
fn detect(&self, dir: &Path) -> Option<DetectorHit> {
if !dir.join("lerna.json").is_file() {
return None;
}
let members: Vec<PathBuf> = read_json(&dir.join("lerna.json"))
.as_ref()
.and_then(|v| v.get("packages"))
.and_then(|p| p.as_array())
.map(|arr| {
arr.iter()
.filter_map(|x| x.as_str())
.flat_map(|p| expand_member(dir, p))
.collect()
})
.unwrap_or_default();
Some(DetectorHit::Workspace {
kind: WorkspaceKindId::Lerna,
members,
signals: vec!["lerna.json".to_string()],
})
}
}
#[derive(Default)]
pub struct NxWorkspaceDetector;
impl Detector for NxWorkspaceDetector {
fn name(&self) -> &str { "nx-workspace" }
fn priority(&self) -> i32 { 40 }
fn declared_workspace_kind(&self) -> Option<WorkspaceKindId> { Some(WorkspaceKindId::Nx) }
fn detect(&self, dir: &Path) -> Option<DetectorHit> {
if !dir.join("nx.json").is_file() {
return None;
}
Some(DetectorHit::Workspace {
kind: WorkspaceKindId::Nx,
members: Vec::new(),
signals: vec!["nx.json".to_string()],
})
}
}
#[derive(Default)]
pub struct TurborepoWorkspaceDetector;
impl Detector for TurborepoWorkspaceDetector {
fn name(&self) -> &str { "turborepo-workspace" }
fn priority(&self) -> i32 { 40 }
fn declared_workspace_kind(&self) -> Option<WorkspaceKindId> { Some(WorkspaceKindId::Turborepo) }
fn detect(&self, dir: &Path) -> Option<DetectorHit> {
if !dir.join("turbo.json").is_file() {
return None;
}
Some(DetectorHit::Workspace {
kind: WorkspaceKindId::Turborepo,
members: Vec::new(),
signals: vec!["turbo.json".to_string()],
})
}
}
#[derive(Default)]
pub struct MiraWorkspaceDetector;
impl Detector for MiraWorkspaceDetector {
fn name(&self) -> &str { "mira-workspace" }
fn priority(&self) -> i32 { 40 }
fn declared_workspace_kind(&self) -> Option<WorkspaceKindId> { Some(WorkspaceKindId::Mira) }
fn detect(&self, dir: &Path) -> Option<DetectorHit> {
let Ok(entries) = std::fs::read_dir(dir) else {
return None;
};
let mut signal = None;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map(|e| e == "sxb").unwrap_or(false) {
signal = Some(
path.file_name()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
.unwrap_or_else(|| "*.sxb".to_string()),
);
break;
}
}
signal.map(|s| DetectorHit::Workspace {
kind: WorkspaceKindId::Mira,
members: Vec::new(),
signals: vec![s],
})
}
}
pub fn register_all(registry: &mut DetectorRegistry) {
registry
.add(CargoDetector)
.add(BunDetector)
.add(DenoDetector)
.add(NodeDetector)
.add(PythonDetector)
.add(LuaDetector)
.add(CppDetector)
.add(CDetector);
registry
.add(BunWorkspaceDetector)
.add(PnpmWorkspaceDetector)
.add(YarnWorkspaceDetector)
.add(NpmWorkspaceDetector)
.add(GoWorkspaceDetector)
.add(LernaWorkspaceDetector)
.add(NxWorkspaceDetector)
.add(TurborepoWorkspaceDetector)
.add(MiraWorkspaceDetector);
}