use std::collections::HashSet;
use std::path::Path;
#[derive(Clone, Debug, Default)]
pub struct DetectedStack {
pub extensions: HashSet<String>,
pub ignores: Vec<String>,
pub preset_name: Option<String>,
pub description: String,
pub is_library: bool,
pub py_roots: Vec<std::path::PathBuf>,
}
impl DetectedStack {
pub fn is_empty(&self) -> bool {
self.extensions.is_empty() && self.preset_name.is_none()
}
}
fn detect_python_roots(root: &Path) -> Vec<std::path::PathBuf> {
let mut roots = Vec::new();
let lib_dir = root.join("Lib");
if lib_dir.is_dir() {
let has_python_dir = root.join("Python").is_dir();
let has_modules_dir = root.join("Modules").is_dir();
let has_include_dir = root.join("Include").is_dir();
if has_python_dir || has_modules_dir || has_include_dir {
roots.push(std::path::PathBuf::from("Lib"));
}
}
let pyproject_path = root.join("pyproject.toml");
if pyproject_path.exists()
&& let Ok(content) = std::fs::read_to_string(&pyproject_path)
{
for line in content.lines() {
let trimmed = line.trim();
if (trimmed.starts_with("packages") || trimmed.starts_with("package-dir"))
&& trimmed.contains('=')
{
if let Some(value_part) = trimmed.split('=').nth(1) {
for segment in value_part.split(['"', '\'', ',', '[', ']', '{', '}']) {
let dir_name = segment.trim();
if !dir_name.is_empty()
&& !dir_name.contains('=')
&& !dir_name.contains(':')
&& dir_name != "src"
&& dir_name != "."
{
let dir_path = root.join(dir_name);
if dir_path.is_dir() && !roots.contains(&dir_name.into()) {
roots.push(std::path::PathBuf::from(dir_name));
}
}
}
}
}
}
}
roots
}
pub fn detect_stack(root: &Path) -> DetectedStack {
let mut result = DetectedStack::default();
let mut detected_parts: Vec<&str> = Vec::new();
let has_cargo_toml = root.join("Cargo.toml").exists() || has_cargo_in_subdir(root);
if has_cargo_toml {
result.extensions.insert("rs".to_string());
result.ignores.push("target".to_string());
detected_parts.push("Rust");
}
if root.join("pubspec.yaml").exists() {
result.extensions.insert("dart".to_string());
result.ignores.push(".dart_tool".to_string());
result.ignores.push("build".to_string());
result.ignores.push(".packages".to_string());
detected_parts.push("Dart/Flutter");
}
if root.join("go.mod").exists()
|| root
.read_dir()
.ok()
.map(|entries| {
entries.flatten().any(|entry| {
entry
.path()
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("go"))
})
})
.unwrap_or(false)
{
result.extensions.insert("go".to_string());
result.ignores.push("vendor".to_string());
detected_parts.push("Go");
}
if root.join("src-tauri").exists() {
result.preset_name = Some("tauri".to_string());
result.extensions.insert("rs".to_string());
result.extensions.insert("ts".to_string());
result.extensions.insert("tsx".to_string());
result.extensions.insert("js".to_string());
result.extensions.insert("jsx".to_string());
result.extensions.insert("css".to_string());
result.ignores.push("target".to_string());
result.ignores.push("node_modules".to_string());
result.ignores.push("dist".to_string());
detected_parts.push("Tauri");
}
let has_tsconfig = root.join("tsconfig.json").exists();
let has_package_json = root.join("package.json").exists();
if has_tsconfig || has_package_json {
result.extensions.insert("ts".to_string());
result.extensions.insert("tsx".to_string());
result.extensions.insert("js".to_string());
result.extensions.insert("jsx".to_string());
result.extensions.insert("mjs".to_string());
result.extensions.insert("cjs".to_string());
if !result.ignores.contains(&"node_modules".to_string()) {
result.ignores.push("node_modules".to_string());
}
if !result.ignores.contains(&"dist".to_string()) {
result.ignores.push("dist".to_string());
}
if has_tsconfig && !detected_parts.contains(&"Tauri") {
detected_parts.push("TypeScript");
} else if has_package_json && !detected_parts.contains(&"Tauri") {
detected_parts.push("JavaScript");
}
if is_npm_library(root) {
result.is_library = true;
if !detected_parts.contains(&"Library") {
detected_parts.push("Library");
}
}
}
let vite_extensions = ["js", "ts", "mjs"];
for ext in vite_extensions {
if root.join(format!("vite.config.{}", ext)).exists() {
if !result.ignores.contains(&"dist".to_string()) {
result.ignores.push("dist".to_string());
}
result.ignores.push("build".to_string());
if !detected_parts.contains(&"Vite") {
detected_parts.push("Vite");
}
break;
}
}
let svelte_exists =
root.join("svelte.config.js").exists() || root.join("svelte.config.ts").exists();
let mut svelte_in_subdir = false;
for subdir in ["apps", "packages"] {
let dir = root.join(subdir);
if dir.is_dir()
&& let Ok(entries) = std::fs::read_dir(&dir)
{
for e in entries.flatten() {
let path = e.path();
if path.is_dir()
&& (path.join("svelte.config.js").exists()
|| path.join("svelte.config.ts").exists())
{
svelte_in_subdir = true;
break;
}
}
}
if svelte_in_subdir {
break;
}
}
if svelte_exists || svelte_in_subdir {
result.extensions.insert("svelte".to_string());
result.ignores.push(".svelte-kit".to_string());
if !detected_parts.contains(&"SvelteKit") {
detected_parts.push("SvelteKit");
}
}
let vue_config_exists =
root.join("vue.config.js").exists() || root.join("vue.config.ts").exists();
let has_vue_files = root.join("src").exists()
&& std::fs::read_dir(root.join("src"))
.map(|entries| {
entries.flatten().any(|e| {
e.path()
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("vue"))
})
})
.unwrap_or(false);
let mut vue_in_subdir = false;
for subdir in ["packages", "packages-private", "apps"] {
let dir = root.join(subdir);
if dir.is_dir()
&& let Ok(entries) = std::fs::read_dir(&dir)
{
for e in entries.flatten() {
let path = e.path();
if path.is_dir() {
let pkg_src = path.join("src");
if pkg_src.is_dir()
&& std::fs::read_dir(&pkg_src)
.map(|entries| {
entries.flatten().any(|e| {
e.path()
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("vue"))
})
})
.unwrap_or(false)
{
vue_in_subdir = true;
break;
}
}
}
}
if vue_in_subdir {
break;
}
}
if vue_config_exists || has_vue_files || vue_in_subdir {
result.extensions.insert("vue".to_string());
if !detected_parts.contains(&"Vue") {
detected_parts.push("Vue");
}
}
if root.join("pyproject.toml").exists() || root.join("setup.py").exists() {
result.extensions.insert("py".to_string());
result.ignores.push(".venv".to_string());
result.ignores.push("venv".to_string());
result.ignores.push("__pycache__".to_string());
result.ignores.push(".pytest_cache".to_string());
result.ignores.push(".mypy_cache".to_string());
result.ignores.push(".ruff_cache".to_string());
result.ignores.push("*.egg-info".to_string());
result.ignores.push(".eggs".to_string());
result.ignores.push("dist".to_string());
result.ignores.push("build".to_string());
result.ignores.push(".tox".to_string());
result.ignores.push(".fastembed_cache".to_string());
result.ignores.push(".cache".to_string());
result.ignores.push("logs".to_string());
result.ignores.push("packaging".to_string());
result.ignores.push(".uv".to_string());
detected_parts.push("Python");
result.py_roots = detect_python_roots(root);
}
if root.join("src").exists() || root.join("styles").exists() {
if result.extensions.contains("ts") || result.extensions.contains("js") {
result.extensions.insert("css".to_string());
}
}
if !result.ignores.is_empty() {
for dir in &[
"e2e",
"scripts",
"mobile",
"__mocks__",
"__fixtures__",
"fixtures",
] {
if !result.ignores.contains(&dir.to_string()) {
result.ignores.push(dir.to_string());
}
}
}
if !detected_parts.is_empty() {
result.description = format!("Detected: {}", detected_parts.join(" + "));
}
result
}
fn has_cargo_in_subdir(root: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(root) else {
return false;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with('.')
|| name_str == "node_modules"
|| name_str == "dist"
|| name_str == "build"
{
continue;
}
if path.join("Cargo.toml").exists() {
return true;
}
}
}
false
}
fn is_npm_library(root: &Path) -> bool {
let package_json_path = root.join("package.json");
if !package_json_path.exists() {
return false;
}
let Ok(content) = std::fs::read_to_string(&package_json_path) else {
return false;
};
let Ok(parsed): Result<serde_json::Value, _> = serde_json::from_str(&content) else {
return false;
};
if parsed.get("exports").is_some() {
return true;
}
let has_main = parsed.get("main").is_some();
let has_module = parsed.get("module").is_some();
let has_types = parsed.get("types").is_some() || parsed.get("typings").is_some();
if has_main || has_module || has_types {
let has_index_html = root.join("index.html").exists();
let has_public_html = root.join("public/index.html").exists();
if !has_index_html && !has_public_html {
return true;
}
}
let packages_dir = root.join("packages");
if packages_dir.is_dir()
&& std::fs::read_dir(&packages_dir)
.map(|entries| {
entries
.flatten()
.filter(|e| e.path().is_dir())
.any(|e| e.path().join("package.json").exists())
})
.unwrap_or(false)
{
return true;
}
false
}
pub fn apply_detected_stack(
root: &Path,
extensions: &mut Option<HashSet<String>>,
ignore_patterns: &mut Vec<String>,
tauri_preset: &mut bool,
library_mode: &mut bool,
py_roots: &mut Vec<std::path::PathBuf>,
verbose: bool,
) {
if extensions.is_some() {
return;
}
if *tauri_preset {
return;
}
let detected = detect_stack(root);
if detected.is_empty() {
return;
}
if verbose && !detected.description.is_empty() {
eprintln!("[loctree][detect] {}", detected.description);
}
if !detected.extensions.is_empty() {
*extensions = Some(detected.extensions);
}
if ignore_patterns.is_empty() {
*ignore_patterns = detected.ignores;
}
if let Some(preset) = detected.preset_name
&& preset == "tauri"
{
*tauri_preset = true;
}
if detected.is_library && !*library_mode {
*library_mode = true;
if verbose {
eprintln!(
"[loctree][detect] Detected library/framework project - enabling library mode"
);
}
}
if py_roots.is_empty() && !detected.py_roots.is_empty() {
*py_roots = detected.py_roots;
if verbose {
let roots_str: Vec<_> = py_roots.iter().map(|p| p.display().to_string()).collect();
eprintln!(
"[loctree][detect] Auto-detected Python roots: {}",
roots_str.join(", ")
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_detect_rust_project() {
let tmp = TempDir::new().expect("create temp dir");
std::fs::write(tmp.path().join("Cargo.toml"), "[package]\nname = \"test\"")
.expect("write Cargo.toml");
let detected = detect_stack(tmp.path());
assert!(detected.extensions.contains("rs"));
assert!(detected.ignores.contains(&"target".to_string()));
}
#[test]
fn test_detect_typescript_project() {
let tmp = TempDir::new().expect("create temp dir");
std::fs::write(tmp.path().join("tsconfig.json"), "{}").expect("write tsconfig.json");
let detected = detect_stack(tmp.path());
assert!(detected.extensions.contains("ts"));
assert!(detected.extensions.contains("tsx"));
assert!(detected.ignores.contains(&"node_modules".to_string()));
}
#[test]
fn test_detect_tauri_project() {
let tmp = TempDir::new().expect("create temp dir");
std::fs::create_dir(tmp.path().join("src-tauri")).expect("create src-tauri dir");
std::fs::write(tmp.path().join("Cargo.toml"), "[package]\nname = \"test\"")
.expect("write Cargo.toml");
std::fs::write(tmp.path().join("package.json"), "{}").expect("write package.json");
let detected = detect_stack(tmp.path());
assert_eq!(detected.preset_name, Some("tauri".to_string()));
assert!(detected.extensions.contains("rs"));
assert!(detected.extensions.contains("ts"));
assert!(detected.extensions.contains("tsx"));
}
#[test]
fn test_detect_python_project() {
let tmp = TempDir::new().expect("create temp dir");
std::fs::write(
tmp.path().join("pyproject.toml"),
"[project]\nname = \"test\"",
)
.expect("write pyproject.toml");
let detected = detect_stack(tmp.path());
assert!(detected.extensions.contains("py"));
assert!(detected.ignores.contains(&".venv".to_string()));
assert!(detected.ignores.contains(&"__pycache__".to_string()));
}
#[test]
fn test_detect_empty_project() {
let tmp = TempDir::new().expect("create temp dir");
let detected = detect_stack(tmp.path());
assert!(detected.is_empty());
}
#[test]
fn test_detect_mixed_project() {
let tmp = TempDir::new().expect("create temp dir");
std::fs::write(tmp.path().join("Cargo.toml"), "").expect("write Cargo.toml");
std::fs::write(tmp.path().join("pyproject.toml"), "").expect("write pyproject.toml");
let detected = detect_stack(tmp.path());
assert!(detected.extensions.contains("rs"));
assert!(detected.extensions.contains("py"));
}
#[test]
fn test_detect_vite_project() {
let tmp = TempDir::new().expect("create temp dir");
std::fs::write(tmp.path().join("vite.config.ts"), "export default {}").expect("write");
std::fs::write(tmp.path().join("package.json"), "{}").expect("write");
let detected = detect_stack(tmp.path());
assert!(detected.ignores.contains(&"build".to_string()));
assert!(detected.description.contains("Vite"));
}
#[test]
fn test_detect_javascript_only() {
let tmp = TempDir::new().expect("create temp dir");
std::fs::write(tmp.path().join("package.json"), "{}").expect("write package.json");
let detected = detect_stack(tmp.path());
assert!(detected.extensions.contains("js"));
assert!(detected.description.contains("JavaScript"));
}
#[test]
fn test_detect_with_src_adds_css() {
let tmp = TempDir::new().expect("create temp dir");
std::fs::write(tmp.path().join("package.json"), "{}").expect("write package.json");
std::fs::create_dir(tmp.path().join("src")).expect("create src");
let detected = detect_stack(tmp.path());
assert!(detected.extensions.contains("css"));
}
#[test]
fn test_apply_detected_stack_skips_if_extensions_set() {
let tmp = TempDir::new().expect("create temp dir");
std::fs::write(tmp.path().join("Cargo.toml"), "").expect("write");
let mut extensions = Some(HashSet::from(["py".to_string()]));
let mut ignores = Vec::new();
let mut tauri = false;
let mut library_mode = false;
apply_detected_stack(
tmp.path(),
&mut extensions,
&mut ignores,
&mut tauri,
&mut library_mode,
&mut Vec::new(),
false,
);
assert!(extensions.as_ref().unwrap().contains("py"));
assert!(!extensions.as_ref().unwrap().contains("rs"));
}
#[test]
fn test_apply_detected_stack_skips_if_tauri_preset() {
let tmp = TempDir::new().expect("create temp dir");
std::fs::write(tmp.path().join("Cargo.toml"), "").expect("write");
let mut extensions: Option<HashSet<String>> = None;
let mut ignores = Vec::new();
let mut tauri = true; let mut library_mode = false;
apply_detected_stack(
tmp.path(),
&mut extensions,
&mut ignores,
&mut tauri,
&mut library_mode,
&mut Vec::new(),
false,
);
assert!(extensions.is_none());
}
#[test]
fn test_apply_detected_stack_applies_tauri() {
let tmp = TempDir::new().expect("create temp dir");
std::fs::create_dir(tmp.path().join("src-tauri")).expect("mkdir");
std::fs::write(tmp.path().join("package.json"), "{}").expect("write");
let mut extensions: Option<HashSet<String>> = None;
let mut ignores = Vec::new();
let mut tauri = false;
let mut library_mode = false;
apply_detected_stack(
tmp.path(),
&mut extensions,
&mut ignores,
&mut tauri,
&mut library_mode,
&mut Vec::new(),
false,
);
assert!(tauri);
assert!(extensions.is_some());
assert!(extensions.as_ref().unwrap().contains("ts"));
}
#[test]
fn test_apply_detected_stack_preserves_user_ignores() {
let tmp = TempDir::new().expect("create temp dir");
std::fs::write(tmp.path().join("Cargo.toml"), "").expect("write");
let mut extensions: Option<HashSet<String>> = None;
let mut ignores = vec!["custom_ignore".to_string()];
let mut tauri = false;
let mut library_mode = false;
apply_detected_stack(
tmp.path(),
&mut extensions,
&mut ignores,
&mut tauri,
&mut library_mode,
&mut Vec::new(),
false,
);
assert_eq!(ignores, vec!["custom_ignore".to_string()]);
}
#[test]
fn test_apply_detected_stack_verbose() {
let tmp = TempDir::new().expect("create temp dir");
std::fs::write(tmp.path().join("Cargo.toml"), "").expect("write");
let mut extensions: Option<HashSet<String>> = None;
let mut ignores = Vec::new();
let mut tauri = false;
let mut library_mode = false;
apply_detected_stack(
tmp.path(),
&mut extensions,
&mut ignores,
&mut tauri,
&mut library_mode,
&mut Vec::new(),
true,
);
}
#[test]
fn test_detected_stack_is_empty() {
let empty = DetectedStack::default();
assert!(empty.is_empty());
let with_ext = DetectedStack {
extensions: HashSet::from(["rs".to_string()]),
..Default::default()
};
assert!(!with_ext.is_empty());
let with_preset = DetectedStack {
preset_name: Some("tauri".to_string()),
..Default::default()
};
assert!(!with_preset.is_empty());
}
#[test]
fn test_detect_rust_in_subdirectory() {
let tmp = TempDir::new().expect("create temp dir");
std::fs::write(tmp.path().join("package.json"), "{}").expect("write package.json");
std::fs::create_dir(tmp.path().join("codex-rs")).expect("mkdir codex-rs");
std::fs::write(
tmp.path().join("codex-rs").join("Cargo.toml"),
"[package]\nname = \"test\"",
)
.expect("write Cargo.toml");
let detected = detect_stack(tmp.path());
assert!(detected.extensions.contains("rs"));
assert!(detected.extensions.contains("js"));
assert!(detected.description.contains("JavaScript"));
assert!(detected.description.contains("Rust"));
}
#[test]
fn test_has_cargo_in_subdir() {
let tmp = TempDir::new().expect("create temp dir");
assert!(!has_cargo_in_subdir(tmp.path()));
std::fs::create_dir(tmp.path().join("src")).expect("mkdir");
assert!(!has_cargo_in_subdir(tmp.path()));
std::fs::create_dir(tmp.path().join("backend")).expect("mkdir");
std::fs::write(tmp.path().join("backend").join("Cargo.toml"), "").expect("write");
assert!(has_cargo_in_subdir(tmp.path()));
}
#[test]
fn test_has_cargo_in_subdir_skips_hidden() {
let tmp = TempDir::new().expect("create temp dir");
std::fs::create_dir(tmp.path().join(".hidden")).expect("mkdir");
std::fs::write(tmp.path().join(".hidden").join("Cargo.toml"), "").expect("write");
assert!(!has_cargo_in_subdir(tmp.path()));
}
#[test]
fn test_detect_library_with_exports_field() {
let tmp = TempDir::new().expect("create temp dir");
std::fs::write(
tmp.path().join("package.json"),
r#"{"name": "solid-js", "exports": {"./jsx-runtime": "./jsx-runtime/index.js"}}"#,
)
.expect("write package.json");
let detected = detect_stack(tmp.path());
assert!(
detected.is_library,
"Should detect library project with exports field"
);
assert!(detected.description.contains("Library"));
}
#[test]
fn test_detect_library_with_main_field_no_html() {
let tmp = TempDir::new().expect("create temp dir");
std::fs::write(
tmp.path().join("package.json"),
r#"{"name": "some-lib", "main": "dist/index.js", "types": "dist/index.d.ts"}"#,
)
.expect("write package.json");
let detected = detect_stack(tmp.path());
assert!(
detected.is_library,
"Should detect library with main/types but no HTML"
);
}
#[test]
fn test_detect_app_with_index_html() {
let tmp = TempDir::new().expect("create temp dir");
std::fs::write(
tmp.path().join("package.json"),
r#"{"name": "some-app", "main": "src/main.js"}"#,
)
.expect("write package.json");
std::fs::write(tmp.path().join("index.html"), "<!DOCTYPE html>").expect("write index.html");
let detected = detect_stack(tmp.path());
assert!(
!detected.is_library,
"Should NOT detect library when index.html exists"
);
}
#[test]
fn test_detect_monorepo_with_packages() {
let tmp = TempDir::new().expect("create temp dir");
std::fs::write(tmp.path().join("package.json"), "{}").expect("write package.json");
std::fs::create_dir(tmp.path().join("packages")).expect("mkdir packages");
std::fs::create_dir(tmp.path().join("packages/foo")).expect("mkdir foo");
std::fs::write(tmp.path().join("packages/foo/package.json"), "{}")
.expect("write foo package.json");
let detected = detect_stack(tmp.path());
assert!(
detected.is_library,
"Should detect monorepo with packages/ as library"
);
}
#[test]
fn test_library_mode_applied_automatically() {
let tmp = TempDir::new().expect("create temp dir");
std::fs::write(
tmp.path().join("package.json"),
r#"{"name": "test-lib", "exports": {"./index": "./index.js"}}"#,
)
.expect("write package.json");
let mut extensions: Option<HashSet<String>> = None;
let mut ignores = Vec::new();
let mut tauri = false;
let mut library_mode = false;
apply_detected_stack(
tmp.path(),
&mut extensions,
&mut ignores,
&mut tauri,
&mut library_mode,
&mut Vec::new(),
false,
);
assert!(
library_mode,
"Library mode should be auto-enabled for library projects"
);
}
#[test]
fn test_detect_cpython_py_roots() {
let tmp = TempDir::new().expect("create temp dir");
std::fs::write(
tmp.path().join("pyproject.toml"),
"[project]\nname = \"cpython\"",
)
.expect("write pyproject.toml");
std::fs::create_dir(tmp.path().join("Lib")).expect("mkdir Lib");
std::fs::create_dir(tmp.path().join("Python")).expect("mkdir Python");
std::fs::create_dir(tmp.path().join("Modules")).expect("mkdir Modules");
let detected = detect_stack(tmp.path());
assert!(detected.extensions.contains("py"));
assert_eq!(detected.py_roots.len(), 1);
assert_eq!(detected.py_roots[0], std::path::PathBuf::from("Lib"));
}
#[test]
fn test_detect_no_py_roots_for_standard_layout() {
let tmp = TempDir::new().expect("create temp dir");
std::fs::write(
tmp.path().join("pyproject.toml"),
"[project]\nname = \"myapp\"",
)
.expect("write pyproject.toml");
std::fs::create_dir(tmp.path().join("src")).expect("mkdir src");
std::fs::create_dir(tmp.path().join("tests")).expect("mkdir tests");
let detected = detect_stack(tmp.path());
assert!(detected.extensions.contains("py"));
assert!(
detected.py_roots.is_empty(),
"Standard layout should not add py_roots"
);
}
}