Skip to main content

prj_core/
detect.rs

1use std::path::Path;
2
3use serde::{Deserialize, Serialize};
4
5/// Version control systems that `prj` can detect.
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7pub enum VcsType {
8    Git,
9}
10
11impl std::fmt::Display for VcsType {
12    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
13        match self {
14            VcsType::Git => write!(f, "Git"),
15        }
16    }
17}
18
19/// Build systems detected by the presence of their marker files.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub enum BuildSystem {
22    Cargo,
23    Npm,
24    CMake,
25    Go,
26    Python,
27    Zig,
28    Make,
29    Gradle,
30    Maven,
31    Meson,
32}
33
34impl std::fmt::Display for BuildSystem {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        let s = match self {
37            BuildSystem::Cargo => "Cargo",
38            BuildSystem::Npm => "Npm",
39            BuildSystem::CMake => "CMake",
40            BuildSystem::Go => "Go",
41            BuildSystem::Python => "Python",
42            BuildSystem::Zig => "Zig",
43            BuildSystem::Make => "Make",
44            BuildSystem::Gradle => "Gradle",
45            BuildSystem::Maven => "Maven",
46            BuildSystem::Meson => "Meson",
47        };
48        write!(f, "{s}")
49    }
50}
51
52struct BuildSystemInfo {
53    marker: &'static str,
54    system: BuildSystem,
55    artifact_dirs: &'static [&'static str],
56}
57
58const BUILD_SYSTEMS: &[BuildSystemInfo] = &[
59    BuildSystemInfo {
60        marker: "Cargo.toml",
61        system: BuildSystem::Cargo,
62        artifact_dirs: &["target"],
63    },
64    BuildSystemInfo {
65        marker: "package.json",
66        system: BuildSystem::Npm,
67        artifact_dirs: &["node_modules", "dist", "build"],
68    },
69    BuildSystemInfo {
70        marker: "CMakeLists.txt",
71        system: BuildSystem::CMake,
72        artifact_dirs: &["build"],
73    },
74    BuildSystemInfo {
75        marker: "go.mod",
76        system: BuildSystem::Go,
77        artifact_dirs: &[],
78    },
79    BuildSystemInfo {
80        marker: "pyproject.toml",
81        system: BuildSystem::Python,
82        artifact_dirs: &["__pycache__", ".venv", "dist"],
83    },
84    BuildSystemInfo {
85        marker: "build.zig",
86        system: BuildSystem::Zig,
87        artifact_dirs: &["zig-out", "zig-cache"],
88    },
89    BuildSystemInfo {
90        marker: "Makefile",
91        system: BuildSystem::Make,
92        artifact_dirs: &[],
93    },
94    BuildSystemInfo {
95        marker: "build.gradle",
96        system: BuildSystem::Gradle,
97        artifact_dirs: &["build", ".gradle"],
98    },
99    BuildSystemInfo {
100        marker: "build.gradle.kts",
101        system: BuildSystem::Gradle,
102        artifact_dirs: &["build", ".gradle"],
103    },
104    BuildSystemInfo {
105        marker: "pom.xml",
106        system: BuildSystem::Maven,
107        artifact_dirs: &["target"],
108    },
109    BuildSystemInfo {
110        marker: "meson.build",
111        system: BuildSystem::Meson,
112        artifact_dirs: &["builddir"],
113    },
114];
115
116/// Known artifact directory names (used during scan to skip).
117pub const ARTIFACT_DIR_NAMES: &[&str] = &[
118    "target",
119    "node_modules",
120    "dist",
121    "build",
122    "__pycache__",
123    ".venv",
124    "zig-out",
125    "zig-cache",
126    ".gradle",
127    "builddir",
128    ".git",
129];
130
131/// Result of scanning a project directory for VCS and build system markers.
132pub struct DetectionResult {
133    pub vcs: Vec<VcsType>,
134    pub build_systems: Vec<BuildSystem>,
135    pub artifact_dirs: Vec<String>,
136}
137
138/// Detect VCS, build systems, and artifact directories for a given path.
139pub fn detect_project(path: &Path) -> DetectionResult {
140    let mut vcs = Vec::new();
141    let mut build_systems = Vec::new();
142    let mut artifact_dirs = Vec::new();
143
144    // VCS detection
145    if path.join(".git").exists() {
146        vcs.push(VcsType::Git);
147    }
148
149    // Build system detection
150    for info in BUILD_SYSTEMS {
151        if path.join(info.marker).exists() {
152            // Avoid duplicate build systems (e.g. build.gradle and build.gradle.kts)
153            if !build_systems.contains(&info.system) {
154                build_systems.push(info.system.clone());
155            }
156            for dir in info.artifact_dirs {
157                let s = dir.to_string();
158                if !artifact_dirs.contains(&s) {
159                    artifact_dirs.push(s);
160                }
161            }
162        }
163    }
164
165    DetectionResult {
166        vcs,
167        build_systems,
168        artifact_dirs,
169    }
170}
171
172/// Returns true if the given path looks like a project root.
173pub fn is_project(path: &Path) -> bool {
174    if path.join(".git").exists() {
175        return true;
176    }
177    for info in BUILD_SYSTEMS {
178        if path.join(info.marker).exists() {
179            return true;
180        }
181    }
182    false
183}
184
185/// Scan a directory tree for projects up to `max_depth`.
186/// Skips children of already-detected projects and artifact directories.
187pub fn scan_projects(root: &Path, max_depth: usize) -> Vec<std::path::PathBuf> {
188    let mut found = Vec::new();
189
190    let walker = walkdir::WalkDir::new(root)
191        .max_depth(max_depth)
192        .follow_links(false)
193        .into_iter();
194
195    // Track project roots so we skip their children
196    let mut project_roots: Vec<std::path::PathBuf> = Vec::new();
197
198    for entry in walker.filter_entry(|e| {
199        // Always allow the root itself
200        if e.depth() == 0 {
201            return true;
202        }
203        // Skip non-directories
204        if !e.file_type().is_dir() {
205            return false;
206        }
207        // Skip artifact directories
208        if let Some(name) = e.file_name().to_str() {
209            if ARTIFACT_DIR_NAMES.contains(&name) {
210                return false;
211            }
212            // Skip hidden directories (except .git which we handle)
213            if name.starts_with('.') {
214                return false;
215            }
216        }
217        true
218    }) {
219        let Ok(entry) = entry else { continue };
220        if !entry.file_type().is_dir() {
221            continue;
222        }
223        let path = entry.path();
224
225        // Skip if this is a child of an already-found project
226        if project_roots
227            .iter()
228            .any(|root| path.starts_with(root) && path != root)
229        {
230            continue;
231        }
232
233        if is_project(path) {
234            project_roots.push(path.to_path_buf());
235            found.push(path.to_path_buf());
236        }
237    }
238
239    found
240}