Skip to main content

trusty_memory/
project_root.rs

1//! Project-root detection and palace-slug derivation (issue #88).
2//!
3//! Why: unbounded palace creation leads to orphaned namespaces that no longer
4//! correspond to any project on disk. Anchoring palace names to a stable,
5//! filesystem-derived slug ensures each project gets exactly one palace and
6//! makes "which palace am I in?" predictable from the working directory alone.
7//! The `personal` palace is the single sanctioned exception for non-project
8//! contexts (global notes, one-off sessions).
9//! What: `project_slug()` walks upward from CWD looking for canonical project
10//! markers (`.git`, `Cargo.toml`, `pyproject.toml`, `package.json`) and
11//! returns the slugified basename of the first ancestor that contains one.
12//! Returns `None` when no project root is found (all ancestors have been
13//! exhausted). The slug is deterministic, lowercase, filesystem-safe, and
14//! under 64 chars.
15//! Test: `project_slug_finds_git_root`, `project_slug_returns_none_without_markers`,
16//! `project_slug_uses_first_ancestor_marker`,
17//! `project_slug_personal_always_allowed`.
18
19use anyhow::Result;
20use std::path::{Path, PathBuf};
21
22use crate::messaging::slugify_string;
23
24/// Sentinel palace name that is always valid regardless of project context.
25///
26/// Why: users operating outside any project root (global notes, exploratory
27/// sessions, personal task lists) need a stable palace that can receive
28/// memories without failing the project-enforcement gate. The name `personal`
29/// is the single reserved identifier for this purpose.
30/// What: a `&str` constant that the enforcement logic tests against before
31/// applying project-slug validation.
32/// Test: `project_slug_personal_always_allowed`.
33pub const PERSONAL_PALACE: &str = "personal";
34
35/// File names that mark a directory as a project root.
36///
37/// Why: different ecosystems use different conventions for the project root;
38/// we want a single, ordered list that every part of the codebase agrees on
39/// so project detection is consistent whether invoked from CLI, MCP, or
40/// tests. `.git` comes first because it is the most universal signal.
41/// What: an ordered slice of filenames checked by `find_project_root`. A
42/// directory is considered a project root when it contains *any* of these.
43/// Test: `project_slug_uses_first_ancestor_marker`.
44pub const PROJECT_MARKERS: &[&str] = &[
45    ".git",
46    "Cargo.toml",
47    "pyproject.toml",
48    "package.json",
49    "go.mod",
50    ".project-root",
51];
52
53/// Walk upward from `start` and return the first ancestor directory (inclusive)
54/// that contains at least one project marker.
55///
56/// Why: keeping the filesystem walk in a dedicated helper makes both the slug
57/// derivation function and the tests easier to reason about — callers get the
58/// root path, not just the slug.
59/// What: starts at `start`, checks for every [`PROJECT_MARKERS`] file/dir,
60/// and ascends to `parent()` until a root is found or the filesystem root is
61/// reached. Returns `None` when no project root is found.
62/// Test: `project_slug_finds_git_root`, `project_slug_uses_first_ancestor_marker`.
63pub fn find_project_root(start: &Path) -> Option<PathBuf> {
64    let mut current = start.to_path_buf();
65    // Canonicalize to resolve symlinks before walking (best-effort; fall back
66    // to the original path if canonicalization fails, e.g. path does not exist
67    // yet).
68    if let Ok(canonical) = std::fs::canonicalize(&current) {
69        current = canonical;
70    }
71    loop {
72        for marker in PROJECT_MARKERS {
73            if current.join(marker).exists() {
74                return Some(current);
75            }
76        }
77        // Ascend one level; stop at the filesystem root.
78        match current.parent() {
79            Some(parent) if parent != current => current = parent.to_path_buf(),
80            _ => return None,
81        }
82    }
83}
84
85/// Derive a palace slug from the project root found at or above `start`.
86///
87/// Why: the core of issue #88 — palace names must match the canonical slug
88/// of the project they belong to so a project's palace is unambiguously
89/// discoverable from any subdirectory of that project.
90/// What: calls `find_project_root`, then `slugify_string` on the basename.
91/// Returns `None` when no project root is found (the caller should then fall
92/// back to the `personal` palace or prompt the user to pass `--palace
93/// personal`).
94/// Test: `project_slug_finds_git_root`, `project_slug_returns_none_without_markers`.
95pub fn project_slug_at(start: &Path) -> Option<String> {
96    let root = find_project_root(start)?;
97    let basename = root.file_name()?.to_str()?;
98    let slug = slugify_string(basename);
99    if slug.is_empty() {
100        None
101    } else {
102        Some(slug)
103    }
104}
105
106/// Derive a palace slug for the current working directory.
107///
108/// Why: convenience wrapper over `project_slug_at` for callers that want
109/// the "natural" project slug (CLI commands, MCP handlers, tests running
110/// inside a repo).
111/// What: calls `std::env::current_dir()`, propagates the error if the syscall
112/// fails, then delegates to [`project_slug_at`].
113/// Test: `project_slug_finds_git_root` (run from inside the trusty-tools repo
114/// which is a git checkout).
115pub fn project_slug() -> Result<Option<String>> {
116    let cwd = std::env::current_dir().map_err(|e| anyhow::anyhow!("read cwd: {e}"))?;
117    Ok(project_slug_at(&cwd))
118}
119
120/// Validate a proposed palace name against project-slug enforcement rules.
121///
122/// Why: palace creation in MCP tool calls and HTTP handlers must apply the
123/// same enforcement logic. Centralising the check here keeps the rule in one
124/// place and makes it easy to write exhaustive unit tests.
125/// What: returns `Ok(())` when the name is valid; returns `Err` with a
126/// human-readable message when it is not. The rules are:
127///   1. `personal` is always valid (the escape hatch for non-project
128///      contexts).
129///   2. When a project root is detectable from `cwd`, the name must equal
130///      the derived slug.
131///   3. When no project root is detectable, only `personal` is allowed.
132///
133/// Existing palaces are **not** affected by this check; it applies only to
134/// *new* palace creation requests.
135/// Test: `validate_palace_name_accepts_personal`,
136/// `validate_palace_name_accepts_matching_slug`,
137/// `validate_palace_name_rejects_mismatch`,
138/// `validate_palace_name_rejects_non_personal_without_project`.
139pub fn validate_palace_name(name: &str, cwd: &Path) -> Result<()> {
140    // The `personal` palace is always a valid creation target.
141    if name == PERSONAL_PALACE {
142        return Ok(());
143    }
144
145    match project_slug_at(cwd) {
146        Some(expected) => {
147            if name == expected {
148                Ok(())
149            } else {
150                Err(anyhow::anyhow!(
151                    "palace name '{name}' does not match the project slug '{expected}' \
152                     (derived from {cwd}). \
153                     Either use '{expected}' or use 'personal' for non-project memories.",
154                    cwd = cwd.display(),
155                ))
156            }
157        }
158        None => Err(anyhow::anyhow!(
159            "no project root found at or above '{cwd}'. \
160             Use 'personal' for memories not tied to a project, \
161             or run from inside a project directory that contains \
162             a .git file, Cargo.toml, pyproject.toml, or package.json.",
163            cwd = cwd.display(),
164        )),
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use std::fs;
172
173    // -----------------------------------------------------------------------
174    // find_project_root
175    // -----------------------------------------------------------------------
176
177    /// Why: the primary use-case — a nested directory inside a git repo must
178    /// resolve to the repo root, not just the immediate parent.
179    /// What: create a temp dir with a `.git` subdir, nest a subdirectory
180    /// inside it, and assert that `find_project_root` from the subdirectory
181    /// returns the outer root (the one with `.git`).
182    /// Test: itself.
183    #[test]
184    fn project_slug_finds_git_root() {
185        let tmp = tempfile::tempdir().expect("tempdir");
186        let root = tmp.path().to_path_buf();
187        // Create a .git marker at the root level.
188        fs::create_dir_all(root.join(".git")).unwrap();
189        // Create a nested subdirectory.
190        let nested = root.join("crates").join("foo");
191        fs::create_dir_all(&nested).unwrap();
192
193        let found = find_project_root(&nested);
194        assert!(found.is_some(), "should find project root");
195        // Canonicalize both sides so macOS /var vs /private/var symlinks
196        // do not cause false mismatches.
197        let found_canonical = fs::canonicalize(found.unwrap()).unwrap();
198        let root_canonical = fs::canonicalize(&root).unwrap();
199        assert_eq!(found_canonical, root_canonical);
200    }
201
202    /// Why: when the CWD is not inside any project, `find_project_root` must
203    /// return `None` so the caller can fall through to the `personal` palace.
204    /// What: create a temp dir with *no* marker files and assert the result
205    /// is `None`.
206    /// Test: itself.
207    #[test]
208    fn project_slug_returns_none_without_markers() {
209        let tmp = tempfile::tempdir().expect("tempdir");
210        // Bare directory — no .git, Cargo.toml, etc.
211        let found = find_project_root(tmp.path());
212        assert!(
213            found.is_none(),
214            "bare tempdir should not resolve to a project root"
215        );
216    }
217
218    /// Why: `Cargo.toml` is also a valid project marker; not every project
219    /// uses git.
220    /// What: create a temp dir with a `Cargo.toml` file and assert it is
221    /// detected as the project root from a subdirectory.
222    /// Test: itself.
223    #[test]
224    fn project_slug_uses_first_ancestor_marker() {
225        let tmp = tempfile::tempdir().expect("tempdir");
226        let root = tmp.path().to_path_buf();
227        fs::write(root.join("Cargo.toml"), "[package]").unwrap();
228        let sub = root.join("src");
229        fs::create_dir_all(&sub).unwrap();
230
231        let found = find_project_root(&sub);
232        assert!(found.is_some());
233        // Canonicalize both sides so macOS /var vs /private/var symlinks
234        // do not cause false mismatches.
235        let found_canonical = fs::canonicalize(found.unwrap()).unwrap();
236        let root_canonical = fs::canonicalize(&root).unwrap();
237        assert_eq!(found_canonical, root_canonical);
238    }
239
240    // -----------------------------------------------------------------------
241    // project_slug_at
242    // -----------------------------------------------------------------------
243
244    /// Why: the slug must be the slugified basename of the project root, not
245    /// the subdirectory we started from.
246    /// What: create a root named `my-project` with a `.git` marker; start
247    /// from a nested subdirectory; assert the slug is `my-project`.
248    /// Test: itself.
249    #[test]
250    fn project_slug_at_returns_root_basename_slug() {
251        let tmp = tempfile::tempdir().expect("tempdir");
252        let root = tmp.path().join("my-project");
253        fs::create_dir_all(root.join(".git")).unwrap();
254        let src = root.join("src");
255        fs::create_dir_all(&src).unwrap();
256
257        let slug = project_slug_at(&src).expect("should return slug");
258        assert_eq!(slug, "my-project");
259    }
260
261    /// Why: uppercase and underscores must be normalised by the slug derivation
262    /// so that `My_Project` and `my-project` resolve to the same palace.
263    /// What: create a root named `My_Project`; assert the derived slug is
264    /// `my-project`.
265    /// Test: itself.
266    #[test]
267    fn project_slug_at_normalises_case_and_underscores() {
268        let tmp = tempfile::tempdir().expect("tempdir");
269        let root = tmp.path().join("My_Project");
270        fs::create_dir_all(root.join(".git")).unwrap();
271
272        let slug = project_slug_at(&root).expect("should return slug");
273        assert_eq!(slug, "my-project");
274    }
275
276    /// Why: when no project root is found, `project_slug_at` must return
277    /// `None` so the caller knows to use `personal`.
278    /// Test: itself.
279    #[test]
280    fn project_slug_at_returns_none_without_markers() {
281        let tmp = tempfile::tempdir().expect("tempdir");
282        assert!(project_slug_at(tmp.path()).is_none());
283    }
284
285    // -----------------------------------------------------------------------
286    // validate_palace_name
287    // -----------------------------------------------------------------------
288
289    /// Why: `personal` is the sanctioned escape hatch; it must always be
290    /// accepted regardless of whether a project root is found.
291    /// What: run `validate_palace_name("personal", …)` from a plain temp
292    /// dir (no project markers); assert `Ok(())`.
293    /// Test: itself.
294    #[test]
295    fn validate_palace_name_accepts_personal() {
296        let tmp = tempfile::tempdir().expect("tempdir");
297        let result = validate_palace_name(PERSONAL_PALACE, tmp.path());
298        assert!(
299            result.is_ok(),
300            "personal must always be accepted; got {result:?}"
301        );
302    }
303
304    /// Why: when the name exactly matches the derived slug the creation must
305    /// succeed.
306    /// What: create a project root named `cool-app`; assert that
307    /// `validate_palace_name("cool-app", subdir)` returns `Ok(())`.
308    /// Test: itself.
309    #[test]
310    fn validate_palace_name_accepts_matching_slug() {
311        let tmp = tempfile::tempdir().expect("tempdir");
312        let root = tmp.path().join("cool-app");
313        fs::create_dir_all(root.join(".git")).unwrap();
314        let sub = root.join("src");
315        fs::create_dir_all(&sub).unwrap();
316
317        let result = validate_palace_name("cool-app", &sub);
318        assert!(result.is_ok(), "matching slug must be accepted: {result:?}");
319    }
320
321    /// Why: a mismatched name must be rejected with an actionable error that
322    /// tells the user which slug is expected.
323    /// What: create a project root named `cool-app`; assert that
324    /// `validate_palace_name("wrong-name", subdir)` returns `Err` and the
325    /// error message mentions `cool-app`.
326    /// Test: itself.
327    #[test]
328    fn validate_palace_name_rejects_mismatch() {
329        let tmp = tempfile::tempdir().expect("tempdir");
330        let root = tmp.path().join("cool-app");
331        fs::create_dir_all(root.join(".git")).unwrap();
332        let sub = root.join("src");
333        fs::create_dir_all(&sub).unwrap();
334
335        let result = validate_palace_name("wrong-name", &sub);
336        assert!(result.is_err(), "mismatched name must be rejected");
337        let msg = result.unwrap_err().to_string();
338        assert!(
339            msg.contains("cool-app"),
340            "error must mention the expected slug; got: {msg}"
341        );
342    }
343
344    /// Why: outside a project directory, only `personal` is allowed; any
345    /// other name must be rejected.
346    /// What: use a plain tempdir (no markers); assert that any non-`personal`
347    /// name returns `Err`.
348    /// Test: itself.
349    #[test]
350    fn validate_palace_name_rejects_non_personal_without_project() {
351        let tmp = tempfile::tempdir().expect("tempdir");
352        let result = validate_palace_name("my-notes", tmp.path());
353        assert!(
354            result.is_err(),
355            "non-personal name outside a project must be rejected"
356        );
357        let msg = result.unwrap_err().to_string();
358        assert!(
359            msg.contains("personal"),
360            "error must mention 'personal'; got: {msg}"
361        );
362    }
363}