Skip to main content

termesh_workspace/
root.rs

1//! Workspace root and project-type detection (ADR-0005 §6, ARCHITECTURE.md §16 Phase 02).
2//!
3//! Goes through [`FileSystemService`] like everything else — never `std::fs` — which is
4//! also what makes it testable against an in-memory tree.
5
6use std::path::{Path, PathBuf};
7
8use termesh_filesystem::{FileSystemService, FsError};
9
10/// What kind of project a root looks like. Selects the language recipe and the task
11/// adapter, and names the project in agent context.
12///
13/// A root reports *every* kind it matches, not one: a repository holding both a
14/// `pom.xml` and a `package.json` is both, and each side starts its own language session
15/// on the first document it claims (ADR-0012).
16#[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
41/// One label for a detected set — `"rust, node"`.
42///
43/// A polyglot root that reads as a single language tells the developer we found less
44/// than we did, so every surface that names the project kind uses this rather than the
45/// primary alone (ADR-0012 §1). An empty set is `"unknown"`, matching a root found by
46/// `.git` or no marker at all.
47pub 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
54/// Marker files that identify project types, in priority order. Higher rows win primary
55/// status: Java follows the flagship Rust marker but precedes Node so a Java backend with
56/// a `package.json` frontend is identified primarily as Java. A directory holding several
57/// markers reports all matches while retaining the first as its primary kind.
58const 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    // A Gradle multi-project root often declares only `settings.gradle`, leaving the
64    // build files to its modules. Without these rows such a root is not a project at
65    // all: no language server and no tasks.
66    ("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
73/// `.git` alone marks a root without identifying a project type.
74const VCS_MARKER: &str = ".git";
75
76/// A detected project root.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct WorkspaceRoot {
79    pub path: PathBuf,
80    /// The primary kind retained for display and existing single-answer call sites.
81    pub kind: ProjectKind,
82    /// Every marker found at this root, in marker-priority order (ADR-0012 §1).
83    pub kinds: Vec<ProjectKind>,
84    /// True when we found a real marker; false when we fell back to the given directory.
85    pub detected: bool,
86}
87
88impl WorkspaceRoot {
89    /// The name shown in the explorer header — the root directory's own name.
90    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
98/// Walk up from `start` looking for a project or VCS marker.
99///
100/// The nearest ancestor holding a marker wins, so opening `myrepo/src/deep/` lands on
101/// `myrepo`. If nothing matches all the way up, we fall back to `start` itself with
102/// `detected: false` — opening a bare directory is legitimate, not an error.
103pub fn detect_root(fs: &dyn FileSystemService, start: &Path) -> WorkspaceRoot {
104    // Resolve first so `..` segments don't confuse the upward walk. A path we cannot
105    // canonicalize (missing, unreadable) still gets used verbatim rather than failing.
106    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            // Unreadable ancestor: stop climbing rather than silently skipping past it.
111            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        // Java is the first kind with several markers. Keep aliases from duplicating
121        // status, recipes, tasks, and agent context while preserving marker priority
122        // (ADR-0013 §2); sorting here would change the primary kind contract.
123        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
148/// Detect the project type of one directory without walking up.
149pub 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        // A crate nested inside a larger repo resolves to the crate, not the repo.
175        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        // Java is the first kind with more than one marker. Every existing kind has
257        // exactly one, so nothing has ever guarded against duplicates: without a dedup
258        // this reports [Java, Java], the status bar reads "(java, java)", and two
259        // recipes claim `.java` while only the first can ever start (ADR-0013 §2).
260        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        // Root-level scan on purpose (ADR-0012 §1). Finding nested projects is monorepo
298        // support and is out of scope for this phase.
299        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}