trusty_common/
project_discovery.rs1use std::path::{Path, PathBuf};
17
18use crate::claude_config::SCAN_SKIP_DIRS;
19
20const DEFAULT_PROJECT_MAX_DEPTH: usize = 3;
22
23pub const DEFAULT_SEARCH_DIRS: &[&str] = &["Projects", "src", "dev", "code", "work", "workspace"];
32
33#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct ClaudeProject {
42 pub path: PathBuf,
44 pub has_claude_dir: bool,
46 pub has_claude_md: bool,
48 pub has_git: bool,
50}
51
52pub const fn default_project_max_depth() -> usize {
59 DEFAULT_PROJECT_MAX_DEPTH
60}
61
62pub fn discover_claude_projects(
77 home: &Path,
78 search_dirs: &[&str],
79 max_depth: usize,
80) -> Vec<ClaudeProject> {
81 let mut found = Vec::new();
82 for rel in search_dirs {
83 let root = home.join(rel);
84 if root.is_dir() {
85 collect_projects(&root, max_depth, &mut found);
86 }
87 }
88 found.sort_by(|a, b| a.path.cmp(&b.path));
89 found.dedup_by(|a, b| a.path == b.path);
90 found
91}
92
93fn collect_projects(dir: &Path, depth_remaining: usize, out: &mut Vec<ClaudeProject>) {
95 if let Some(project) = inspect_project_dir(dir) {
96 out.push(project);
98 return;
99 }
100
101 if depth_remaining == 0 {
102 return;
103 }
104
105 let entries = match std::fs::read_dir(dir) {
106 Ok(e) => e,
107 Err(_) => return, };
109
110 for entry in entries.flatten() {
111 let Ok(file_type) = entry.file_type() else {
112 continue;
113 };
114 if !file_type.is_dir() {
115 continue;
116 }
117 let path = entry.path();
118 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
119 continue;
120 };
121 if SCAN_SKIP_DIRS.contains(&name) {
122 continue;
123 }
124 collect_projects(&path, depth_remaining.saturating_sub(1), out);
125 }
126}
127
128fn inspect_project_dir(dir: &Path) -> Option<ClaudeProject> {
131 let has_claude_dir = dir.join(".claude").is_dir();
132 let has_claude_md = dir.join("CLAUDE.md").is_file();
133 if !has_claude_dir && !has_claude_md {
134 return None;
135 }
136 Some(ClaudeProject {
137 path: dir.to_path_buf(),
138 has_claude_dir,
139 has_claude_md,
140 has_git: dir.join(".git").is_dir(),
141 })
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 fn scratch_dir(tag: &str) -> PathBuf {
149 let pid = std::process::id();
150 let nanos = std::time::SystemTime::now()
151 .duration_since(std::time::UNIX_EPOCH)
152 .map(|d| d.as_nanos())
153 .unwrap_or(0);
154 let p = std::env::temp_dir().join(format!("trusty-project-disco-{tag}-{pid}-{nanos}"));
155 std::fs::create_dir_all(&p).unwrap();
156 p
157 }
158
159 #[test]
160 fn default_search_dirs_are_stable() {
161 assert_eq!(
162 DEFAULT_SEARCH_DIRS,
163 &["Projects", "src", "dev", "code", "work", "workspace"]
164 );
165 }
166
167 #[test]
168 fn default_project_max_depth_is_three() {
169 assert_eq!(default_project_max_depth(), 3);
170 }
171
172 #[test]
173 fn inspect_project_dir_rejects_unmarked() {
174 let dir = scratch_dir("unmarked");
175 assert!(inspect_project_dir(&dir).is_none());
176 std::fs::remove_dir_all(&dir).ok();
177 }
178
179 #[test]
180 #[ignore = "touches the real filesystem"]
181 fn inspect_project_dir_detects_markers() {
182 let dir = scratch_dir("markers");
183 std::fs::create_dir_all(dir.join(".claude")).unwrap();
184 std::fs::write(dir.join("CLAUDE.md"), "# project").unwrap();
185 std::fs::create_dir_all(dir.join(".git")).unwrap();
186
187 let p = inspect_project_dir(&dir).expect("marked dir should be a project");
188 assert!(p.has_claude_dir);
189 assert!(p.has_claude_md);
190 assert!(p.has_git);
191
192 std::fs::remove_dir_all(&dir).ok();
193 }
194
195 #[test]
196 #[ignore = "touches the real filesystem"]
197 fn discover_claude_projects_finds_marked_dirs() {
198 let home = scratch_dir("home");
199
200 let alpha = home.join("Projects").join("alpha");
202 std::fs::create_dir_all(alpha.join(".claude")).unwrap();
203
204 let beta = home.join("src").join("beta");
206 std::fs::create_dir_all(&beta).unwrap();
207 std::fs::write(beta.join("CLAUDE.md"), "# beta").unwrap();
208
209 let gamma = home.join("Projects").join("node_modules").join("gamma");
211 std::fs::create_dir_all(gamma.join(".claude")).unwrap();
212
213 let found =
214 discover_claude_projects(&home, DEFAULT_SEARCH_DIRS, default_project_max_depth());
215 assert_eq!(found.len(), 2, "alpha + beta, gamma skipped: {found:?}");
216 assert!(found.iter().any(|p| p.path == alpha && p.has_claude_dir));
217 assert!(found.iter().any(|p| p.path == beta && p.has_claude_md));
218 assert!(
219 found
220 .iter()
221 .all(|p| !p.path.to_string_lossy().contains("node_modules"))
222 );
223
224 std::fs::remove_dir_all(&home).ok();
225 }
226}