use std::path::{Path, PathBuf};
use termesh_filesystem::{FileSystemService, FsError};
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ProjectKind {
Rust,
Java,
Node,
Python,
Go,
#[default]
Unknown,
}
impl ProjectKind {
pub fn label(self) -> &'static str {
match self {
ProjectKind::Rust => "rust",
ProjectKind::Java => "java",
ProjectKind::Node => "node",
ProjectKind::Python => "python",
ProjectKind::Go => "go",
ProjectKind::Unknown => "unknown",
}
}
}
pub fn kind_labels(kinds: &[ProjectKind]) -> String {
if kinds.is_empty() {
return ProjectKind::Unknown.label().to_string();
}
kinds.iter().map(|kind| kind.label()).collect::<Vec<_>>().join(", ")
}
const PROJECT_MARKERS: &[(&str, ProjectKind)] = &[
("Cargo.toml", ProjectKind::Rust),
("pom.xml", ProjectKind::Java),
("build.gradle", ProjectKind::Java),
("build.gradle.kts", ProjectKind::Java),
("settings.gradle", ProjectKind::Java),
("settings.gradle.kts", ProjectKind::Java),
("go.mod", ProjectKind::Go),
("pyproject.toml", ProjectKind::Python),
("package.json", ProjectKind::Node),
];
const VCS_MARKER: &str = ".git";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceRoot {
pub path: PathBuf,
pub kind: ProjectKind,
pub kinds: Vec<ProjectKind>,
pub detected: bool,
}
impl WorkspaceRoot {
pub fn display_name(&self) -> String {
self.path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| self.path.to_string_lossy().into_owned())
}
}
pub fn detect_root(fs: &dyn FileSystemService, start: &Path) -> WorkspaceRoot {
let start = fs.canonicalize(start).unwrap_or_else(|_| start.to_path_buf());
for dir in start.ancestors() {
let Ok(entries) = fs.read_dir(dir) else {
break;
};
let has = |name: &str| entries.iter().any(|e| e.name == name);
let mut kinds: Vec<_> = PROJECT_MARKERS
.iter()
.filter(|(marker, _)| has(marker))
.map(|(_, kind)| *kind)
.collect();
let mut seen = Vec::new();
kinds.retain(|kind| {
if seen.contains(kind) {
false
} else {
seen.push(*kind);
true
}
});
if let Some(kind) = kinds.first().copied() {
return WorkspaceRoot { path: dir.to_path_buf(), kind, kinds, detected: true };
}
if has(VCS_MARKER) {
return WorkspaceRoot {
path: dir.to_path_buf(),
kind: ProjectKind::Unknown,
kinds: Vec::new(),
detected: true,
};
}
}
WorkspaceRoot { path: start, kind: ProjectKind::Unknown, kinds: Vec::new(), detected: false }
}
pub fn project_kind_of(fs: &dyn FileSystemService, dir: &Path) -> Result<ProjectKind, FsError> {
let entries = fs.read_dir(dir)?;
Ok(PROJECT_MARKERS
.iter()
.find(|(marker, _)| entries.iter().any(|e| e.name == *marker))
.map(|(_, kind)| *kind)
.unwrap_or_default())
}
#[cfg(test)]
mod tests {
use super::*;
use termesh_test_support::FakeFileSystem;
#[test]
fn climbs_to_the_nearest_marker() {
let fs = FakeFileSystem::with_paths(&["/repo/Cargo.toml", "/repo/src/deep/mod.rs"]);
let root = detect_root(&fs, Path::new("/repo/src/deep"));
assert_eq!(root.path, Path::new("/repo"));
assert_eq!(root.kind, ProjectKind::Rust);
assert!(root.detected);
}
#[test]
fn nearest_root_wins_over_an_outer_one() {
let fs = FakeFileSystem::with_paths(&[
"/repo/.git/config",
"/repo/crates/inner/Cargo.toml",
"/repo/crates/inner/src/lib.rs",
]);
let root = detect_root(&fs, Path::new("/repo/crates/inner/src"));
assert_eq!(root.path, Path::new("/repo/crates/inner"));
}
#[test]
fn git_alone_marks_a_root_without_a_project_kind() {
let fs = FakeFileSystem::with_paths(&["/repo/.git/config", "/repo/notes.txt"]);
let root = detect_root(&fs, Path::new("/repo"));
assert_eq!(root.path, Path::new("/repo"));
assert_eq!(root.kind, ProjectKind::Unknown);
assert!(root.detected);
}
#[test]
fn a_bare_directory_is_still_a_usable_root() {
let fs = FakeFileSystem::with_paths(&["/scratch/a.txt"]);
let root = detect_root(&fs, Path::new("/scratch"));
assert_eq!(root.path, Path::new("/scratch"));
assert!(!root.detected, "fallback must be distinguishable from a real detection");
}
#[test]
fn parent_segments_are_resolved_before_climbing() {
let fs = FakeFileSystem::with_paths(&["/repo/Cargo.toml", "/repo/src/lib.rs"]);
let root = detect_root(&fs, Path::new("/repo/src/../src"));
assert_eq!(root.path, Path::new("/repo"));
}
#[test]
fn project_markers_take_priority_over_each_other_deterministically() {
let fs = FakeFileSystem::with_paths(&["/p/Cargo.toml", "/p/package.json"]);
assert_eq!(project_kind_of(&fs, Path::new("/p")).unwrap(), ProjectKind::Rust);
}
#[test]
fn a_root_with_several_markers_reports_all_of_them() {
let fs = FakeFileSystem::with_paths(&[
"/repo/Cargo.toml",
"/repo/package.json",
"/repo/pyproject.toml",
]);
let root = detect_root(&fs, Path::new("/repo"));
assert_eq!(
root.kinds,
vec![ProjectKind::Rust, ProjectKind::Python, ProjectKind::Node],
"marker priority order, not directory order"
);
assert_eq!(root.kind, ProjectKind::Rust, "the primary is still the first match");
}
#[test]
fn a_single_marker_root_is_unchanged() {
let fs = FakeFileSystem::with_paths(&["/repo/go.mod"]);
let root = detect_root(&fs, Path::new("/repo"));
assert_eq!(root.kind, ProjectKind::Go);
assert_eq!(root.kinds, vec![ProjectKind::Go]);
}
#[test]
fn each_java_marker_maps_to_java() {
for marker in [
"pom.xml",
"build.gradle",
"build.gradle.kts",
"settings.gradle",
"settings.gradle.kts",
] {
let fs = FakeFileSystem::with_paths(&[&format!("/repo/{marker}")]);
assert_eq!(detect_root(&fs, Path::new("/repo")).kind, ProjectKind::Java, "{marker}");
}
}
#[test]
fn a_repository_with_several_java_markers_reports_java_once() {
let fs = FakeFileSystem::with_paths(&[
"/repo/pom.xml",
"/repo/build.gradle",
"/repo/build.gradle.kts",
]);
let root = detect_root(&fs, Path::new("/repo"));
assert_eq!(root.kinds, vec![ProjectKind::Java]);
}
#[test]
fn a_java_and_node_repository_reports_both_once_each() {
let fs = FakeFileSystem::with_paths(&[
"/repo/pom.xml",
"/repo/build.gradle",
"/repo/package.json",
]);
let root = detect_root(&fs, Path::new("/repo"));
assert_eq!(root.kinds, vec![ProjectKind::Java, ProjectKind::Node]);
assert_eq!(root.kind, ProjectKind::Java, "marker priority still picks the primary");
}
#[test]
fn the_status_bar_label_lists_java_once() {
assert_eq!(kind_labels(&[ProjectKind::Java]), "java");
}
#[test]
fn git_alone_still_reports_no_project_kind() {
let fs = FakeFileSystem::with_paths(&["/repo/.git/HEAD"]);
let root = detect_root(&fs, Path::new("/repo"));
assert_eq!(root.kind, ProjectKind::Unknown);
assert!(root.kinds.is_empty(), "an unknown kind is an empty set, not [Unknown]");
}
#[test]
fn markers_below_the_root_are_not_detected() {
let fs = FakeFileSystem::with_paths(&["/repo/Cargo.toml", "/repo/web/package.json"]);
let root = detect_root(&fs, Path::new("/repo"));
assert_eq!(root.kinds, vec![ProjectKind::Rust]);
}
#[test]
fn each_marker_maps_to_its_kind() {
for (marker, expected) in
[("go.mod", ProjectKind::Go), ("pyproject.toml", ProjectKind::Python)]
{
let fs = FakeFileSystem::new();
fs.add_file(format!("/p/{marker}"), b"");
assert_eq!(project_kind_of(&fs, Path::new("/p")).unwrap(), expected);
}
}
#[test]
fn display_name_is_the_root_directory_name() {
let root = WorkspaceRoot {
path: PathBuf::from("/home/me/myproject"),
kind: ProjectKind::Rust,
kinds: vec![ProjectKind::Rust],
detected: true,
};
assert_eq!(root.display_name(), "myproject");
}
}