Skip to main content

stint_core/
discover.rs

1//! Project auto-discovery from filesystem markers.
2//!
3//! Walks up the directory tree looking for `.git` directories to automatically
4//! detect projects without manual registration.
5
6use std::path::{Path, PathBuf};
7
8/// Result of a successful project discovery.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct DiscoveredProject {
11    /// The root directory of the discovered project (where `.git` lives).
12    pub root: PathBuf,
13    /// The project name (directory name of the root).
14    pub name: String,
15}
16
17/// Maximum number of parent directories to walk up when searching for `.git`.
18const MAX_DEPTH: usize = 10;
19
20/// Attempts to discover a project from the given directory by walking up
21/// the directory tree looking for a `.git` directory.
22///
23/// Returns `None` if no `.git` is found within `MAX_DEPTH` levels, or if
24/// the path is under a common non-project directory (e.g., `/tmp`, `/`).
25pub fn discover_project(cwd: &Path) -> Option<DiscoveredProject> {
26    let mut current = cwd.to_path_buf();
27
28    for _ in 0..=MAX_DEPTH {
29        if current.join(".git").exists() {
30            let name = current
31                .file_name()
32                .map(|n| n.to_string_lossy().to_string())
33                .unwrap_or_else(|| "unnamed".to_string());
34
35            return Some(DiscoveredProject {
36                root: current,
37                name,
38            });
39        }
40
41        if !current.pop() {
42            break;
43        }
44
45        // Stop at filesystem root or common non-project directories
46        if current == Path::new("/") || current == Path::new("") {
47            break;
48        }
49    }
50
51    None
52}
53
54#[cfg(test)]
55mod tests {
56    use super::*;
57    use std::fs;
58    use tempfile::TempDir;
59
60    #[test]
61    fn discovers_git_repo_at_cwd() {
62        let tmp = TempDir::new().unwrap();
63        fs::create_dir(tmp.path().join(".git")).unwrap();
64
65        let result = discover_project(tmp.path()).unwrap();
66        assert_eq!(result.root, tmp.path());
67        assert!(!result.name.is_empty());
68    }
69
70    #[test]
71    fn discovers_git_repo_from_subdirectory() {
72        let tmp = TempDir::new().unwrap();
73        fs::create_dir(tmp.path().join(".git")).unwrap();
74        let sub = tmp.path().join("src").join("components");
75        fs::create_dir_all(&sub).unwrap();
76
77        let result = discover_project(&sub).unwrap();
78        assert_eq!(result.root, tmp.path());
79    }
80
81    #[test]
82    fn returns_none_without_git() {
83        let tmp = TempDir::new().unwrap();
84        let sub = tmp.path().join("some").join("deep").join("path");
85        fs::create_dir_all(&sub).unwrap();
86
87        assert!(discover_project(&sub).is_none());
88    }
89
90    #[test]
91    fn discovers_at_max_depth_boundary() {
92        let tmp = TempDir::new().unwrap();
93        let project_dir = tmp.path().join("my-project");
94        fs::create_dir_all(project_dir.join(".git")).unwrap();
95
96        // Build a path exactly MAX_DEPTH levels deep
97        let mut deep = project_dir.clone();
98        for i in 0..MAX_DEPTH {
99            deep = deep.join(format!("level{i}"));
100        }
101        fs::create_dir_all(&deep).unwrap();
102
103        let result = discover_project(&deep);
104        assert!(result.is_some(), "should discover at exactly MAX_DEPTH");
105        assert_eq!(result.unwrap().root, project_dir);
106    }
107
108    #[test]
109    fn uses_directory_name_as_project_name() {
110        let tmp = TempDir::new().unwrap();
111        let project_dir = tmp.path().join("my-awesome-app");
112        fs::create_dir_all(project_dir.join(".git")).unwrap();
113
114        let result = discover_project(&project_dir).unwrap();
115        assert_eq!(result.name, "my-awesome-app");
116    }
117}