831 lines
28 KiB
Rust
831 lines
28 KiB
Rust
// project.rs --- T M4.9 project model: roots, workspace, scoping.
|
|
|
|
//! Project root detection, workspace state, and project-scoped LSP
|
|
//! reuse.
|
|
//!
|
|
//! Per spec §M4.9: "opening a file in a known project type identifies
|
|
//! the root", "project switch is a first-class operation", "LSP
|
|
//! servers run per-project, not per-buffer".
|
|
//!
|
|
//! # Detection
|
|
//!
|
|
//! [`detect_project`] walks upward from a file path looking for a
|
|
//! known marker. Markers are ordered: a deeper match (closer to the
|
|
//! file) wins, but among siblings the order in [`PROJECT_MARKERS`]
|
|
//! ranks language-specific markers above generic VCS roots so that
|
|
//! a `Cargo.toml` next to a `.git` is treated as a Rust project, not
|
|
//! a generic git repo. Walks stop at filesystem roots.
|
|
//!
|
|
//! # Workspace
|
|
//!
|
|
//! [`Workspace`] is the editor-wide registry: a map of canonical
|
|
//! project roots to [`Project`] values, plus an `active` pointer.
|
|
//! Opening the same root twice returns the existing id (idempotent).
|
|
//! Closing drops the project and any per-project LSP scoping built
|
|
//! on top of it.
|
|
//!
|
|
//! # LSP scoping
|
|
//!
|
|
//! [`crate::lsp::LspManager`] holds a `(project_root, language_id) →
|
|
//! LspServerId` table; `ensure_server_for_project` returns the
|
|
//! existing server if one is already serving that pair *and* is in a
|
|
//! healthy state, else spawns one. This is the mechanism by which
|
|
//! "LSP runs per-project, not per-buffer".
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::time::Instant;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Identity
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Stable identifier for one open project. Allocated in monotonic order.
|
|
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
|
|
pub struct ProjectId(u64);
|
|
|
|
impl ProjectId {
|
|
/// Mint a fresh id.
|
|
#[must_use]
|
|
pub fn next() -> Self {
|
|
static COUNTER: AtomicU64 = AtomicU64::new(1);
|
|
Self(COUNTER.fetch_add(1, Ordering::Relaxed))
|
|
}
|
|
|
|
/// Raw counter value. Used at the Lua boundary.
|
|
#[must_use]
|
|
pub fn raw(self) -> u64 {
|
|
self.0
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for ProjectId {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "ProjectId({})", self.0)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Project kinds + markers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// What kind of project a marker file identifies. Drives default LSP
|
|
/// language ids and labelling.
|
|
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
|
|
pub enum ProjectKind {
|
|
/// A Cargo workspace (`Cargo.toml`).
|
|
Rust,
|
|
/// A Lua project (`.luarc.json`).
|
|
Lua,
|
|
/// A Node.js / JavaScript project (`package.json`).
|
|
Node,
|
|
/// A Python project (`pyproject.toml`).
|
|
Python,
|
|
/// A Go module (`go.mod`).
|
|
Go,
|
|
/// A Deno project (`deno.json` / `deno.jsonc`).
|
|
Deno,
|
|
/// A bare git repository (no language marker found inside).
|
|
Git,
|
|
/// Detected via a custom marker registered at runtime.
|
|
Custom(String),
|
|
}
|
|
|
|
impl ProjectKind {
|
|
/// Stable lower-case tag (`"rust"`, `"lua"`, …) used by the Lua
|
|
/// surface and the `*lsp*` buffer.
|
|
#[must_use]
|
|
pub fn tag(&self) -> &str {
|
|
match self {
|
|
Self::Rust => "rust",
|
|
Self::Lua => "lua",
|
|
Self::Node => "node",
|
|
Self::Python => "python",
|
|
Self::Go => "go",
|
|
Self::Deno => "deno",
|
|
Self::Git => "git",
|
|
Self::Custom(s) => s.as_str(),
|
|
}
|
|
}
|
|
|
|
/// Default LSP `languageId` for this project. Buffers in this
|
|
/// kind of project use this string in `didOpen`/`didChange` so
|
|
/// that one LSP per `(root, language_id)` is enough.
|
|
#[must_use]
|
|
pub fn default_language_id(&self) -> &str {
|
|
match self {
|
|
Self::Rust => "rust",
|
|
Self::Lua => "lua",
|
|
Self::Node => "javascript",
|
|
Self::Python => "python",
|
|
Self::Go => "go",
|
|
Self::Deno => "typescript",
|
|
Self::Git | Self::Custom(_) => "plaintext",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One marker rule: a file or directory name and the kind it
|
|
/// identifies.
|
|
#[derive(Clone, Debug)]
|
|
pub struct ProjectMarker {
|
|
/// File or directory name to look for at each ancestor level.
|
|
pub name: &'static str,
|
|
/// Kind to report when this marker matches.
|
|
pub kind: ProjectKind,
|
|
/// `true` if the marker should be a directory (e.g. `.git`).
|
|
/// `false` matches files (e.g. `Cargo.toml`).
|
|
pub is_directory: bool,
|
|
}
|
|
|
|
/// Built-in marker rules, ranked so language-specific markers beat
|
|
/// the generic `.git` when both exist at the same ancestor level.
|
|
#[must_use]
|
|
pub fn default_markers() -> Vec<ProjectMarker> {
|
|
vec![
|
|
ProjectMarker {
|
|
name: "Cargo.toml",
|
|
kind: ProjectKind::Rust,
|
|
is_directory: false,
|
|
},
|
|
ProjectMarker {
|
|
name: ".luarc.json",
|
|
kind: ProjectKind::Lua,
|
|
is_directory: false,
|
|
},
|
|
ProjectMarker {
|
|
name: "pyproject.toml",
|
|
kind: ProjectKind::Python,
|
|
is_directory: false,
|
|
},
|
|
ProjectMarker {
|
|
name: "go.mod",
|
|
kind: ProjectKind::Go,
|
|
is_directory: false,
|
|
},
|
|
ProjectMarker {
|
|
name: "deno.json",
|
|
kind: ProjectKind::Deno,
|
|
is_directory: false,
|
|
},
|
|
ProjectMarker {
|
|
name: "deno.jsonc",
|
|
kind: ProjectKind::Deno,
|
|
is_directory: false,
|
|
},
|
|
ProjectMarker {
|
|
name: "package.json",
|
|
kind: ProjectKind::Node,
|
|
is_directory: false,
|
|
},
|
|
// Generic VCS root: lowest priority among siblings.
|
|
ProjectMarker {
|
|
name: ".git",
|
|
kind: ProjectKind::Git,
|
|
is_directory: true,
|
|
},
|
|
]
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Detection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Walk upward from `start` looking for any marker in `markers`.
|
|
/// Returns the first ancestor where a marker matches, with the
|
|
/// marker's kind. Returns `None` if no marker is found before the
|
|
/// filesystem root.
|
|
///
|
|
/// `start` may be a file or a directory. If `start` is a file, the
|
|
/// search begins at its parent.
|
|
#[must_use]
|
|
pub fn detect_project(start: &Path, markers: &[ProjectMarker]) -> Option<(PathBuf, ProjectKind)> {
|
|
walk_for_marker(start, markers, None)
|
|
}
|
|
|
|
/// Like [`detect_project`], but halts the upward walk after examining
|
|
/// `stop_root`. Used by tests so a stray marker in a temp-dir's
|
|
/// ancestor (e.g. a developer's `/tmp/.git`) can't leak into a
|
|
/// fixture that lives below it. The walk still examines `stop_root`
|
|
/// itself; only its parent and beyond are skipped.
|
|
#[must_use]
|
|
pub fn detect_project_within(
|
|
start: &Path,
|
|
markers: &[ProjectMarker],
|
|
stop_root: &Path,
|
|
) -> Option<(PathBuf, ProjectKind)> {
|
|
walk_for_marker(start, markers, Some(stop_root))
|
|
}
|
|
|
|
fn walk_for_marker(
|
|
start: &Path,
|
|
markers: &[ProjectMarker],
|
|
stop_root: Option<&Path>,
|
|
) -> Option<(PathBuf, ProjectKind)> {
|
|
let start_dir: &Path = if start.is_file() {
|
|
start.parent().unwrap_or(start)
|
|
} else {
|
|
start
|
|
};
|
|
for ancestor in start_dir.ancestors() {
|
|
if let Some(kind) = match_marker(ancestor, markers) {
|
|
return Some((ancestor.to_path_buf(), kind));
|
|
}
|
|
if let Some(stop) = stop_root
|
|
&& ancestor == stop
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Return the highest-priority marker that matches this directory,
|
|
/// if any.
|
|
fn match_marker(dir: &Path, markers: &[ProjectMarker]) -> Option<ProjectKind> {
|
|
for m in markers {
|
|
let candidate = dir.join(m.name);
|
|
let matches = if m.is_directory {
|
|
candidate.is_dir()
|
|
} else {
|
|
candidate.is_file()
|
|
};
|
|
if matches {
|
|
return Some(m.kind.clone());
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Project + Workspace
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// One open project.
|
|
#[derive(Clone, Debug)]
|
|
pub struct Project {
|
|
/// Stable identifier.
|
|
pub id: ProjectId,
|
|
/// Canonical absolute project root.
|
|
pub root: PathBuf,
|
|
/// Detected kind. May be `Custom` for projects opened with an
|
|
/// explicit kind override.
|
|
pub kind: ProjectKind,
|
|
/// Display name. Defaults to the basename of `root`.
|
|
pub name: String,
|
|
/// When [`Workspace::open`] inserted this project.
|
|
pub opened_at: Instant,
|
|
}
|
|
|
|
/// Editor-wide project registry. Owns one [`Project`] per unique
|
|
/// canonical root and tracks the active project (the one that
|
|
/// project-scoped commands target).
|
|
pub struct Workspace {
|
|
by_id: HashMap<ProjectId, Project>,
|
|
by_root: HashMap<PathBuf, ProjectId>,
|
|
active: Option<ProjectId>,
|
|
markers: Vec<ProjectMarker>,
|
|
/// Optional clamp on [`Self::detect`]'s upward marker walk.
|
|
/// When `None` (the default), detection walks ancestors all the
|
|
/// way to the filesystem root (matching `git rev-parse
|
|
/// --show-toplevel` semantics). When `Some(boundary)`, the walk
|
|
/// halts after examining `boundary`; ancestors above `boundary`
|
|
/// are not consulted.
|
|
///
|
|
/// Stored canonicalized so the symlinked-workspace case behaves
|
|
/// predictably (see [`Self::set_search_boundary`]).
|
|
search_boundary: Option<PathBuf>,
|
|
}
|
|
|
|
impl Default for Workspace {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl Workspace {
|
|
/// Empty workspace with the [`default_markers`] rule set and no
|
|
/// search boundary (detection walks to filesystem root).
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self {
|
|
by_id: HashMap::new(),
|
|
by_root: HashMap::new(),
|
|
active: None,
|
|
markers: default_markers(),
|
|
search_boundary: None,
|
|
}
|
|
}
|
|
|
|
/// Replace the marker rule set. Useful for tests or for
|
|
/// runtime-registered language packs (M5+).
|
|
pub fn set_markers(&mut self, markers: Vec<ProjectMarker>) {
|
|
self.markers = markers;
|
|
}
|
|
|
|
/// Read-only view of the configured markers.
|
|
#[must_use]
|
|
pub fn markers(&self) -> &[ProjectMarker] {
|
|
&self.markers
|
|
}
|
|
|
|
/// Set (or clear) the upward-walk boundary for [`Self::detect`].
|
|
///
|
|
/// `Some(path)` clamps detection so it never examines ancestors
|
|
/// above `path`. `None` restores the default walk-to-filesystem-root
|
|
/// behavior. Use case: a user whose code lives under `~/code` can
|
|
/// set `search_boundary = "~/code"` in `init.lua` so a stray
|
|
/// marker higher up the tree (e.g. `/tmp/.git`, an orphaned `.git`
|
|
/// in `~`) cannot capture unrelated files.
|
|
///
|
|
/// # Symlink handling
|
|
///
|
|
/// The boundary is canonicalized at set time, and start paths are
|
|
/// canonicalized at detect time, so a search starting from a
|
|
/// symlinked path that resolves under the boundary still respects
|
|
/// the boundary. If `path` does not exist on disk we store it
|
|
/// as-is; later detection then compares against the literal value
|
|
/// (which matches the upstream behavior of
|
|
/// [`canonicalize_or_passthrough`]).
|
|
///
|
|
/// # Inclusivity
|
|
///
|
|
/// The boundary is *inclusive*: a marker located at the boundary
|
|
/// path itself is found; only strict ancestors of the boundary
|
|
/// are skipped. Set the boundary to the directory that *contains*
|
|
/// your projects, not to one level above.
|
|
pub fn set_search_boundary(&mut self, path: Option<PathBuf>) {
|
|
self.search_boundary = path.map(|p| canonicalize_or_passthrough(&p));
|
|
}
|
|
|
|
/// Read-only view of the configured search boundary.
|
|
#[must_use]
|
|
pub fn search_boundary(&self) -> Option<&Path> {
|
|
self.search_boundary.as_deref()
|
|
}
|
|
|
|
/// Detect the project root for `file_path`, honoring the
|
|
/// workspace's [`Self::set_search_boundary`] clamp if any.
|
|
///
|
|
/// `file_path` is canonicalized before the walk if possible so
|
|
/// that boundary comparison works correctly under symlinks (e.g.,
|
|
/// `/tmp/sandbox/foo` symlinked to `/home/user/code/foo`).
|
|
#[must_use]
|
|
pub fn detect(&self, file_path: &Path) -> Option<(PathBuf, ProjectKind)> {
|
|
let canonical = canonicalize_or_passthrough(file_path);
|
|
match self.search_boundary.as_deref() {
|
|
Some(boundary) => detect_project_within(&canonical, &self.markers, boundary),
|
|
None => detect_project(&canonical, &self.markers),
|
|
}
|
|
}
|
|
|
|
/// Open a project at `root`. Idempotent: if a project for the
|
|
/// same canonical root is already open, returns its id without
|
|
/// allocating a new one. The first opened project becomes
|
|
/// active by default.
|
|
pub fn open(
|
|
&mut self,
|
|
root: impl Into<PathBuf>,
|
|
kind: ProjectKind,
|
|
name: Option<String>,
|
|
) -> ProjectId {
|
|
let root = canonicalize_or_passthrough(&root.into());
|
|
if let Some(id) = self.by_root.get(&root) {
|
|
return *id;
|
|
}
|
|
let id = ProjectId::next();
|
|
let project = Project {
|
|
id,
|
|
name: name.unwrap_or_else(|| {
|
|
root.file_name()
|
|
.and_then(|s| s.to_str())
|
|
.unwrap_or("project")
|
|
.to_owned()
|
|
}),
|
|
root: root.clone(),
|
|
kind,
|
|
opened_at: Instant::now(),
|
|
};
|
|
self.by_root.insert(root, id);
|
|
self.by_id.insert(id, project);
|
|
if self.active.is_none() {
|
|
self.active = Some(id);
|
|
}
|
|
id
|
|
}
|
|
|
|
/// Open by detecting the kind from `root` itself. Returns `None`
|
|
/// if no marker matches at `root` (callers can still
|
|
/// [`Self::open`] explicitly with a kind override).
|
|
pub fn open_detected(&mut self, root: impl Into<PathBuf>) -> Option<ProjectId> {
|
|
let root = root.into();
|
|
let kind = match_marker(&root, &self.markers)?;
|
|
Some(self.open(root, kind, None))
|
|
}
|
|
|
|
/// Open the project containing `file_path`. Returns `None` if no
|
|
/// marker is found between `file_path` and the filesystem root.
|
|
pub fn open_for_file(&mut self, file_path: &Path) -> Option<ProjectId> {
|
|
let (root, kind) = self.detect(file_path)?;
|
|
Some(self.open(root, kind, None))
|
|
}
|
|
|
|
/// Close `id`. Returns `Ok(())` even if `id` was unknown
|
|
/// (idempotent). If the closed project was active, the active
|
|
/// pointer falls back to the most-recently-opened remaining
|
|
/// project, or `None` if the workspace becomes empty.
|
|
pub fn close(&mut self, id: ProjectId) {
|
|
let Some(p) = self.by_id.remove(&id) else {
|
|
return;
|
|
};
|
|
self.by_root.remove(&p.root);
|
|
if self.active == Some(id) {
|
|
self.active = self
|
|
.by_id
|
|
.values()
|
|
.max_by_key(|p| p.opened_at)
|
|
.map(|p| p.id);
|
|
}
|
|
}
|
|
|
|
/// Set the active project. Returns `Err` if `id` is unknown.
|
|
pub fn set_active(&mut self, id: ProjectId) -> Result<(), String> {
|
|
if !self.by_id.contains_key(&id) {
|
|
return Err(format!("unknown project: {id}"));
|
|
}
|
|
self.active = Some(id);
|
|
Ok(())
|
|
}
|
|
|
|
/// Currently active project, if any.
|
|
#[must_use]
|
|
pub fn active(&self) -> Option<&Project> {
|
|
self.active.and_then(|id| self.by_id.get(&id))
|
|
}
|
|
|
|
/// Active project id.
|
|
#[must_use]
|
|
pub fn active_id(&self) -> Option<ProjectId> {
|
|
self.active
|
|
}
|
|
|
|
/// Look up a project by id.
|
|
#[must_use]
|
|
pub fn get(&self, id: ProjectId) -> Option<&Project> {
|
|
self.by_id.get(&id)
|
|
}
|
|
|
|
/// Look up a project by canonical root.
|
|
#[must_use]
|
|
pub fn by_root(&self, root: &Path) -> Option<&Project> {
|
|
let canon = canonicalize_or_passthrough(root);
|
|
self.by_root.get(&canon).and_then(|id| self.by_id.get(id))
|
|
}
|
|
|
|
/// All projects, in id order.
|
|
pub fn projects(&self) -> impl Iterator<Item = &Project> {
|
|
let mut ids: Vec<ProjectId> = self.by_id.keys().copied().collect();
|
|
ids.sort_by_key(|id| id.raw());
|
|
ids.into_iter().filter_map(move |id| self.by_id.get(&id))
|
|
}
|
|
|
|
/// Total project count.
|
|
#[must_use]
|
|
pub fn len(&self) -> usize {
|
|
self.by_id.len()
|
|
}
|
|
|
|
/// True iff no projects are open.
|
|
#[must_use]
|
|
pub fn is_empty(&self) -> bool {
|
|
self.by_id.is_empty()
|
|
}
|
|
}
|
|
|
|
/// Canonicalise `p` if possible; otherwise return it as-is. Falling
|
|
/// back on the passthrough lets tests use synthetic paths that don't
|
|
/// exist on disk.
|
|
fn canonicalize_or_passthrough(p: &Path) -> PathBuf {
|
|
p.canonicalize().unwrap_or_else(|_| p.to_path_buf())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn touch(path: &Path) {
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent).expect("mkdir");
|
|
}
|
|
std::fs::write(path, b"").expect("touch");
|
|
}
|
|
|
|
fn mkdir(path: &Path) {
|
|
std::fs::create_dir_all(path).expect("mkdir");
|
|
}
|
|
|
|
#[test]
|
|
fn project_kind_tags_are_stable() {
|
|
for (k, tag) in [
|
|
(ProjectKind::Rust, "rust"),
|
|
(ProjectKind::Lua, "lua"),
|
|
(ProjectKind::Node, "node"),
|
|
(ProjectKind::Python, "python"),
|
|
(ProjectKind::Go, "go"),
|
|
(ProjectKind::Deno, "deno"),
|
|
(ProjectKind::Git, "git"),
|
|
] {
|
|
assert_eq!(k.tag(), tag);
|
|
}
|
|
assert_eq!(ProjectKind::Custom("zig".into()).tag(), "zig");
|
|
}
|
|
|
|
#[test]
|
|
fn detect_finds_cargo_toml_above_file() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let root = dir.path();
|
|
touch(&root.join("Cargo.toml"));
|
|
let nested = root.join("src/foo/bar.rs");
|
|
touch(&nested);
|
|
let (found, kind) = detect_project(&nested, &default_markers()).expect("detect");
|
|
// Compare canonicalised paths so symlinked /tmp on macOS
|
|
// still matches.
|
|
assert_eq!(
|
|
found.canonicalize().expect("canon"),
|
|
root.canonicalize().expect("canon")
|
|
);
|
|
assert_eq!(kind, ProjectKind::Rust);
|
|
}
|
|
|
|
#[test]
|
|
fn detect_prefers_language_marker_over_git_at_same_level() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let root = dir.path();
|
|
mkdir(&root.join(".git"));
|
|
touch(&root.join("Cargo.toml"));
|
|
let f = root.join("src/main.rs");
|
|
touch(&f);
|
|
let (_, kind) = detect_project(&f, &default_markers()).expect("detect");
|
|
assert_eq!(kind, ProjectKind::Rust);
|
|
}
|
|
|
|
#[test]
|
|
fn detect_walks_up_through_intermediates() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let root = dir.path();
|
|
touch(&root.join("Cargo.toml"));
|
|
let deep = root.join("a/b/c/d/e.rs");
|
|
touch(&deep);
|
|
let (found, _) = detect_project(&deep, &default_markers()).expect("detect");
|
|
assert_eq!(
|
|
found.canonicalize().expect("canon"),
|
|
root.canonicalize().expect("canon")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn detect_returns_none_with_no_markers() {
|
|
// Bound the walk at the tempdir so a marker in a real
|
|
// ancestor (a developer's `/tmp/.git`, a CI runner's repo
|
|
// root above the test fixture, etc.) can't masquerade as a
|
|
// hit. Production callers walk to the filesystem root; the
|
|
// bound is a test-only correctness aid.
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let f = dir.path().join("a/b.rs");
|
|
touch(&f);
|
|
assert!(detect_project_within(&f, &default_markers(), dir.path()).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn detect_picks_innermost_root_when_nested() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let outer = dir.path();
|
|
let inner = outer.join("crates/inner");
|
|
touch(&outer.join("Cargo.toml"));
|
|
touch(&inner.join("Cargo.toml"));
|
|
let f = inner.join("src/lib.rs");
|
|
touch(&f);
|
|
let (found, kind) = detect_project(&f, &default_markers()).expect("detect");
|
|
assert_eq!(
|
|
found.canonicalize().expect("canon"),
|
|
inner.canonicalize().expect("canon")
|
|
);
|
|
assert_eq!(kind, ProjectKind::Rust);
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_open_is_idempotent_per_root() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let root = dir.path();
|
|
touch(&root.join("Cargo.toml"));
|
|
let mut ws = Workspace::new();
|
|
let id1 = ws.open(root, ProjectKind::Rust, None);
|
|
let id2 = ws.open(root, ProjectKind::Rust, None);
|
|
assert_eq!(id1, id2);
|
|
assert_eq!(ws.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_first_opened_becomes_active() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let mut ws = Workspace::new();
|
|
assert!(ws.active().is_none());
|
|
let id = ws.open(dir.path(), ProjectKind::Git, None);
|
|
assert_eq!(ws.active_id(), Some(id));
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_set_active_switches() {
|
|
let d1 = tempfile::tempdir().expect("d1");
|
|
let d2 = tempfile::tempdir().expect("d2");
|
|
let mut ws = Workspace::new();
|
|
let a = ws.open(d1.path(), ProjectKind::Git, None);
|
|
let b = ws.open(d2.path(), ProjectKind::Git, None);
|
|
assert_eq!(ws.active_id(), Some(a));
|
|
ws.set_active(b).expect("switch");
|
|
assert_eq!(ws.active_id(), Some(b));
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_close_drops_and_falls_back_active() {
|
|
let d1 = tempfile::tempdir().expect("d1");
|
|
let d2 = tempfile::tempdir().expect("d2");
|
|
let mut ws = Workspace::new();
|
|
let a = ws.open(d1.path(), ProjectKind::Git, None);
|
|
let b = ws.open(d2.path(), ProjectKind::Git, None);
|
|
ws.set_active(b).expect("switch");
|
|
ws.close(b);
|
|
assert_eq!(ws.active_id(), Some(a));
|
|
ws.close(a);
|
|
assert!(ws.active_id().is_none());
|
|
assert!(ws.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_open_for_file_runs_detection() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let root = dir.path();
|
|
touch(&root.join("Cargo.toml"));
|
|
let f = root.join("src/lib.rs");
|
|
touch(&f);
|
|
let mut ws = Workspace::new();
|
|
let id = ws.open_for_file(&f).expect("open");
|
|
assert_eq!(ws.get(id).unwrap().kind, ProjectKind::Rust);
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_set_active_unknown_errors() {
|
|
let mut ws = Workspace::new();
|
|
assert!(ws.set_active(ProjectId::next()).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_set_markers_overrides_defaults() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let root = dir.path();
|
|
touch(&root.join("Cargo.toml"));
|
|
let f = root.join("src/lib.rs");
|
|
touch(&f);
|
|
let mut ws = Workspace::new();
|
|
// Replace markers with a set that ignores Cargo.
|
|
ws.set_markers(vec![ProjectMarker {
|
|
name: ".git",
|
|
kind: ProjectKind::Git,
|
|
is_directory: true,
|
|
}]);
|
|
// No git → no detection. Bound the walk at the tempdir so
|
|
// that a `.git` in any real ancestor cannot satisfy the
|
|
// (now sole) git marker; we are testing that `set_markers`
|
|
// chose the marker set, not what lives above the test.
|
|
assert!(detect_project_within(&f, ws.markers(), root).is_none());
|
|
}
|
|
|
|
// ----------------------------------------------------------------------
|
|
// Reviewer-flagged item 2: search-boundary clamp on detection.
|
|
// ----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn search_boundary_default_is_none() {
|
|
let ws = Workspace::new();
|
|
assert!(ws.search_boundary().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn search_boundary_clamps_walk_above_boundary() {
|
|
// Stage a fake "outer marker" above the boundary (the reviewer's
|
|
// /tmp/.git case). Without the boundary, `detect` would walk up
|
|
// to the outer marker. With it, the walk halts at the
|
|
// boundary directory and returns None.
|
|
let outer = tempfile::tempdir().expect("outer");
|
|
// The outer dir gets a git marker.
|
|
mkdir(&outer.path().join(".git"));
|
|
// The "boundary" directory is a child of outer; the file
|
|
// lives below the boundary.
|
|
let boundary = outer.path().join("workspace");
|
|
let f = boundary.join("src/main.rs");
|
|
touch(&f);
|
|
|
|
let mut ws = Workspace::new();
|
|
// Without the boundary, detect walks up and finds the outer
|
|
// marker.
|
|
assert!(
|
|
ws.detect(&f).is_some(),
|
|
"sanity: outer .git is detectable without the boundary"
|
|
);
|
|
|
|
ws.set_search_boundary(Some(boundary.clone()));
|
|
// With the boundary set to the workspace dir, the outer marker
|
|
// is above the boundary and thus invisible.
|
|
assert!(
|
|
ws.detect(&f).is_none(),
|
|
"with boundary at {boundary:?}, the outer marker must be excluded"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn search_boundary_is_inclusive_examines_boundary_itself() {
|
|
// The boundary semantics: the boundary path *itself* is
|
|
// examined for markers; only strict ancestors are skipped.
|
|
// Documented inclusivity: "set boundary to the directory
|
|
// containing your projects, not one level above."
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let root = dir.path();
|
|
touch(&root.join("Cargo.toml"));
|
|
let f = root.join("src/lib.rs");
|
|
touch(&f);
|
|
|
|
let mut ws = Workspace::new();
|
|
ws.set_search_boundary(Some(root.to_path_buf()));
|
|
let detected = ws.detect(&f).expect("marker at the boundary must match");
|
|
// Path equality after canonicalization (macOS /tmp → /private/tmp).
|
|
assert_eq!(
|
|
detected.0.canonicalize().expect("canon found"),
|
|
root.canonicalize().expect("canon root")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn search_boundary_clearable_back_to_none() {
|
|
let outer = tempfile::tempdir().expect("outer");
|
|
mkdir(&outer.path().join(".git"));
|
|
let boundary = outer.path().join("workspace");
|
|
let f = boundary.join("src/main.rs");
|
|
touch(&f);
|
|
|
|
let mut ws = Workspace::new();
|
|
ws.set_search_boundary(Some(boundary.clone()));
|
|
assert!(ws.detect(&f).is_none());
|
|
ws.set_search_boundary(None);
|
|
assert!(
|
|
ws.detect(&f).is_some(),
|
|
"clearing the boundary must restore the unbounded walk"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn search_boundary_resolves_under_symlinked_start() {
|
|
// The symlinked-workspace case: corporate /home mounts and
|
|
// user-organized symlink farms put the file path under a
|
|
// symlink that resolves into the boundary. The walk
|
|
// canonicalizes both the start path and the boundary, so the
|
|
// boundary applies after symlink resolution.
|
|
//
|
|
// Skip on platforms / sandboxes that disallow symlink
|
|
// creation. Symlinks under tempfile dirs are normally allowed,
|
|
// but a paranoid sandbox may reject EPERM.
|
|
let real_dir = tempfile::tempdir().expect("real");
|
|
let real = real_dir.path().to_path_buf();
|
|
touch(&real.join("Cargo.toml"));
|
|
let real_file = real.join("src/lib.rs");
|
|
touch(&real_file);
|
|
|
|
let link_dir = tempfile::tempdir().expect("link");
|
|
let link = link_dir.path().join("via-link");
|
|
if let Err(e) = std::os::unix::fs::symlink(&real, &link) {
|
|
eprintln!("test skipped: symlink {link:?} → {real:?} failed: {e}");
|
|
return;
|
|
}
|
|
|
|
let linked_file = link.join("src/lib.rs");
|
|
|
|
let mut ws = Workspace::new();
|
|
ws.set_search_boundary(Some(real.clone()));
|
|
// Walking via the symlinked path: after canonicalization both
|
|
// the start and the boundary live under `real`. The marker at
|
|
// `real/Cargo.toml` is at the boundary and is examined.
|
|
let (found, kind) = ws
|
|
.detect(&linked_file)
|
|
.expect("symlinked walk must still find the marker at the boundary");
|
|
assert_eq!(kind, ProjectKind::Rust);
|
|
assert_eq!(
|
|
found.canonicalize().expect("canon"),
|
|
real.canonicalize().expect("canon real")
|
|
);
|
|
}
|
|
}
|