trusty_memory/project_root.rs
1//! Project-root detection, palace-slug derivation, and `.trusty-tools/` pin
2//! file management (issue #88 + Phase 1 of the `.trusty-tools/` convention).
3//!
4//! Why: unbounded palace creation leads to orphaned namespaces that no longer
5//! correspond to any project on disk. Anchoring palace names to a stable,
6//! filesystem-derived slug ensures each project gets exactly one palace and
7//! makes "which palace am I in?" predictable from the working directory alone.
8//! The `personal` palace is the single sanctioned exception for non-project
9//! contexts (global notes, one-off sessions).
10//!
11//! Phase 1 adds a pin-file convention: a project may commit
12//! `.trusty-tools/trusty-memory.yaml` at its root to pin the palace slug.
13//! This survives directory renames and drive reorganisations because the slug
14//! no longer depends solely on the directory basename.
15//!
16//! Resolution order for `project_slug_at`:
17//! a. Walk up to the project root. If `.trusty-tools/trusty-memory.yaml`
18//! exists, read `palace` from it (authoritative — survives renames).
19//! b. If absent, compute the slug from the directory basename (existing
20//! logic), then lazily write `.trusty-tools/trusty-memory.yaml` so all
21//! future resolutions are stable. The lazy write is best-effort and
22//! non-fatal (read-only trees are tolerated; failures are logged to
23//! stderr).
24//!
25//! What: `project_slug_at` implements the resolution order above. Helpers
26//! `read_project_pin`, `write_project_pin`, and `project_slug_from_basename`
27//! are split out so each can be tested independently and called by the
28//! `trusty-memory link` backfill command.
29//! Test: `project_slug_finds_git_root`, `project_slug_returns_none_without_markers`,
30//! `project_slug_uses_first_ancestor_marker`,
31//! `project_slug_personal_always_allowed`,
32//! `pin_file_read_when_present`, `absent_pin_writes_computed_slug`,
33//! `renamed_dir_with_pin_resolves_to_original_slug`,
34//! `trusty_tools_dir_is_project_marker`,
35//! `lazy_write_non_fatal_on_readonly_dir`.
36
37use anyhow::Result;
38use serde::{Deserialize, Serialize};
39use std::path::{Path, PathBuf};
40
41use crate::messaging::slugify_string;
42
43/// Schema version for `.trusty-tools/trusty-memory.yaml`.
44///
45/// Why: forward-proofing — a future phase may need to distinguish older pin
46/// files that lack new fields. Hard-coding `1` now makes that migration
47/// straightforward: read `schema_version`, branch on the value.
48/// What: the `u32` constant `1`.
49/// Test: `write_project_pin` embeds this value; `read_project_pin` accepts it.
50pub const PIN_SCHEMA_VERSION: u32 = 1;
51
52/// Relative path of the pin file within a project root.
53///
54/// Why: defined as a constant so every call site (`read_project_pin`,
55/// `write_project_pin`, `find_project_root`) agrees on the same path and
56/// tests can compare against this value instead of a bare string literal.
57/// What: `".trusty-tools/trusty-memory.yaml"`.
58/// Test: used in every pin-file test in this module.
59pub const PIN_FILE_REL: &str = ".trusty-tools/trusty-memory.yaml";
60
61/// The `.trusty-tools/` directory name (used as a project marker).
62///
63/// Why: a project that already contains `.trusty-tools/trusty-memory.yaml`
64/// should be recognised as a project root even if it has no `.git` or
65/// `Cargo.toml`. Adding the directory itself to `PROJECT_MARKERS` (decision
66/// D5) lets `find_project_root` detect this case without special-casing.
67/// What: `".trusty-tools"`.
68/// Test: `trusty_tools_dir_is_project_marker`.
69pub const TRUSTY_TOOLS_DIR: &str = ".trusty-tools";
70
71/// Serialisable schema for `.trusty-tools/trusty-memory.yaml`.
72///
73/// Why: a typed struct with `serde` makes the YAML schema self-documenting
74/// and prevents future fields from silently deserialising to wrong types.
75/// What: holds `schema_version` (always 1 for Phase 1) and `palace` (the
76/// pinned slug string). An optional `note` field is supported for humans who
77/// want to document why the slug was pinned.
78/// Test: `write_project_pin` round-trips through `read_project_pin`.
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
80pub struct ProjectPin {
81 /// Pin-file format version. Always `1` in Phase 1.
82 pub schema_version: u32,
83 /// The pinned palace slug — stored verbatim, no re-slugification.
84 pub palace: String,
85 /// Optional human note (e.g. "pinned before drive reorg 2026-06").
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub note: Option<String>,
88}
89
90/// Sentinel palace name that is always valid regardless of project context.
91///
92/// Why: users operating outside any project root (global notes, exploratory
93/// sessions, personal task lists) need a stable palace that can receive
94/// memories without failing the project-enforcement gate. The name `personal`
95/// is the single reserved identifier for this purpose.
96/// What: a `&str` constant that the enforcement logic tests against before
97/// applying project-slug validation.
98/// Test: `project_slug_personal_always_allowed`.
99pub const PERSONAL_PALACE: &str = "personal";
100
101/// File names that mark a directory as a project root.
102///
103/// Why: different ecosystems use different conventions for the project root;
104/// we want a single, ordered list that every part of the codebase agrees on
105/// so project detection is consistent whether invoked from CLI, MCP, or
106/// tests. `.git` comes first because it is the most universal signal.
107/// `.trusty-tools` is included (decision D5) so a directory that already
108/// carries a pin file is recognised even without a `.git` or build manifest.
109/// What: an ordered slice of filenames checked by `find_project_root`. A
110/// directory is considered a project root when it contains *any* of these.
111/// Test: `project_slug_uses_first_ancestor_marker`,
112/// `trusty_tools_dir_is_project_marker`.
113pub const PROJECT_MARKERS: &[&str] = &[
114 ".git",
115 "Cargo.toml",
116 "pyproject.toml",
117 "package.json",
118 "go.mod",
119 ".project-root",
120 TRUSTY_TOOLS_DIR,
121];
122
123/// Walk upward from `start` and return the first ancestor directory (inclusive)
124/// that contains at least one project marker.
125///
126/// Why: keeping the filesystem walk in a dedicated helper makes both the slug
127/// derivation function and the tests easier to reason about — callers get the
128/// root path, not just the slug.
129/// What: starts at `start`, checks for every [`PROJECT_MARKERS`] file/dir,
130/// and ascends to `parent()` until a root is found or the filesystem root is
131/// reached. Returns `None` when no project root is found.
132/// Test: `project_slug_finds_git_root`, `project_slug_uses_first_ancestor_marker`.
133pub fn find_project_root(start: &Path) -> Option<PathBuf> {
134 find_project_root_with_marker(start).map(|(root, _)| root)
135}
136
137/// Walk upward from `start` and return the first ancestor directory (inclusive)
138/// that contains at least one project marker, together with the name of the
139/// marker that was found.
140///
141/// Why: the lazy-write path in `project_slug_at` needs to know whether the
142/// detected root was anchored by a real marker (`.git`, `Cargo.toml`, etc.)
143/// or whether no marker was found at all (in which case `find_project_root`
144/// would already have returned `None` — this function's second return value
145/// is always `Some` when the `PathBuf` is `Some`). The guard in
146/// `project_slug_at` uses the marker name to distinguish a real project root
147/// from a directory that only became the root by coincidence (e.g. a stale
148/// pin file in `/tmp`).
149/// What: same walk as `find_project_root`; returns `Some((root, marker))` on
150/// success where `marker` is one of the strings from [`PROJECT_MARKERS`].
151/// Returns `None` when no project root is found.
152/// Test: indirectly via `find_project_root`, `project_slug_at`, and the
153/// guard tests `lazy_write_skipped_for_temp_dir_root`.
154fn find_project_root_with_marker(start: &Path) -> Option<(PathBuf, &'static str)> {
155 let mut current = start.to_path_buf();
156 // Canonicalize to resolve symlinks before walking (best-effort; fall back
157 // to the original path if canonicalization fails, e.g. path does not exist
158 // yet).
159 if let Ok(canonical) = std::fs::canonicalize(¤t) {
160 current = canonical;
161 }
162 loop {
163 for marker in PROJECT_MARKERS {
164 if current.join(marker).exists() {
165 return Some((current, marker));
166 }
167 }
168 // Ascend one level; stop at the filesystem root.
169 match current.parent() {
170 Some(parent) if parent != current => current = parent.to_path_buf(),
171 _ => return None,
172 }
173 }
174}
175
176/// Return `true` when `root` is an unsafe location where we must not
177/// lazily write a palace pin file.
178///
179/// Why (product guard): when `find_project_root` walks up from a temp or
180/// scratch directory and finds no real project marker, it can fall through
181/// to a fallback root such as the system temp dir, the user's home
182/// directory, or the filesystem root. Writing a pin file there silently
183/// poisons every future invocation from any subdirectory of that path —
184/// including every `tempfile::tempdir()` in the test suite (which resolves
185/// to a child of `/tmp`). The guard intercepts this before the write so
186/// only genuine project roots ever receive a pin file.
187/// What: canonicalises `root` and compares it against `std::env::temp_dir()`
188/// (canonicalised), `dirs::home_dir()` (canonicalised, best-effort), and the
189/// filesystem root `/`. Returns `true` when any comparison matches.
190/// Test: `lazy_write_skipped_for_temp_dir_root`.
191fn is_unsafe_pin_location(root: &Path) -> bool {
192 let canonical = match std::fs::canonicalize(root) {
193 Ok(c) => c,
194 // If we can't canonicalise, treat as unsafe to be conservative.
195 Err(_) => return true,
196 };
197
198 // System temp dir (handles /tmp → /private/tmp on macOS).
199 let temp = std::fs::canonicalize(std::env::temp_dir()).unwrap_or_else(|_| std::env::temp_dir());
200 if canonical == temp {
201 return true;
202 }
203
204 // User home directory.
205 if let Some(home) = dirs::home_dir() {
206 let home_canon = std::fs::canonicalize(&home).unwrap_or(home);
207 if canonical == home_canon {
208 return true;
209 }
210 }
211
212 // Filesystem root.
213 if canonical == std::path::Path::new("/") {
214 return true;
215 }
216
217 false
218}
219
220/// Read the palace pin from `.trusty-tools/trusty-memory.yaml` at `root`.
221///
222/// Why: the pin file is the authoritative source for a project's palace slug
223/// when present. Reading it in a dedicated helper keeps the I/O concern
224/// separate from the slug-derivation logic and makes it easy to test the
225/// round-trip in isolation.
226/// What: constructs the path `root/.trusty-tools/trusty-memory.yaml`, reads
227/// it, and deserialises with `serde_yaml`. Returns `None` when the file does
228/// not exist. Returns `Err` only on I/O or parse failures.
229/// Test: `pin_file_read_when_present`, `read_project_pin_returns_none_when_absent`.
230pub fn read_project_pin(root: &Path) -> Result<Option<ProjectPin>> {
231 let pin_path = root.join(PIN_FILE_REL);
232 match std::fs::read_to_string(&pin_path) {
233 Ok(s) => {
234 let pin: ProjectPin = serde_yaml::from_str(&s)
235 .map_err(|e| anyhow::anyhow!("parse {}: {e}", pin_path.display()))?;
236 Ok(Some(pin))
237 }
238 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
239 Err(e) => Err(anyhow::anyhow!("read {}: {e}", pin_path.display())),
240 }
241}
242
243/// Write a palace pin to `.trusty-tools/trusty-memory.yaml` at `root`.
244///
245/// Why: the lazy-write path in `project_slug_at` and the explicit
246/// `trusty-memory link` backfill command both need to emit the same YAML
247/// schema. A single writer keeps the format consistent and avoids duplicated
248/// YAML-construction logic.
249/// What: creates `.trusty-tools/` if missing, serialises `pin` with
250/// `serde_yaml`, and writes it atomically (write to `<file>.tmp`, then
251/// rename). Returns the path that was written.
252/// Test: `write_project_pin_creates_expected_yaml`,
253/// `write_project_pin_round_trips_through_read`.
254pub fn write_project_pin(root: &Path, pin: &ProjectPin) -> Result<PathBuf> {
255 let dir = root.join(TRUSTY_TOOLS_DIR);
256 std::fs::create_dir_all(&dir).map_err(|e| anyhow::anyhow!("create {}: {e}", dir.display()))?;
257 let pin_path = root.join(PIN_FILE_REL);
258 let tmp_path = pin_path.with_extension("yaml.tmp");
259 let yaml = serde_yaml::to_string(pin).map_err(|e| anyhow::anyhow!("serialise pin: {e}"))?;
260 let header = "# .trusty-tools/trusty-memory.yaml\n\
261 # This file pins the trusty-memory palace slug for this project.\n\
262 # Commit it so the linkage survives directory renames and drive reorgs.\n\
263 # Schema: https://github.com/bobmatnyc/trusty-tools (trusty-tools convention)\n\n";
264 let content = format!("{header}{yaml}");
265 std::fs::write(&tmp_path, &content)
266 .map_err(|e| anyhow::anyhow!("write {}: {e}", tmp_path.display()))?;
267 std::fs::rename(&tmp_path, &pin_path).map_err(|e| {
268 anyhow::anyhow!(
269 "rename {} → {}: {e}",
270 tmp_path.display(),
271 pin_path.display()
272 )
273 })?;
274 Ok(pin_path)
275}
276
277/// Compute the palace slug purely from the directory basename (the pre-Phase-1
278/// logic, now extracted for composability).
279///
280/// Why: the resolution order in `project_slug_at` needs to call the basename
281/// derivation without triggering the pin-file read/write side effects. Exposing
282/// this as a separate function makes both paths testable in isolation.
283/// What: calls `slugify_string` on the last path component of `root`. Returns
284/// `None` when the basename is empty or slugifies to an empty string.
285/// Test: `project_slug_from_basename_basic`.
286pub fn project_slug_from_basename(root: &Path) -> Option<String> {
287 let basename = root.file_name()?.to_str()?;
288 let slug = slugify_string(basename);
289 if slug.is_empty() {
290 None
291 } else {
292 Some(slug)
293 }
294}
295
296/// Derive a palace slug from the project root found at or above `start`.
297///
298/// Why: the core of issue #88 with Phase-1 pin-file support. Palace names
299/// must match the canonical slug of the project they belong to, and that slug
300/// must survive directory renames. The pin file provides the stable anchor.
301/// What: implements the two-step resolution order:
302/// a. Walk up to the project root. If `.trusty-tools/trusty-memory.yaml`
303/// exists, return `pin.palace` (authoritative — survives renames).
304/// b. If absent, compute the slug via `project_slug_from_basename`, then
305/// lazily write the pin file (best-effort, non-fatal) so future calls
306/// always land on path (a).
307/// Returns `None` when no project root is found.
308/// Test: `pin_file_read_when_present`, `absent_pin_writes_computed_slug`,
309/// `renamed_dir_with_pin_resolves_to_original_slug`.
310pub fn project_slug_at(start: &Path) -> Option<String> {
311 let root = find_project_root(start)?;
312
313 // Step (a): check for a committed pin file.
314 match read_project_pin(&root) {
315 Ok(Some(pin)) => return Some(pin.palace),
316 Ok(None) => {} // absent — fall through to step (b)
317 Err(e) => {
318 // Corrupt or unreadable pin file: log to stderr and fall through
319 // to the basename derivation so memory operations are not blocked.
320 tracing::warn!(
321 path = %root.join(PIN_FILE_REL).display(),
322 "could not read palace pin file ({e:#}); falling back to basename slug"
323 );
324 }
325 }
326
327 // Step (b): compute from basename and lazily write the pin file.
328 // Guard: never write into a system temp dir, home dir, or filesystem root —
329 // those are unsafe pin locations that would poison every subdirectory.
330 // `is_unsafe_pin_location` was introduced in PR #492 for exactly this
331 // case; if the resolved root is unsafe we still return the derived slug
332 // (so memory operations work) but skip the write.
333 let slug = project_slug_from_basename(&root)?;
334 if is_unsafe_pin_location(&root) {
335 tracing::debug!(
336 slug = %slug,
337 root = %root.display(),
338 "skipping lazy pin write: root is a system/home/temp dir"
339 );
340 return Some(slug);
341 }
342 let pin = ProjectPin {
343 schema_version: PIN_SCHEMA_VERSION,
344 palace: slug.clone(),
345 note: None,
346 };
347 match write_project_pin(&root, &pin) {
348 Ok(path) => {
349 tracing::debug!(
350 slug = %slug,
351 path = %path.display(),
352 "wrote palace pin file (lazy init)"
353 );
354 }
355 Err(e) => {
356 // Read-only tree, insufficient permissions, etc. — non-fatal.
357 tracing::warn!(
358 slug = %slug,
359 root = %root.display(),
360 "could not write palace pin file ({e:#}); slug will remain basename-derived"
361 );
362 }
363 }
364 Some(slug)
365}
366
367/// Derive a palace slug from the project root found at or above `start`,
368/// WITHOUT the lazy-write side-effect.
369///
370/// Why: the `prompt-context` hook runs in read-only or short-lived contexts
371/// where creating `.trusty-tools/trusty-memory.yaml` would be surprising and
372/// potentially disruptive. The slug is still resolved via the pin-file when
373/// one already exists (step a), and falls back to the basename slug (step b)
374/// without ever writing a new file. This makes `cwd_palace_slug_at` safe to
375/// call unconditionally from hooks. The writing variant (`project_slug_at`)
376/// remains the right choice for interactive commands (`trusty-memory link`,
377/// `trusty-memory remember`) that want to stabilise the slug.
378/// What: same two-step resolution as `project_slug_at` but step (b) only
379/// computes and returns the basename slug — it does NOT write the pin file.
380/// Returns `None` when no project root is found.
381/// Test: `project_slug_at_readonly_no_write_when_absent`,
382/// `project_slug_at_readonly_reads_existing_pin`,
383/// `project_slug_at_readonly_falls_back_to_basename`.
384pub fn project_slug_at_readonly(start: &Path) -> Option<String> {
385 let root = find_project_root(start)?;
386
387 // Step (a): if a pin file exists, use it authoritatively.
388 match read_project_pin(&root) {
389 Ok(Some(pin)) => return Some(pin.palace),
390 Ok(None) => {} // absent — fall through to step (b)
391 Err(e) => {
392 // Corrupt or unreadable pin file: log to stderr and fall through
393 // so the hook is not blocked.
394 tracing::warn!(
395 path = %root.join(PIN_FILE_REL).display(),
396 "could not read palace pin file ({e:#}); falling back to basename slug (read-only)"
397 );
398 }
399 }
400
401 // Step (b): compute from basename — but do NOT write a pin file.
402 project_slug_from_basename(&root)
403}
404
405/// Derive a palace slug for the current working directory.
406///
407/// Why: convenience wrapper over `project_slug_at` for callers that want
408/// the "natural" project slug (CLI commands, MCP handlers, tests running
409/// inside a repo).
410/// What: calls `std::env::current_dir()`, propagates the error if the syscall
411/// fails, then delegates to [`project_slug_at`].
412/// Test: `project_slug_finds_git_root` (run from inside the trusty-tools repo
413/// which is a git checkout).
414pub fn project_slug() -> Result<Option<String>> {
415 let cwd = std::env::current_dir().map_err(|e| anyhow::anyhow!("read cwd: {e}"))?;
416 Ok(project_slug_at(&cwd))
417}
418
419/// Validate a proposed palace name against project-slug enforcement rules.
420///
421/// Why: palace creation in MCP tool calls and HTTP handlers must apply the
422/// same enforcement logic. Centralising the check here keeps the rule in one
423/// place and makes it easy to write exhaustive unit tests.
424/// What: returns `Ok(())` when the name is valid; returns `Err` with a
425/// human-readable message when it is not. The rules are:
426/// 1. `personal` is always valid (the escape hatch for non-project
427/// contexts).
428/// 2. When a project root is detectable from `cwd`, the name must equal
429/// the derived slug.
430/// 3. When no project root is detectable, only `personal` is allowed.
431///
432/// Existing palaces are **not** affected by this check; it applies only to
433/// *new* palace creation requests.
434/// Test: `validate_palace_name_accepts_personal`,
435/// `validate_palace_name_accepts_matching_slug`,
436/// `validate_palace_name_rejects_mismatch`,
437/// `validate_palace_name_rejects_non_personal_without_project`.
438pub fn validate_palace_name(name: &str, cwd: &Path) -> Result<()> {
439 // The `personal` palace is always a valid creation target.
440 if name == PERSONAL_PALACE {
441 return Ok(());
442 }
443
444 match project_slug_at(cwd) {
445 Some(expected) => {
446 if name == expected {
447 Ok(())
448 } else {
449 Err(anyhow::anyhow!(
450 "palace name '{name}' does not match the project slug '{expected}' \
451 (derived from {cwd}). \
452 Either use '{expected}' or use 'personal' for non-project memories.",
453 cwd = cwd.display(),
454 ))
455 }
456 }
457 None => Err(anyhow::anyhow!(
458 "no project root found at or above '{cwd}'. \
459 Use 'personal' for memories not tied to a project, \
460 or run from inside a project directory that contains \
461 a .git file, Cargo.toml, pyproject.toml, or package.json.",
462 cwd = cwd.display(),
463 )),
464 }
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470 use std::fs;
471
472 // -----------------------------------------------------------------------
473 // find_project_root
474 // -----------------------------------------------------------------------
475
476 /// Why: the primary use-case — a nested directory inside a git repo must
477 /// resolve to the repo root, not just the immediate parent.
478 /// What: create a temp dir with a `.git` subdir, nest a subdirectory
479 /// inside it, and assert that `find_project_root` from the subdirectory
480 /// returns the outer root (the one with `.git`).
481 /// Test: itself.
482 #[test]
483 fn project_slug_finds_git_root() {
484 let tmp = tempfile::tempdir().expect("tempdir");
485 let root = tmp.path().to_path_buf();
486 // Create a .git marker at the root level.
487 fs::create_dir_all(root.join(".git")).unwrap();
488 // Create a nested subdirectory.
489 let nested = root.join("crates").join("foo");
490 fs::create_dir_all(&nested).unwrap();
491
492 let found = find_project_root(&nested);
493 assert!(found.is_some(), "should find project root");
494 // Canonicalize both sides so macOS /var vs /private/var symlinks
495 // do not cause false mismatches.
496 let found_canonical = fs::canonicalize(found.unwrap()).unwrap();
497 let root_canonical = fs::canonicalize(&root).unwrap();
498 assert_eq!(found_canonical, root_canonical);
499 }
500
501 /// Why: when the CWD is not inside any project, `find_project_root` must
502 /// return `None` so the caller can fall through to the `personal` palace.
503 /// What: create a temp dir with *no* marker files and assert the result
504 /// is `None`.
505 /// Test: itself.
506 #[test]
507 fn project_slug_returns_none_without_markers() {
508 let tmp = tempfile::tempdir().expect("tempdir");
509 // Bare directory — no .git, Cargo.toml, etc.
510 let found = find_project_root(tmp.path());
511 assert!(
512 found.is_none(),
513 "bare tempdir should not resolve to a project root"
514 );
515 }
516
517 /// Why: `Cargo.toml` is also a valid project marker; not every project
518 /// uses git.
519 /// What: create a temp dir with a `Cargo.toml` file and assert it is
520 /// detected as the project root from a subdirectory.
521 /// Test: itself.
522 #[test]
523 fn project_slug_uses_first_ancestor_marker() {
524 let tmp = tempfile::tempdir().expect("tempdir");
525 let root = tmp.path().to_path_buf();
526 fs::write(root.join("Cargo.toml"), "[package]").unwrap();
527 let sub = root.join("src");
528 fs::create_dir_all(&sub).unwrap();
529
530 let found = find_project_root(&sub);
531 assert!(found.is_some());
532 // Canonicalize both sides so macOS /var vs /private/var symlinks
533 // do not cause false mismatches.
534 let found_canonical = fs::canonicalize(found.unwrap()).unwrap();
535 let root_canonical = fs::canonicalize(&root).unwrap();
536 assert_eq!(found_canonical, root_canonical);
537 }
538
539 // -----------------------------------------------------------------------
540 // project_slug_at
541 // -----------------------------------------------------------------------
542
543 /// Why: the slug must be the slugified basename of the project root, not
544 /// the subdirectory we started from.
545 /// What: create a root named `my-project` with a `.git` marker; start
546 /// from a nested subdirectory; assert the slug is `my-project`.
547 /// Test: itself.
548 #[test]
549 fn project_slug_at_returns_root_basename_slug() {
550 let tmp = tempfile::tempdir().expect("tempdir");
551 let root = tmp.path().join("my-project");
552 fs::create_dir_all(root.join(".git")).unwrap();
553 let src = root.join("src");
554 fs::create_dir_all(&src).unwrap();
555
556 let slug = project_slug_at(&src).expect("should return slug");
557 assert_eq!(slug, "my-project");
558 }
559
560 /// Why: uppercase and underscores must be normalised by the slug derivation
561 /// so that `My_Project` and `my-project` resolve to the same palace.
562 /// What: create a root named `My_Project`; assert the derived slug is
563 /// `my-project`.
564 /// Test: itself.
565 #[test]
566 fn project_slug_at_normalises_case_and_underscores() {
567 let tmp = tempfile::tempdir().expect("tempdir");
568 let root = tmp.path().join("My_Project");
569 fs::create_dir_all(root.join(".git")).unwrap();
570
571 let slug = project_slug_at(&root).expect("should return slug");
572 assert_eq!(slug, "my-project");
573 }
574
575 /// Why: when no project root is found, `project_slug_at` must return
576 /// `None` so the caller knows to use `personal`.
577 /// Test: itself.
578 #[test]
579 fn project_slug_at_returns_none_without_markers() {
580 let tmp = tempfile::tempdir().expect("tempdir");
581 assert!(project_slug_at(tmp.path()).is_none());
582 }
583
584 // -----------------------------------------------------------------------
585 // validate_palace_name
586 // -----------------------------------------------------------------------
587
588 /// Why: `personal` is the sanctioned escape hatch; it must always be
589 /// accepted regardless of whether a project root is found.
590 /// What: run `validate_palace_name("personal", …)` from a plain temp
591 /// dir (no project markers); assert `Ok(())`.
592 /// Test: itself.
593 #[test]
594 fn validate_palace_name_accepts_personal() {
595 let tmp = tempfile::tempdir().expect("tempdir");
596 let result = validate_palace_name(PERSONAL_PALACE, tmp.path());
597 assert!(
598 result.is_ok(),
599 "personal must always be accepted; got {result:?}"
600 );
601 }
602
603 /// Why: when the name exactly matches the derived slug the creation must
604 /// succeed.
605 /// What: create a project root named `cool-app`; assert that
606 /// `validate_palace_name("cool-app", subdir)` returns `Ok(())`.
607 /// Test: itself.
608 #[test]
609 fn validate_palace_name_accepts_matching_slug() {
610 let tmp = tempfile::tempdir().expect("tempdir");
611 let root = tmp.path().join("cool-app");
612 fs::create_dir_all(root.join(".git")).unwrap();
613 let sub = root.join("src");
614 fs::create_dir_all(&sub).unwrap();
615
616 let result = validate_palace_name("cool-app", &sub);
617 assert!(result.is_ok(), "matching slug must be accepted: {result:?}");
618 }
619
620 /// Why: a mismatched name must be rejected with an actionable error that
621 /// tells the user which slug is expected.
622 /// What: create a project root named `cool-app`; assert that
623 /// `validate_palace_name("wrong-name", subdir)` returns `Err` and the
624 /// error message mentions `cool-app`.
625 /// Test: itself.
626 #[test]
627 fn validate_palace_name_rejects_mismatch() {
628 let tmp = tempfile::tempdir().expect("tempdir");
629 let root = tmp.path().join("cool-app");
630 fs::create_dir_all(root.join(".git")).unwrap();
631 let sub = root.join("src");
632 fs::create_dir_all(&sub).unwrap();
633
634 let result = validate_palace_name("wrong-name", &sub);
635 assert!(result.is_err(), "mismatched name must be rejected");
636 let msg = result.unwrap_err().to_string();
637 assert!(
638 msg.contains("cool-app"),
639 "error must mention the expected slug; got: {msg}"
640 );
641 }
642
643 /// Why: outside a project directory, only `personal` is allowed; any
644 /// other name must be rejected.
645 /// What: use a plain tempdir (no markers); assert that any non-`personal`
646 /// name returns `Err`.
647 /// Test: itself.
648 #[test]
649 fn validate_palace_name_rejects_non_personal_without_project() {
650 let tmp = tempfile::tempdir().expect("tempdir");
651 let result = validate_palace_name("my-notes", tmp.path());
652 assert!(
653 result.is_err(),
654 "non-personal name outside a project must be rejected"
655 );
656 let msg = result.unwrap_err().to_string();
657 assert!(
658 msg.contains("personal"),
659 "error must mention 'personal'; got: {msg}"
660 );
661 }
662
663 // -----------------------------------------------------------------------
664 // Pin-file helpers: read_project_pin / write_project_pin
665 // -----------------------------------------------------------------------
666
667 /// Why: the round-trip must be lossless — what we write we must be able
668 /// to read back with the same slug value.
669 /// What: writes a pin, reads it back, asserts all fields match.
670 /// Test: itself.
671 #[test]
672 fn write_and_read_pin_round_trips() {
673 let tmp = tempfile::tempdir().expect("tempdir");
674 let pin = ProjectPin {
675 schema_version: PIN_SCHEMA_VERSION,
676 palace: "my-project".to_string(),
677 note: None,
678 };
679 write_project_pin(tmp.path(), &pin).expect("write ok");
680 let read_back = read_project_pin(tmp.path())
681 .expect("read ok")
682 .expect("Some(pin)");
683 assert_eq!(read_back, pin);
684 }
685
686 /// Why: the `note` field is optional; serialising without it must not emit
687 /// a `note: null` line in the YAML (which would confuse minimal parsers).
688 /// What: write a pin without `note`, read the raw YAML, assert it does not
689 /// contain the word `null`.
690 /// Test: itself.
691 #[test]
692 fn write_pin_omits_null_note() {
693 let tmp = tempfile::tempdir().expect("tempdir");
694 let pin = ProjectPin {
695 schema_version: PIN_SCHEMA_VERSION,
696 palace: "alpha".to_string(),
697 note: None,
698 };
699 let path = write_project_pin(tmp.path(), &pin).expect("write ok");
700 let raw = std::fs::read_to_string(&path).expect("read raw ok");
701 assert!(
702 !raw.contains("null"),
703 "null note must be omitted; got:\n{raw}"
704 );
705 assert!(raw.contains("palace: alpha"), "slug must be present");
706 assert!(
707 raw.contains("schema_version: 1"),
708 "schema_version must be present"
709 );
710 }
711
712 /// Why: `read_project_pin` must return `None` (not an error) when no pin
713 /// file has been written yet, so callers can fall through to basename
714 /// derivation without unwrapping an error.
715 /// Test: itself.
716 #[test]
717 fn read_project_pin_returns_none_when_absent() {
718 let tmp = tempfile::tempdir().expect("tempdir");
719 let result = read_project_pin(tmp.path()).expect("no error");
720 assert!(result.is_none(), "absent pin must yield None");
721 }
722
723 // -----------------------------------------------------------------------
724 // Phase-1 resolution order in project_slug_at
725 // -----------------------------------------------------------------------
726
727 /// Why: when a pin file is present it must override the directory basename,
728 /// which is the core goal of Phase 1.
729 /// What: create a root named `actual-dir`, write a pin file with
730 /// `palace: pinned-slug`, then assert `project_slug_at` from a sub-
731 /// directory returns `"pinned-slug"` (not `"actual-dir"`).
732 /// Test: itself.
733 #[test]
734 fn pin_file_read_when_present() {
735 let tmp = tempfile::tempdir().expect("tempdir");
736 let root = tmp.path().join("actual-dir");
737 fs::create_dir_all(root.join(".git")).unwrap();
738 let pin = ProjectPin {
739 schema_version: PIN_SCHEMA_VERSION,
740 palace: "pinned-slug".to_string(),
741 note: None,
742 };
743 write_project_pin(&root, &pin).expect("write pin");
744
745 let sub = root.join("src");
746 fs::create_dir_all(&sub).unwrap();
747 let slug = project_slug_at(&sub).expect("slug");
748 assert_eq!(
749 slug, "pinned-slug",
750 "pin file must override the directory basename"
751 );
752 }
753
754 /// Why: when no pin file exists, `project_slug_at` must lazily create one
755 /// so subsequent calls (or after a rename) use the file instead of the
756 /// basename.
757 /// What: create a project root with a `.git` marker but no pin file; call
758 /// `project_slug_at`; assert the pin file was created with the expected slug.
759 /// Test: itself.
760 #[test]
761 fn absent_pin_writes_computed_slug() {
762 let tmp = tempfile::tempdir().expect("tempdir");
763 let root = tmp.path().join("my-cool-project");
764 fs::create_dir_all(root.join(".git")).unwrap();
765
766 // No pin file yet.
767 assert!(
768 read_project_pin(&root).expect("no err").is_none(),
769 "no pin before first call"
770 );
771
772 let slug = project_slug_at(&root).expect("slug");
773 assert_eq!(slug, "my-cool-project");
774
775 // Pin file must now exist.
776 let pin = read_project_pin(&root)
777 .expect("no err")
778 .expect("pin written");
779 assert_eq!(pin.palace, "my-cool-project");
780 assert_eq!(pin.schema_version, PIN_SCHEMA_VERSION);
781 }
782
783 /// Why: the central use-case for Phase 1 — a project with a pin file
784 /// returns the original slug even after the directory is renamed.
785 /// What: create `old-name/` with `.git` + a pin file set to
786 /// `"original-slug"`; rename the directory to `new-name/`; assert that
787 /// `project_slug_at` from inside `new-name/` returns `"original-slug"`.
788 /// Test: itself.
789 #[test]
790 fn renamed_dir_with_pin_resolves_to_original_slug() {
791 let tmp = tempfile::tempdir().expect("tempdir");
792 let old_root = tmp.path().join("old-name");
793 fs::create_dir_all(old_root.join(".git")).unwrap();
794 let pin = ProjectPin {
795 schema_version: PIN_SCHEMA_VERSION,
796 palace: "original-slug".to_string(),
797 note: None,
798 };
799 write_project_pin(&old_root, &pin).expect("write pin");
800
801 // Simulate a directory rename.
802 let new_root = tmp.path().join("new-name");
803 fs::rename(&old_root, &new_root).expect("rename");
804
805 let sub = new_root.join("src");
806 fs::create_dir_all(&sub).unwrap();
807 let slug = project_slug_at(&sub).expect("slug after rename");
808 assert_eq!(
809 slug, "original-slug",
810 "pin file must survive the directory rename"
811 );
812 }
813
814 /// Why: decision D5 — a directory containing only `.trusty-tools/` must be
815 /// recognised as a project root so the pin file can be found without any
816 /// other ecosystem marker (`.git`, `Cargo.toml`, etc.).
817 /// What: create a bare tempdir, add only `.trusty-tools/`, assert that
818 /// `find_project_root` identifies it as the root.
819 /// Test: itself.
820 #[test]
821 fn trusty_tools_dir_is_project_marker() {
822 let tmp = tempfile::tempdir().expect("tempdir");
823 fs::create_dir_all(tmp.path().join(TRUSTY_TOOLS_DIR)).unwrap();
824 let found = find_project_root(tmp.path());
825 assert!(
826 found.is_some(),
827 ".trusty-tools must trigger project-root detection"
828 );
829 }
830
831 // -----------------------------------------------------------------------
832 // project_slug_at_readonly
833 // -----------------------------------------------------------------------
834
835 /// Why: the hook read path must return the pinned slug without creating a
836 /// new pin file when one already exists — same authoritative result as the
837 /// writing variant but with no side-effects.
838 /// What: create a project root with a pin file, call `project_slug_at_readonly`
839 /// from a subdirectory, assert the pinned slug is returned and no new file
840 /// is written.
841 /// Test: itself.
842 #[test]
843 fn project_slug_at_readonly_reads_existing_pin() {
844 let tmp = tempfile::tempdir().expect("tempdir");
845 let root = tmp.path().join("some-dir");
846 fs::create_dir_all(root.join(".git")).unwrap();
847 let pin = ProjectPin {
848 schema_version: PIN_SCHEMA_VERSION,
849 palace: "canonical-slug".to_string(),
850 note: None,
851 };
852 write_project_pin(&root, &pin).expect("write pin");
853
854 let sub = root.join("nested");
855 fs::create_dir_all(&sub).unwrap();
856 let slug = project_slug_at_readonly(&sub).expect("slug");
857 assert_eq!(
858 slug, "canonical-slug",
859 "readonly path must return the pinned slug"
860 );
861 }
862
863 /// Why: the hook read path must NOT create a pin file when none exists — the
864 /// lazy-write side-effect is only appropriate for interactive commands.
865 /// What: create a project root with no pin file, call `project_slug_at_readonly`,
866 /// assert the basename slug is returned but the pin file is NOT created.
867 /// Test: itself.
868 #[test]
869 fn project_slug_at_readonly_no_write_when_absent() {
870 let tmp = tempfile::tempdir().expect("tempdir");
871 let root = tmp.path().join("my-repo");
872 fs::create_dir_all(root.join(".git")).unwrap();
873
874 // No pin file before the call.
875 assert!(
876 read_project_pin(&root).expect("no err").is_none(),
877 "no pin before call"
878 );
879
880 let slug = project_slug_at_readonly(&root).expect("slug");
881 assert_eq!(slug, "my-repo", "should derive from basename");
882
883 // Pin file must NOT have been created.
884 assert!(
885 read_project_pin(&root).expect("no err").is_none(),
886 "pin file must NOT be written by the readonly variant"
887 );
888 }
889
890 /// Why: `project_slug_at_readonly` must walk upward just like the writing
891 /// variant so it works from any subdirectory, not just the project root.
892 /// What: create a project root with a pin, start from a deep subdirectory,
893 /// assert the pinned slug is returned.
894 /// Test: itself.
895 #[test]
896 fn project_slug_at_readonly_falls_back_to_basename() {
897 let tmp = tempfile::tempdir().expect("tempdir");
898 let root = tmp.path().join("basename-project");
899 fs::create_dir_all(root.join(".git")).unwrap();
900 // No pin file — readonly path must fall back to basename.
901 let slug = project_slug_at_readonly(&root).expect("slug");
902 assert_eq!(slug, "basename-project");
903 // Still no pin file.
904 assert!(read_project_pin(&root).unwrap().is_none());
905 }
906
907 // -----------------------------------------------------------------------
908 // Change 2: validate_palace_name with pin-file cwd
909 // -----------------------------------------------------------------------
910
911 /// Why: Change 2 — when the caller passes a `cwd` path that contains
912 /// (or is above) a `.trusty-tools/trusty-memory.yaml` pin file,
913 /// `validate_palace_name` must accept the pinned slug rather than the
914 /// basename of the CWD directory. This is the core correctness guarantee
915 /// for multi-checkout and drive-reorg scenarios.
916 /// What: create a project root named `new-name` with a `.git` marker and
917 /// a pin file for `original-slug`; assert `validate_palace_name(
918 /// "original-slug", new-name/src)` returns `Ok(())`.
919 /// Test: itself.
920 #[test]
921 fn validate_palace_name_accepts_pinned_slug_via_cwd() {
922 let tmp = tempfile::tempdir().expect("tempdir");
923 let root = tmp.path().join("new-name");
924 fs::create_dir_all(root.join(".git")).unwrap();
925 let pin = ProjectPin {
926 schema_version: PIN_SCHEMA_VERSION,
927 palace: "original-slug".to_string(),
928 note: None,
929 };
930 write_project_pin(&root, &pin).expect("write pin");
931
932 let sub = root.join("src");
933 fs::create_dir_all(&sub).unwrap();
934
935 // The pinned slug must be accepted even though the dir is "new-name".
936 let result = validate_palace_name("original-slug", &sub);
937 assert!(
938 result.is_ok(),
939 "pinned slug must be accepted when cwd resolves to pin: {result:?}"
940 );
941
942 // The basename slug must be rejected (it is not in the pin file).
943 let mismatch = validate_palace_name("new-name", &sub);
944 assert!(
945 mismatch.is_err(),
946 "non-pinned name must be rejected when pin file exists"
947 );
948 }
949
950 // Note: the bypass-env contract (TRUSTY_SKIP_PALACE_ENFORCEMENT=1 allows any
951 // name) is covered by `dispatch_palace_create_persists` in tools.rs, which
952 // sets the env var in the test harness. No unit test here — the env-var
953 // bypass is a test-only escape hatch and not part of the public API contract.
954
955 #[cfg(unix)]
956 #[test]
957 fn lazy_write_non_fatal_on_readonly_dir() {
958 use std::os::unix::fs::PermissionsExt;
959 let tmp = tempfile::tempdir().expect("tempdir");
960 let root = tmp.path().join("ro-project");
961 fs::create_dir_all(root.join(".git")).unwrap();
962
963 // Make the root read-only so the lazy write cannot create `.trusty-tools/`.
964 let mut perms = fs::metadata(&root).unwrap().permissions();
965 perms.set_mode(0o555);
966 fs::set_permissions(&root, perms).unwrap();
967
968 let slug = project_slug_at(&root);
969 // Restore permissions before the tempdir drops (so cleanup works).
970 let mut restore = fs::metadata(&root).unwrap().permissions();
971 restore.set_mode(0o755);
972 fs::set_permissions(&root, restore).unwrap();
973
974 assert!(
975 slug.is_some(),
976 "slug must be returned even when the pin write fails"
977 );
978 assert_eq!(slug.unwrap(), "ro-project");
979 }
980}