Skip to main content

trusty_common/
project_discovery.rs

1//! Claude Code project directory discovery.
2//!
3//! Why: trusty-memory's `setup` command walks the user's home directory looking
4//! for Claude Code project directories (those carrying a `.claude/` directory
5//! or a `CLAUDE.md` file) so it can offer to register them. The walk, the
6//! marker detection, and the default search roots are generally useful — this
7//! module hoists them into trusty-common so trusty-search and trusty-analyze
8//! can reuse them instead of growing their own copies.
9//!
10//! What: a [`ClaudeProject`] record plus a [`discover_claude_projects`] walker.
11//! No global state.
12//!
13//! Test: `cargo test -p trusty-common` covers default-search-dir wiring; the
14//! filesystem-walking test is `#[ignore]`.
15
16use std::path::{Path, PathBuf};
17
18use crate::claude_config::SCAN_SKIP_DIRS;
19
20/// Default depth [`discover_claude_projects`] recurses inside each search root.
21const DEFAULT_PROJECT_MAX_DEPTH: usize = 3;
22
23/// Relative directory names under `$HOME` searched by default for Claude Code
24/// projects.
25///
26/// Why: developers keep code in a small, conventional set of top-level folders.
27/// Sharing the list keeps every trusty-* setup command searching the same
28/// places, and gives callers a sensible default they can override.
29/// What: a slice of directory base-names relative to the home directory.
30/// Test: `default_search_dirs_are_stable` pins the contents.
31pub const DEFAULT_SEARCH_DIRS: &[&str] = &["Projects", "src", "dev", "code", "work", "workspace"];
32
33/// A discovered Claude Code project directory.
34///
35/// Why: callers need to know not just that a directory looks like a project but
36/// *why* — whether it has a `.claude/` directory, a `CLAUDE.md`, or a `.git/`.
37/// A setup UI uses those flags to label and prioritise entries.
38/// What: the absolute project `path` plus three boolean markers.
39/// Test: populated and asserted by `discover_claude_projects_finds_marked_dirs`.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct ClaudeProject {
42    /// Absolute path to the project directory.
43    pub path: PathBuf,
44    /// Directory contains a `.claude/` subdirectory.
45    pub has_claude_dir: bool,
46    /// Directory contains a `CLAUDE.md` file.
47    pub has_claude_md: bool,
48    /// Directory contains a `.git/` subdirectory.
49    pub has_git: bool,
50}
51
52/// Default [`discover_claude_projects`] recursion depth, exposed so callers can
53/// use the library default without hard-coding the number.
54///
55/// Why: keeps the "3" in one place.
56/// What: returns [`DEFAULT_PROJECT_MAX_DEPTH`].
57/// Test: compile-time constant; no runtime test needed.
58pub const fn default_project_max_depth() -> usize {
59    DEFAULT_PROJECT_MAX_DEPTH
60}
61
62/// Discover Claude Code project directories under `home`.
63///
64/// Why: setup commands want to present the user with a list of their Claude
65/// Code projects. Scanning a few conventional roots (rather than all of `$HOME`)
66/// keeps the walk fast and the results relevant.
67/// What: for each entry of `search_dirs` (joined onto `home`), recursively walks
68/// up to `max_depth` directories deep, skipping any directory in
69/// [`SCAN_SKIP_DIRS`]. Every directory carrying a `.claude/` directory or a
70/// `CLAUDE.md` file is reported as a [`ClaudeProject`] with its marker flags
71/// populated. A directory matching is not recursed into (its subdirectories are
72/// considered part of the same project). Use [`DEFAULT_SEARCH_DIRS`] and
73/// [`default_project_max_depth`] for the standard configuration. Results are
74/// sorted by path and de-duplicated.
75/// Test: `discover_claude_projects_finds_marked_dirs` (`#[ignore]`, real fs).
76pub 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
93/// Recursive worker for [`discover_claude_projects`].
94fn collect_projects(dir: &Path, depth_remaining: usize, out: &mut Vec<ClaudeProject>) {
95    if let Some(project) = inspect_project_dir(dir) {
96        // A matched directory IS the project — don't descend into it.
97        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, // permission denied / not a dir — skip silently
108    };
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
128/// Inspect a single directory; return a [`ClaudeProject`] if it carries a
129/// Claude Code marker (`.claude/` or `CLAUDE.md`), else `None`.
130fn 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        // home/Projects/alpha has a .claude dir.
201        let alpha = home.join("Projects").join("alpha");
202        std::fs::create_dir_all(alpha.join(".claude")).unwrap();
203
204        // home/src/beta has a CLAUDE.md.
205        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        // home/Projects/node_modules/gamma is skipped.
210        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}