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(¤t) {
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}