use std::path::{Path, PathBuf};
use crate::claude_config::SCAN_SKIP_DIRS;
const DEFAULT_PROJECT_MAX_DEPTH: usize = 3;
pub const DEFAULT_SEARCH_DIRS: &[&str] = &["Projects", "src", "dev", "code", "work", "workspace"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClaudeProject {
pub path: PathBuf,
pub has_claude_dir: bool,
pub has_claude_md: bool,
pub has_git: bool,
}
pub const fn default_project_max_depth() -> usize {
DEFAULT_PROJECT_MAX_DEPTH
}
pub fn discover_claude_projects(
home: &Path,
search_dirs: &[&str],
max_depth: usize,
) -> Vec<ClaudeProject> {
let mut found = Vec::new();
for rel in search_dirs {
let root = home.join(rel);
if root.is_dir() {
collect_projects(&root, max_depth, &mut found);
}
}
found.sort_by(|a, b| a.path.cmp(&b.path));
found.dedup_by(|a, b| a.path == b.path);
found
}
fn collect_projects(dir: &Path, depth_remaining: usize, out: &mut Vec<ClaudeProject>) {
if let Some(project) = inspect_project_dir(dir) {
out.push(project);
return;
}
if depth_remaining == 0 {
return;
}
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return, };
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if !file_type.is_dir() {
continue;
}
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if SCAN_SKIP_DIRS.contains(&name) {
continue;
}
collect_projects(&path, depth_remaining.saturating_sub(1), out);
}
}
fn inspect_project_dir(dir: &Path) -> Option<ClaudeProject> {
let has_claude_dir = dir.join(".claude").is_dir();
let has_claude_md = dir.join("CLAUDE.md").is_file();
if !has_claude_dir && !has_claude_md {
return None;
}
Some(ClaudeProject {
path: dir.to_path_buf(),
has_claude_dir,
has_claude_md,
has_git: dir.join(".git").is_dir(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch_dir(tag: &str) -> PathBuf {
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let p = std::env::temp_dir().join(format!("trusty-project-disco-{tag}-{pid}-{nanos}"));
std::fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn default_search_dirs_are_stable() {
assert_eq!(
DEFAULT_SEARCH_DIRS,
&["Projects", "src", "dev", "code", "work", "workspace"]
);
}
#[test]
fn default_project_max_depth_is_three() {
assert_eq!(default_project_max_depth(), 3);
}
#[test]
fn inspect_project_dir_rejects_unmarked() {
let dir = scratch_dir("unmarked");
assert!(inspect_project_dir(&dir).is_none());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
#[ignore = "touches the real filesystem"]
fn inspect_project_dir_detects_markers() {
let dir = scratch_dir("markers");
std::fs::create_dir_all(dir.join(".claude")).unwrap();
std::fs::write(dir.join("CLAUDE.md"), "# project").unwrap();
std::fs::create_dir_all(dir.join(".git")).unwrap();
let p = inspect_project_dir(&dir).expect("marked dir should be a project");
assert!(p.has_claude_dir);
assert!(p.has_claude_md);
assert!(p.has_git);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
#[ignore = "touches the real filesystem"]
fn discover_claude_projects_finds_marked_dirs() {
let home = scratch_dir("home");
let alpha = home.join("Projects").join("alpha");
std::fs::create_dir_all(alpha.join(".claude")).unwrap();
let beta = home.join("src").join("beta");
std::fs::create_dir_all(&beta).unwrap();
std::fs::write(beta.join("CLAUDE.md"), "# beta").unwrap();
let gamma = home.join("Projects").join("node_modules").join("gamma");
std::fs::create_dir_all(gamma.join(".claude")).unwrap();
let found =
discover_claude_projects(&home, DEFAULT_SEARCH_DIRS, default_project_max_depth());
assert_eq!(found.len(), 2, "alpha + beta, gamma skipped: {found:?}");
assert!(found.iter().any(|p| p.path == alpha && p.has_claude_dir));
assert!(found.iter().any(|p| p.path == beta && p.has_claude_md));
assert!(
found
.iter()
.all(|p| !p.path.to_string_lossy().contains("node_modules"))
);
std::fs::remove_dir_all(&home).ok();
}
}