use super::config::MonorepoDetectionConfig;
use crate::error::Result;
use serde_json::Value as JsonValue;
use std::path::{Path, PathBuf};
pub(crate) fn detect_potential_projects(
root_path: &Path,
config: &MonorepoDetectionConfig,
) -> Result<Vec<PathBuf>> {
let mut potential_projects = Vec::new();
if is_project_directory(root_path)? {
potential_projects.push(root_path.to_path_buf());
}
if config.deep_scan {
scan_for_projects(root_path, root_path, &mut potential_projects, 0, config)?;
}
potential_projects.sort_by_key(|p| p.components().count());
potential_projects.dedup();
filter_nested_projects(potential_projects)
}
fn scan_for_projects(
root_path: &Path,
current_path: &Path,
projects: &mut Vec<PathBuf>,
depth: usize,
config: &MonorepoDetectionConfig,
) -> Result<()> {
if depth >= config.max_depth {
return Ok(());
}
if let Ok(entries) = std::fs::read_dir(current_path) {
for entry in entries.flatten() {
if !entry.file_type()?.is_dir() {
continue;
}
let dir_name = entry.file_name().to_string_lossy().to_string();
let dir_path = entry.path();
if is_placeholder_dir(&dir_path) {
continue;
}
if should_exclude_directory(&dir_name, config) {
continue;
}
if is_project_directory(&dir_path)? {
projects.push(dir_path.clone());
}
scan_for_projects(root_path, &dir_path, projects, depth + 1, config)?;
}
}
Ok(())
}
fn should_exclude_directory(dir_name: &str, config: &MonorepoDetectionConfig) -> bool {
if dir_name.starts_with('.') {
return true;
}
config
.exclude_patterns
.iter()
.any(|pattern| dir_name == pattern)
}
fn is_project_directory(path: &Path) -> Result<bool> {
let pkg = path.join("package.json");
if pkg.exists()
&& let Ok(content) = std::fs::read_to_string(&pkg)
&& let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
&& json
.get("name")
.and_then(|n| n.as_str())
.map(|s| s.contains("${") || s.contains("}}"))
== Some(true)
{
return Ok(false);
}
let project_indicators = [
"package.json",
"Cargo.toml",
"requirements.txt",
"pyproject.toml",
"Pipfile",
"setup.py",
"go.mod",
"pom.xml",
"build.gradle",
"build.gradle.kts",
"*.csproj",
"*.fsproj",
"*.vbproj",
"Gemfile",
"composer.json",
"Dockerfile",
];
let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
let generic_buckets = [
"src", "packages", "apps", "app", "libs", "services", "packages",
];
let is_template_placeholder = is_placeholder_dir(path);
for indicator in &project_indicators {
if indicator.contains('*') {
if let Ok(entries) = std::fs::read_dir(path) {
for entry in entries.flatten() {
if let Some(file_name) = entry.file_name().to_str() {
let pattern = indicator.replace('*', "");
if file_name.ends_with(&pattern) {
return Ok(true);
}
}
}
}
} else if path.join(indicator).exists() {
return Ok(true);
}
}
if is_template_placeholder || generic_buckets.contains(&dir_name) {
return Ok(false);
}
Ok(false)
}
fn is_placeholder_dir(path: &Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.map(|s| s.contains("${") || s.contains("}}"))
.unwrap_or(false)
}
#[allow(dead_code)]
fn directory_contains_code(path: &Path) -> Result<bool> {
let code_extensions = [
"js", "ts", "jsx", "tsx", "py", "rs", "go", "java", "kt", "cs", "rb", "php",
];
if let Ok(entries) = std::fs::read_dir(path) {
for entry in entries.flatten() {
if let Some(extension) = entry.path().extension()
&& let Some(ext_str) = extension.to_str()
&& code_extensions.contains(&ext_str)
{
return Ok(true);
}
if entry.file_type()?.is_dir() && directory_contains_code(&entry.path())? {
return Ok(true);
}
}
}
Ok(false)
}
fn filter_nested_projects(mut projects: Vec<PathBuf>) -> Result<Vec<PathBuf>> {
projects.sort();
projects.dedup();
let wrapper_indicators = ["Dockerfile", "docker-compose.yml", "docker-compose.yaml"];
let code_manifests = [
"package.json",
"Cargo.toml",
"go.mod",
"pom.xml",
"build.gradle",
"build.gradle.kts",
"requirements.txt",
"pyproject.toml",
"Gemfile",
"composer.json",
];
let wrapper_projects: std::collections::HashSet<_> = projects
.iter()
.filter(|path| {
let has_wrapper = wrapper_indicators.iter().any(|ind| path.join(ind).exists());
let has_code_manifest = code_manifests.iter().any(|m| path.join(m).exists());
has_wrapper && !has_code_manifest
})
.cloned()
.collect();
let filtered: Vec<PathBuf> = projects
.into_iter()
.filter(|project| {
if wrapper_projects.contains(project) {
let common_child_dirs = [
"server", "app", "src", "backend", "frontend", "api", "service",
];
for child_dir in &common_child_dirs {
let child_path = project.join(child_dir);
if code_manifests.iter().any(|m| child_path.join(m).exists()) {
log::debug!(
"Filtering out wrapper project '{}' in favor of child '{}'",
project.display(),
child_path.display()
);
return false; }
}
}
true
})
.collect();
Ok(filtered)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keeps_nested_projects_for_workspaces() {
let projects = vec![
PathBuf::from("."),
PathBuf::from("apps/api"),
PathBuf::from("apps/web"),
PathBuf::from("libs/common"),
];
let filtered = filter_nested_projects(projects).unwrap();
assert!(filtered.iter().any(|p| p == &PathBuf::from(".")));
assert!(filtered.iter().any(|p| p == &PathBuf::from("apps/api")));
assert!(filtered.iter().any(|p| p == &PathBuf::from("apps/web")));
assert!(filtered.iter().any(|p| p == &PathBuf::from("libs/common")));
}
#[test]
fn skips_placeholder_dirs() {
assert!(is_placeholder_dir(Path::new("${{ values.name }}")));
assert!(is_placeholder_dir(Path::new("templates/${{ service }}")));
assert!(!is_placeholder_dir(Path::new("apps/api")));
}
#[test]
fn skips_placeholder_package_json_name() {
let tmp = tempfile::tempdir().unwrap();
let pkg_path = tmp.path().join("package.json");
std::fs::write(
&pkg_path,
r#"{ "name": "${{ values.name }}", "version": "1.0.0" }"#,
)
.unwrap();
assert!(!is_project_directory(tmp.path()).unwrap());
}
}
pub(crate) fn determine_if_monorepo(
root_path: &Path,
potential_projects: &[PathBuf],
_config: &MonorepoDetectionConfig,
) -> Result<bool> {
if potential_projects.len() > 1 {
return Ok(true);
}
let monorepo_indicators = [
"lerna.json", "nx.json", "rush.json", "pnpm-workspace.yaml", "yarn.lock", "packages", "apps", "services", "libs", ];
for indicator in &monorepo_indicators {
if root_path.join(indicator).exists() {
return Ok(true);
}
}
let package_json_path = root_path.join("package.json");
if package_json_path.exists()
&& let Ok(content) = std::fs::read_to_string(&package_json_path)
&& let Ok(package_json) = serde_json::from_str::<JsonValue>(&content)
&& package_json.get("workspaces").is_some()
{
return Ok(true);
}
Ok(false)
}