Skip to main content

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(&current) {
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}