use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
const MAX_DEPTH_CAP: u32 = 10;
pub fn discover_projects(dir: &Path) -> Result<Vec<PathBuf>> {
discover_projects_filtered(dir, &[])
}
pub fn discover_projects_filtered(dir: &Path, ignore: &[String]) -> Result<Vec<PathBuf>> {
discover_projects_with_depth(dir, ignore, 1)
}
pub fn discover_projects_with_depth(
dir: &Path,
ignore: &[String],
depth: u32,
) -> Result<Vec<PathBuf>> {
let capped_depth = depth.min(MAX_DEPTH_CAP);
let mut projects = Vec::new();
scan_recursive(dir, ignore, capped_depth, &mut projects)?;
projects.sort();
Ok(projects)
}
fn scan_recursive(
dir: &Path,
ignore: &[String],
remaining_depth: u32,
projects: &mut Vec<PathBuf>,
) -> Result<()> {
if remaining_depth == 0 {
if dir.join(".git").exists() {
projects.push(dir.to_path_buf());
}
return Ok(());
}
let entries = match fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) => {
if e.kind() == std::io::ErrorKind::PermissionDenied {
eprintln!(" Warning: permission denied: {}", dir.display());
return Ok(());
}
return Err(e).with_context(|| format!("Failed to read directory: {}", dir.display()));
}
};
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
let path = entry.path();
let file_type = match entry.file_type() {
Ok(ft) => ft,
Err(_) => continue,
};
if !file_type.is_dir() {
continue;
}
let name = match entry.file_name().to_str() {
Some(n) => n.to_string(),
None => continue,
};
if name.starts_with('.') {
continue;
}
if ignore.iter().any(|ig| ig == &name) {
continue;
}
if path.join(".git").exists() {
projects.push(path);
} else if remaining_depth > 1 {
scan_recursive(&path, ignore, remaining_depth - 1, projects)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn create_fake_project(base: &Path, name: &str) {
let project = base.join(name);
fs::create_dir_all(project.join(".git")).unwrap();
}
fn create_plain_dir(base: &Path, name: &str) {
fs::create_dir_all(base.join(name)).unwrap();
}
#[test]
fn test_depth_1_finds_immediate_children() {
let dir = tempfile::tempdir().unwrap();
create_fake_project(dir.path(), "alpha");
create_fake_project(dir.path(), "beta");
create_plain_dir(dir.path(), "not-a-repo");
let projects = discover_projects_with_depth(dir.path(), &[], 1).unwrap();
assert_eq!(projects.len(), 2);
let names: Vec<_> = projects
.iter()
.map(|p| p.file_name().unwrap().to_str().unwrap())
.collect();
assert!(names.contains(&"alpha"));
assert!(names.contains(&"beta"));
}
#[test]
fn test_depth_0_checks_dir_itself() {
let dir = tempfile::tempdir().unwrap();
let projects = discover_projects_with_depth(dir.path(), &[], 0).unwrap();
assert!(projects.is_empty());
fs::create_dir_all(dir.path().join(".git")).unwrap();
let projects = discover_projects_with_depth(dir.path(), &[], 0).unwrap();
assert_eq!(projects.len(), 1);
}
#[test]
fn test_depth_2_finds_nested_projects() {
let dir = tempfile::tempdir().unwrap();
create_fake_project(dir.path(), "top-repo");
let group = dir.path().join("group");
fs::create_dir_all(&group).unwrap();
create_fake_project(&group, "nested-repo");
let projects = discover_projects_with_depth(dir.path(), &[], 1).unwrap();
assert_eq!(projects.len(), 1);
assert!(projects[0].ends_with("top-repo"));
let projects = discover_projects_with_depth(dir.path(), &[], 2).unwrap();
assert_eq!(projects.len(), 2);
}
#[test]
fn test_does_not_recurse_into_git_projects() {
let dir = tempfile::tempdir().unwrap();
create_fake_project(dir.path(), "outer");
let outer = dir.path().join("outer");
create_fake_project(&outer, "inner-sub");
let projects = discover_projects_with_depth(dir.path(), &[], 3).unwrap();
assert_eq!(projects.len(), 1);
assert!(projects[0].ends_with("outer"));
}
#[test]
fn test_ignores_hidden_directories() {
let dir = tempfile::tempdir().unwrap();
create_fake_project(dir.path(), ".hidden-repo");
create_fake_project(dir.path(), "visible-repo");
let projects = discover_projects_with_depth(dir.path(), &[], 1).unwrap();
assert_eq!(projects.len(), 1);
assert!(projects[0].ends_with("visible-repo"));
}
#[test]
fn test_ignores_specified_directories() {
let dir = tempfile::tempdir().unwrap();
create_fake_project(dir.path(), "keep-me");
create_fake_project(dir.path(), "ignore-me");
let ignore = vec!["ignore-me".to_string()];
let projects = discover_projects_with_depth(dir.path(), &ignore, 1).unwrap();
assert_eq!(projects.len(), 1);
assert!(projects[0].ends_with("keep-me"));
}
#[test]
fn test_ignore_applies_at_all_depth_levels() {
let dir = tempfile::tempdir().unwrap();
let group = dir.path().join("group");
fs::create_dir_all(&group).unwrap();
create_fake_project(&group, "good-project");
create_fake_project(&group, "vendor");
let ignore = vec!["vendor".to_string()];
let projects = discover_projects_with_depth(dir.path(), &ignore, 2).unwrap();
assert_eq!(projects.len(), 1);
assert!(projects[0].ends_with("good-project"));
}
#[test]
fn test_empty_directory() {
let dir = tempfile::tempdir().unwrap();
let projects = discover_projects_with_depth(dir.path(), &[], 1).unwrap();
assert!(projects.is_empty());
}
#[test]
fn test_empty_directory_deep() {
let dir = tempfile::tempdir().unwrap();
let projects = discover_projects_with_depth(dir.path(), &[], 5).unwrap();
assert!(projects.is_empty());
}
#[test]
fn test_non_utf8_names_skipped() {
let dir = tempfile::tempdir().unwrap();
let projects = discover_projects_with_depth(dir.path(), &[], 1).unwrap();
assert!(projects.is_empty());
}
#[test]
fn test_results_are_sorted() {
let dir = tempfile::tempdir().unwrap();
create_fake_project(dir.path(), "zebra");
create_fake_project(dir.path(), "alpha");
create_fake_project(dir.path(), "mango");
let projects = discover_projects_with_depth(dir.path(), &[], 1).unwrap();
let names: Vec<_> = projects
.iter()
.map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
.collect();
assert_eq!(names, vec!["alpha", "mango", "zebra"]);
}
#[test]
fn test_depth_capped_at_max() {
let dir = tempfile::tempdir().unwrap();
let projects = discover_projects_with_depth(dir.path(), &[], 999).unwrap();
assert!(projects.is_empty());
}
#[test]
fn test_nonexistent_directory_returns_error() {
let result = discover_projects_with_depth(Path::new("/nonexistent/path/xyz"), &[], 1);
assert!(result.is_err());
}
#[test]
fn test_discover_projects_uses_depth_1() {
let dir = tempfile::tempdir().unwrap();
create_fake_project(dir.path(), "repo");
let group = dir.path().join("group");
fs::create_dir_all(&group).unwrap();
create_fake_project(&group, "nested");
let projects = discover_projects(dir.path()).unwrap();
assert_eq!(projects.len(), 1);
assert!(projects[0].ends_with("repo"));
}
#[test]
fn test_deep_nesting_depth_3() {
let dir = tempfile::tempdir().unwrap();
let l1 = dir.path().join("level1");
let l2 = l1.join("level2");
fs::create_dir_all(&l2).unwrap();
create_fake_project(&l2, "deep-project");
let projects = discover_projects_with_depth(dir.path(), &[], 2).unwrap();
assert!(projects.is_empty());
let projects = discover_projects_with_depth(dir.path(), &[], 3).unwrap();
assert_eq!(projects.len(), 1);
assert!(projects[0].ends_with("deep-project"));
}
}