1use std::path::{Path, PathBuf};
7
8use termesh_filesystem::{FileSystemService, FsError};
9
10#[non_exhaustive]
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum ProjectKind {
19 Rust,
20 Java,
21 Node,
22 Python,
23 Go,
24 #[default]
25 Unknown,
26}
27
28impl ProjectKind {
29 pub fn label(self) -> &'static str {
30 match self {
31 ProjectKind::Rust => "rust",
32 ProjectKind::Java => "java",
33 ProjectKind::Node => "node",
34 ProjectKind::Python => "python",
35 ProjectKind::Go => "go",
36 ProjectKind::Unknown => "unknown",
37 }
38 }
39}
40
41pub fn kind_labels(kinds: &[ProjectKind]) -> String {
48 if kinds.is_empty() {
49 return ProjectKind::Unknown.label().to_string();
50 }
51 kinds.iter().map(|kind| kind.label()).collect::<Vec<_>>().join(", ")
52}
53
54const PROJECT_MARKERS: &[(&str, ProjectKind)] = &[
59 ("Cargo.toml", ProjectKind::Rust),
60 ("pom.xml", ProjectKind::Java),
61 ("build.gradle", ProjectKind::Java),
62 ("build.gradle.kts", ProjectKind::Java),
63 ("settings.gradle", ProjectKind::Java),
67 ("settings.gradle.kts", ProjectKind::Java),
68 ("go.mod", ProjectKind::Go),
69 ("pyproject.toml", ProjectKind::Python),
70 ("package.json", ProjectKind::Node),
71];
72
73const VCS_MARKER: &str = ".git";
75
76#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct WorkspaceRoot {
79 pub path: PathBuf,
80 pub kind: ProjectKind,
82 pub kinds: Vec<ProjectKind>,
84 pub detected: bool,
86}
87
88impl WorkspaceRoot {
89 pub fn display_name(&self) -> String {
91 self.path
92 .file_name()
93 .map(|n| n.to_string_lossy().into_owned())
94 .unwrap_or_else(|| self.path.to_string_lossy().into_owned())
95 }
96}
97
98pub fn detect_root(fs: &dyn FileSystemService, start: &Path) -> WorkspaceRoot {
104 let start = fs.canonicalize(start).unwrap_or_else(|_| start.to_path_buf());
107
108 for dir in start.ancestors() {
109 let Ok(entries) = fs.read_dir(dir) else {
110 break;
112 };
113 let has = |name: &str| entries.iter().any(|e| e.name == name);
114
115 let mut kinds: Vec<_> = PROJECT_MARKERS
116 .iter()
117 .filter(|(marker, _)| has(marker))
118 .map(|(_, kind)| *kind)
119 .collect();
120 let mut seen = Vec::new();
124 kinds.retain(|kind| {
125 if seen.contains(kind) {
126 false
127 } else {
128 seen.push(*kind);
129 true
130 }
131 });
132 if let Some(kind) = kinds.first().copied() {
133 return WorkspaceRoot { path: dir.to_path_buf(), kind, kinds, detected: true };
134 }
135 if has(VCS_MARKER) {
136 return WorkspaceRoot {
137 path: dir.to_path_buf(),
138 kind: ProjectKind::Unknown,
139 kinds: Vec::new(),
140 detected: true,
141 };
142 }
143 }
144
145 WorkspaceRoot { path: start, kind: ProjectKind::Unknown, kinds: Vec::new(), detected: false }
146}
147
148pub fn project_kind_of(fs: &dyn FileSystemService, dir: &Path) -> Result<ProjectKind, FsError> {
150 let entries = fs.read_dir(dir)?;
151 Ok(PROJECT_MARKERS
152 .iter()
153 .find(|(marker, _)| entries.iter().any(|e| e.name == *marker))
154 .map(|(_, kind)| *kind)
155 .unwrap_or_default())
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161 use termesh_test_support::FakeFileSystem;
162
163 #[test]
164 fn climbs_to_the_nearest_marker() {
165 let fs = FakeFileSystem::with_paths(&["/repo/Cargo.toml", "/repo/src/deep/mod.rs"]);
166 let root = detect_root(&fs, Path::new("/repo/src/deep"));
167 assert_eq!(root.path, Path::new("/repo"));
168 assert_eq!(root.kind, ProjectKind::Rust);
169 assert!(root.detected);
170 }
171
172 #[test]
173 fn nearest_root_wins_over_an_outer_one() {
174 let fs = FakeFileSystem::with_paths(&[
176 "/repo/.git/config",
177 "/repo/crates/inner/Cargo.toml",
178 "/repo/crates/inner/src/lib.rs",
179 ]);
180 let root = detect_root(&fs, Path::new("/repo/crates/inner/src"));
181 assert_eq!(root.path, Path::new("/repo/crates/inner"));
182 }
183
184 #[test]
185 fn git_alone_marks_a_root_without_a_project_kind() {
186 let fs = FakeFileSystem::with_paths(&["/repo/.git/config", "/repo/notes.txt"]);
187 let root = detect_root(&fs, Path::new("/repo"));
188 assert_eq!(root.path, Path::new("/repo"));
189 assert_eq!(root.kind, ProjectKind::Unknown);
190 assert!(root.detected);
191 }
192
193 #[test]
194 fn a_bare_directory_is_still_a_usable_root() {
195 let fs = FakeFileSystem::with_paths(&["/scratch/a.txt"]);
196 let root = detect_root(&fs, Path::new("/scratch"));
197 assert_eq!(root.path, Path::new("/scratch"));
198 assert!(!root.detected, "fallback must be distinguishable from a real detection");
199 }
200
201 #[test]
202 fn parent_segments_are_resolved_before_climbing() {
203 let fs = FakeFileSystem::with_paths(&["/repo/Cargo.toml", "/repo/src/lib.rs"]);
204 let root = detect_root(&fs, Path::new("/repo/src/../src"));
205 assert_eq!(root.path, Path::new("/repo"));
206 }
207
208 #[test]
209 fn project_markers_take_priority_over_each_other_deterministically() {
210 let fs = FakeFileSystem::with_paths(&["/p/Cargo.toml", "/p/package.json"]);
211 assert_eq!(project_kind_of(&fs, Path::new("/p")).unwrap(), ProjectKind::Rust);
212 }
213
214 #[test]
215 fn a_root_with_several_markers_reports_all_of_them() {
216 let fs = FakeFileSystem::with_paths(&[
217 "/repo/Cargo.toml",
218 "/repo/package.json",
219 "/repo/pyproject.toml",
220 ]);
221 let root = detect_root(&fs, Path::new("/repo"));
222
223 assert_eq!(
224 root.kinds,
225 vec![ProjectKind::Rust, ProjectKind::Python, ProjectKind::Node],
226 "marker priority order, not directory order"
227 );
228 assert_eq!(root.kind, ProjectKind::Rust, "the primary is still the first match");
229 }
230
231 #[test]
232 fn a_single_marker_root_is_unchanged() {
233 let fs = FakeFileSystem::with_paths(&["/repo/go.mod"]);
234 let root = detect_root(&fs, Path::new("/repo"));
235
236 assert_eq!(root.kind, ProjectKind::Go);
237 assert_eq!(root.kinds, vec![ProjectKind::Go]);
238 }
239
240 #[test]
241 fn each_java_marker_maps_to_java() {
242 for marker in [
243 "pom.xml",
244 "build.gradle",
245 "build.gradle.kts",
246 "settings.gradle",
247 "settings.gradle.kts",
248 ] {
249 let fs = FakeFileSystem::with_paths(&[&format!("/repo/{marker}")]);
250 assert_eq!(detect_root(&fs, Path::new("/repo")).kind, ProjectKind::Java, "{marker}");
251 }
252 }
253
254 #[test]
255 fn a_repository_with_several_java_markers_reports_java_once() {
256 let fs = FakeFileSystem::with_paths(&[
261 "/repo/pom.xml",
262 "/repo/build.gradle",
263 "/repo/build.gradle.kts",
264 ]);
265 let root = detect_root(&fs, Path::new("/repo"));
266 assert_eq!(root.kinds, vec![ProjectKind::Java]);
267 }
268
269 #[test]
270 fn a_java_and_node_repository_reports_both_once_each() {
271 let fs = FakeFileSystem::with_paths(&[
272 "/repo/pom.xml",
273 "/repo/build.gradle",
274 "/repo/package.json",
275 ]);
276 let root = detect_root(&fs, Path::new("/repo"));
277 assert_eq!(root.kinds, vec![ProjectKind::Java, ProjectKind::Node]);
278 assert_eq!(root.kind, ProjectKind::Java, "marker priority still picks the primary");
279 }
280
281 #[test]
282 fn the_status_bar_label_lists_java_once() {
283 assert_eq!(kind_labels(&[ProjectKind::Java]), "java");
284 }
285
286 #[test]
287 fn git_alone_still_reports_no_project_kind() {
288 let fs = FakeFileSystem::with_paths(&["/repo/.git/HEAD"]);
289 let root = detect_root(&fs, Path::new("/repo"));
290
291 assert_eq!(root.kind, ProjectKind::Unknown);
292 assert!(root.kinds.is_empty(), "an unknown kind is an empty set, not [Unknown]");
293 }
294
295 #[test]
296 fn markers_below_the_root_are_not_detected() {
297 let fs = FakeFileSystem::with_paths(&["/repo/Cargo.toml", "/repo/web/package.json"]);
300 let root = detect_root(&fs, Path::new("/repo"));
301
302 assert_eq!(root.kinds, vec![ProjectKind::Rust]);
303 }
304
305 #[test]
306 fn each_marker_maps_to_its_kind() {
307 for (marker, expected) in
308 [("go.mod", ProjectKind::Go), ("pyproject.toml", ProjectKind::Python)]
309 {
310 let fs = FakeFileSystem::new();
311 fs.add_file(format!("/p/{marker}"), b"");
312 assert_eq!(project_kind_of(&fs, Path::new("/p")).unwrap(), expected);
313 }
314 }
315
316 #[test]
317 fn display_name_is_the_root_directory_name() {
318 let root = WorkspaceRoot {
319 path: PathBuf::from("/home/me/myproject"),
320 kind: ProjectKind::Rust,
321 kinds: vec![ProjectKind::Rust],
322 detected: true,
323 };
324 assert_eq!(root.display_name(), "myproject");
325 }
326}