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    let mut current = start.to_path_buf();
135    // Canonicalize to resolve symlinks before walking (best-effort; fall back
136    // to the original path if canonicalization fails, e.g. path does not exist
137    // yet).
138    if let Ok(canonical) = std::fs::canonicalize(&current) {
139        current = canonical;
140    }
141    loop {
142        for marker in PROJECT_MARKERS {
143            if current.join(marker).exists() {
144                return Some(current);
145            }
146        }
147        // Ascend one level; stop at the filesystem root.
148        match current.parent() {
149            Some(parent) if parent != current => current = parent.to_path_buf(),
150            _ => return None,
151        }
152    }
153}
154
155/// Read the palace pin from `.trusty-tools/trusty-memory.yaml` at `root`.
156///
157/// Why: the pin file is the authoritative source for a project's palace slug
158/// when present. Reading it in a dedicated helper keeps the I/O concern
159/// separate from the slug-derivation logic and makes it easy to test the
160/// round-trip in isolation.
161/// What: constructs the path `root/.trusty-tools/trusty-memory.yaml`, reads
162/// it, and deserialises with `serde_yaml`. Returns `None` when the file does
163/// not exist. Returns `Err` only on I/O or parse failures.
164/// Test: `pin_file_read_when_present`, `read_project_pin_returns_none_when_absent`.
165pub fn read_project_pin(root: &Path) -> Result<Option<ProjectPin>> {
166    let pin_path = root.join(PIN_FILE_REL);
167    match std::fs::read_to_string(&pin_path) {
168        Ok(s) => {
169            let pin: ProjectPin = serde_yaml::from_str(&s)
170                .map_err(|e| anyhow::anyhow!("parse {}: {e}", pin_path.display()))?;
171            Ok(Some(pin))
172        }
173        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
174        Err(e) => Err(anyhow::anyhow!("read {}: {e}", pin_path.display())),
175    }
176}
177
178/// Write a palace pin to `.trusty-tools/trusty-memory.yaml` at `root`.
179///
180/// Why: the lazy-write path in `project_slug_at` and the explicit
181/// `trusty-memory link` backfill command both need to emit the same YAML
182/// schema. A single writer keeps the format consistent and avoids duplicated
183/// YAML-construction logic.
184/// What: creates `.trusty-tools/` if missing, serialises `pin` with
185/// `serde_yaml`, and writes it atomically (write to `<file>.tmp`, then
186/// rename). Returns the path that was written.
187/// Test: `write_project_pin_creates_expected_yaml`,
188///       `write_project_pin_round_trips_through_read`.
189pub fn write_project_pin(root: &Path, pin: &ProjectPin) -> Result<PathBuf> {
190    let dir = root.join(TRUSTY_TOOLS_DIR);
191    std::fs::create_dir_all(&dir).map_err(|e| anyhow::anyhow!("create {}: {e}", dir.display()))?;
192    let pin_path = root.join(PIN_FILE_REL);
193    let tmp_path = pin_path.with_extension("yaml.tmp");
194    let yaml = serde_yaml::to_string(pin).map_err(|e| anyhow::anyhow!("serialise pin: {e}"))?;
195    let header = "# .trusty-tools/trusty-memory.yaml\n\
196                  # This file pins the trusty-memory palace slug for this project.\n\
197                  # Commit it so the linkage survives directory renames and drive reorgs.\n\
198                  # Schema: https://github.com/bobmatnyc/trusty-tools (trusty-tools convention)\n\n";
199    let content = format!("{header}{yaml}");
200    std::fs::write(&tmp_path, &content)
201        .map_err(|e| anyhow::anyhow!("write {}: {e}", tmp_path.display()))?;
202    std::fs::rename(&tmp_path, &pin_path).map_err(|e| {
203        anyhow::anyhow!(
204            "rename {} → {}: {e}",
205            tmp_path.display(),
206            pin_path.display()
207        )
208    })?;
209    Ok(pin_path)
210}
211
212/// Compute the palace slug purely from the directory basename (the pre-Phase-1
213/// logic, now extracted for composability).
214///
215/// Why: the resolution order in `project_slug_at` needs to call the basename
216/// derivation without triggering the pin-file read/write side effects. Exposing
217/// this as a separate function makes both paths testable in isolation.
218/// What: calls `slugify_string` on the last path component of `root`. Returns
219/// `None` when the basename is empty or slugifies to an empty string.
220/// Test: `project_slug_from_basename_basic`.
221pub fn project_slug_from_basename(root: &Path) -> Option<String> {
222    let basename = root.file_name()?.to_str()?;
223    let slug = slugify_string(basename);
224    if slug.is_empty() {
225        None
226    } else {
227        Some(slug)
228    }
229}
230
231/// Derive a palace slug from the project root found at or above `start`.
232///
233/// Why: the core of issue #88 with Phase-1 pin-file support. Palace names
234/// must match the canonical slug of the project they belong to, and that slug
235/// must survive directory renames. The pin file provides the stable anchor.
236/// What: implements the two-step resolution order:
237///   a. Walk up to the project root. If `.trusty-tools/trusty-memory.yaml`
238///      exists, return `pin.palace` (authoritative — survives renames).
239///   b. If absent, compute the slug via `project_slug_from_basename`, then
240///      lazily write the pin file (best-effort, non-fatal) so future calls
241///      always land on path (a).
242/// Returns `None` when no project root is found.
243/// Test: `pin_file_read_when_present`, `absent_pin_writes_computed_slug`,
244///       `renamed_dir_with_pin_resolves_to_original_slug`.
245pub fn project_slug_at(start: &Path) -> Option<String> {
246    let root = find_project_root(start)?;
247
248    // Step (a): check for a committed pin file.
249    match read_project_pin(&root) {
250        Ok(Some(pin)) => return Some(pin.palace),
251        Ok(None) => {} // absent — fall through to step (b)
252        Err(e) => {
253            // Corrupt or unreadable pin file: log to stderr and fall through
254            // to the basename derivation so memory operations are not blocked.
255            tracing::warn!(
256                path = %root.join(PIN_FILE_REL).display(),
257                "could not read palace pin file ({e:#}); falling back to basename slug"
258            );
259        }
260    }
261
262    // Step (b): compute from basename and lazily write the pin file.
263    let slug = project_slug_from_basename(&root)?;
264    let pin = ProjectPin {
265        schema_version: PIN_SCHEMA_VERSION,
266        palace: slug.clone(),
267        note: None,
268    };
269    match write_project_pin(&root, &pin) {
270        Ok(path) => {
271            tracing::debug!(
272                slug = %slug,
273                path = %path.display(),
274                "wrote palace pin file (lazy init)"
275            );
276        }
277        Err(e) => {
278            // Read-only tree, insufficient permissions, etc. — non-fatal.
279            tracing::warn!(
280                slug = %slug,
281                root = %root.display(),
282                "could not write palace pin file ({e:#}); slug will remain basename-derived"
283            );
284        }
285    }
286    Some(slug)
287}
288
289/// Derive a palace slug for the current working directory.
290///
291/// Why: convenience wrapper over `project_slug_at` for callers that want
292/// the "natural" project slug (CLI commands, MCP handlers, tests running
293/// inside a repo).
294/// What: calls `std::env::current_dir()`, propagates the error if the syscall
295/// fails, then delegates to [`project_slug_at`].
296/// Test: `project_slug_finds_git_root` (run from inside the trusty-tools repo
297/// which is a git checkout).
298pub fn project_slug() -> Result<Option<String>> {
299    let cwd = std::env::current_dir().map_err(|e| anyhow::anyhow!("read cwd: {e}"))?;
300    Ok(project_slug_at(&cwd))
301}
302
303/// Validate a proposed palace name against project-slug enforcement rules.
304///
305/// Why: palace creation in MCP tool calls and HTTP handlers must apply the
306/// same enforcement logic. Centralising the check here keeps the rule in one
307/// place and makes it easy to write exhaustive unit tests.
308/// What: returns `Ok(())` when the name is valid; returns `Err` with a
309/// human-readable message when it is not. The rules are:
310///   1. `personal` is always valid (the escape hatch for non-project
311///      contexts).
312///   2. When a project root is detectable from `cwd`, the name must equal
313///      the derived slug.
314///   3. When no project root is detectable, only `personal` is allowed.
315///
316/// Existing palaces are **not** affected by this check; it applies only to
317/// *new* palace creation requests.
318/// Test: `validate_palace_name_accepts_personal`,
319/// `validate_palace_name_accepts_matching_slug`,
320/// `validate_palace_name_rejects_mismatch`,
321/// `validate_palace_name_rejects_non_personal_without_project`.
322pub fn validate_palace_name(name: &str, cwd: &Path) -> Result<()> {
323    // The `personal` palace is always a valid creation target.
324    if name == PERSONAL_PALACE {
325        return Ok(());
326    }
327
328    match project_slug_at(cwd) {
329        Some(expected) => {
330            if name == expected {
331                Ok(())
332            } else {
333                Err(anyhow::anyhow!(
334                    "palace name '{name}' does not match the project slug '{expected}' \
335                     (derived from {cwd}). \
336                     Either use '{expected}' or use 'personal' for non-project memories.",
337                    cwd = cwd.display(),
338                ))
339            }
340        }
341        None => Err(anyhow::anyhow!(
342            "no project root found at or above '{cwd}'. \
343             Use 'personal' for memories not tied to a project, \
344             or run from inside a project directory that contains \
345             a .git file, Cargo.toml, pyproject.toml, or package.json.",
346            cwd = cwd.display(),
347        )),
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use std::fs;
355
356    // -----------------------------------------------------------------------
357    // find_project_root
358    // -----------------------------------------------------------------------
359
360    /// Why: the primary use-case — a nested directory inside a git repo must
361    /// resolve to the repo root, not just the immediate parent.
362    /// What: create a temp dir with a `.git` subdir, nest a subdirectory
363    /// inside it, and assert that `find_project_root` from the subdirectory
364    /// returns the outer root (the one with `.git`).
365    /// Test: itself.
366    #[test]
367    fn project_slug_finds_git_root() {
368        let tmp = tempfile::tempdir().expect("tempdir");
369        let root = tmp.path().to_path_buf();
370        // Create a .git marker at the root level.
371        fs::create_dir_all(root.join(".git")).unwrap();
372        // Create a nested subdirectory.
373        let nested = root.join("crates").join("foo");
374        fs::create_dir_all(&nested).unwrap();
375
376        let found = find_project_root(&nested);
377        assert!(found.is_some(), "should find project root");
378        // Canonicalize both sides so macOS /var vs /private/var symlinks
379        // do not cause false mismatches.
380        let found_canonical = fs::canonicalize(found.unwrap()).unwrap();
381        let root_canonical = fs::canonicalize(&root).unwrap();
382        assert_eq!(found_canonical, root_canonical);
383    }
384
385    /// Why: when the CWD is not inside any project, `find_project_root` must
386    /// return `None` so the caller can fall through to the `personal` palace.
387    /// What: create a temp dir with *no* marker files and assert the result
388    /// is `None`.
389    /// Test: itself.
390    #[test]
391    fn project_slug_returns_none_without_markers() {
392        let tmp = tempfile::tempdir().expect("tempdir");
393        // Bare directory — no .git, Cargo.toml, etc.
394        let found = find_project_root(tmp.path());
395        assert!(
396            found.is_none(),
397            "bare tempdir should not resolve to a project root"
398        );
399    }
400
401    /// Why: `Cargo.toml` is also a valid project marker; not every project
402    /// uses git.
403    /// What: create a temp dir with a `Cargo.toml` file and assert it is
404    /// detected as the project root from a subdirectory.
405    /// Test: itself.
406    #[test]
407    fn project_slug_uses_first_ancestor_marker() {
408        let tmp = tempfile::tempdir().expect("tempdir");
409        let root = tmp.path().to_path_buf();
410        fs::write(root.join("Cargo.toml"), "[package]").unwrap();
411        let sub = root.join("src");
412        fs::create_dir_all(&sub).unwrap();
413
414        let found = find_project_root(&sub);
415        assert!(found.is_some());
416        // Canonicalize both sides so macOS /var vs /private/var symlinks
417        // do not cause false mismatches.
418        let found_canonical = fs::canonicalize(found.unwrap()).unwrap();
419        let root_canonical = fs::canonicalize(&root).unwrap();
420        assert_eq!(found_canonical, root_canonical);
421    }
422
423    // -----------------------------------------------------------------------
424    // project_slug_at
425    // -----------------------------------------------------------------------
426
427    /// Why: the slug must be the slugified basename of the project root, not
428    /// the subdirectory we started from.
429    /// What: create a root named `my-project` with a `.git` marker; start
430    /// from a nested subdirectory; assert the slug is `my-project`.
431    /// Test: itself.
432    #[test]
433    fn project_slug_at_returns_root_basename_slug() {
434        let tmp = tempfile::tempdir().expect("tempdir");
435        let root = tmp.path().join("my-project");
436        fs::create_dir_all(root.join(".git")).unwrap();
437        let src = root.join("src");
438        fs::create_dir_all(&src).unwrap();
439
440        let slug = project_slug_at(&src).expect("should return slug");
441        assert_eq!(slug, "my-project");
442    }
443
444    /// Why: uppercase and underscores must be normalised by the slug derivation
445    /// so that `My_Project` and `my-project` resolve to the same palace.
446    /// What: create a root named `My_Project`; assert the derived slug is
447    /// `my-project`.
448    /// Test: itself.
449    #[test]
450    fn project_slug_at_normalises_case_and_underscores() {
451        let tmp = tempfile::tempdir().expect("tempdir");
452        let root = tmp.path().join("My_Project");
453        fs::create_dir_all(root.join(".git")).unwrap();
454
455        let slug = project_slug_at(&root).expect("should return slug");
456        assert_eq!(slug, "my-project");
457    }
458
459    /// Why: when no project root is found, `project_slug_at` must return
460    /// `None` so the caller knows to use `personal`.
461    /// Test: itself.
462    #[test]
463    fn project_slug_at_returns_none_without_markers() {
464        let tmp = tempfile::tempdir().expect("tempdir");
465        assert!(project_slug_at(tmp.path()).is_none());
466    }
467
468    // -----------------------------------------------------------------------
469    // validate_palace_name
470    // -----------------------------------------------------------------------
471
472    /// Why: `personal` is the sanctioned escape hatch; it must always be
473    /// accepted regardless of whether a project root is found.
474    /// What: run `validate_palace_name("personal", …)` from a plain temp
475    /// dir (no project markers); assert `Ok(())`.
476    /// Test: itself.
477    #[test]
478    fn validate_palace_name_accepts_personal() {
479        let tmp = tempfile::tempdir().expect("tempdir");
480        let result = validate_palace_name(PERSONAL_PALACE, tmp.path());
481        assert!(
482            result.is_ok(),
483            "personal must always be accepted; got {result:?}"
484        );
485    }
486
487    /// Why: when the name exactly matches the derived slug the creation must
488    /// succeed.
489    /// What: create a project root named `cool-app`; assert that
490    /// `validate_palace_name("cool-app", subdir)` returns `Ok(())`.
491    /// Test: itself.
492    #[test]
493    fn validate_palace_name_accepts_matching_slug() {
494        let tmp = tempfile::tempdir().expect("tempdir");
495        let root = tmp.path().join("cool-app");
496        fs::create_dir_all(root.join(".git")).unwrap();
497        let sub = root.join("src");
498        fs::create_dir_all(&sub).unwrap();
499
500        let result = validate_palace_name("cool-app", &sub);
501        assert!(result.is_ok(), "matching slug must be accepted: {result:?}");
502    }
503
504    /// Why: a mismatched name must be rejected with an actionable error that
505    /// tells the user which slug is expected.
506    /// What: create a project root named `cool-app`; assert that
507    /// `validate_palace_name("wrong-name", subdir)` returns `Err` and the
508    /// error message mentions `cool-app`.
509    /// Test: itself.
510    #[test]
511    fn validate_palace_name_rejects_mismatch() {
512        let tmp = tempfile::tempdir().expect("tempdir");
513        let root = tmp.path().join("cool-app");
514        fs::create_dir_all(root.join(".git")).unwrap();
515        let sub = root.join("src");
516        fs::create_dir_all(&sub).unwrap();
517
518        let result = validate_palace_name("wrong-name", &sub);
519        assert!(result.is_err(), "mismatched name must be rejected");
520        let msg = result.unwrap_err().to_string();
521        assert!(
522            msg.contains("cool-app"),
523            "error must mention the expected slug; got: {msg}"
524        );
525    }
526
527    /// Why: outside a project directory, only `personal` is allowed; any
528    /// other name must be rejected.
529    /// What: use a plain tempdir (no markers); assert that any non-`personal`
530    /// name returns `Err`.
531    /// Test: itself.
532    #[test]
533    fn validate_palace_name_rejects_non_personal_without_project() {
534        let tmp = tempfile::tempdir().expect("tempdir");
535        let result = validate_palace_name("my-notes", tmp.path());
536        assert!(
537            result.is_err(),
538            "non-personal name outside a project must be rejected"
539        );
540        let msg = result.unwrap_err().to_string();
541        assert!(
542            msg.contains("personal"),
543            "error must mention 'personal'; got: {msg}"
544        );
545    }
546
547    // -----------------------------------------------------------------------
548    // Pin-file helpers: read_project_pin / write_project_pin
549    // -----------------------------------------------------------------------
550
551    /// Why: the round-trip must be lossless — what we write we must be able
552    /// to read back with the same slug value.
553    /// What: writes a pin, reads it back, asserts all fields match.
554    /// Test: itself.
555    #[test]
556    fn write_and_read_pin_round_trips() {
557        let tmp = tempfile::tempdir().expect("tempdir");
558        let pin = ProjectPin {
559            schema_version: PIN_SCHEMA_VERSION,
560            palace: "my-project".to_string(),
561            note: None,
562        };
563        write_project_pin(tmp.path(), &pin).expect("write ok");
564        let read_back = read_project_pin(tmp.path())
565            .expect("read ok")
566            .expect("Some(pin)");
567        assert_eq!(read_back, pin);
568    }
569
570    /// Why: the `note` field is optional; serialising without it must not emit
571    /// a `note: null` line in the YAML (which would confuse minimal parsers).
572    /// What: write a pin without `note`, read the raw YAML, assert it does not
573    /// contain the word `null`.
574    /// Test: itself.
575    #[test]
576    fn write_pin_omits_null_note() {
577        let tmp = tempfile::tempdir().expect("tempdir");
578        let pin = ProjectPin {
579            schema_version: PIN_SCHEMA_VERSION,
580            palace: "alpha".to_string(),
581            note: None,
582        };
583        let path = write_project_pin(tmp.path(), &pin).expect("write ok");
584        let raw = std::fs::read_to_string(&path).expect("read raw ok");
585        assert!(
586            !raw.contains("null"),
587            "null note must be omitted; got:\n{raw}"
588        );
589        assert!(raw.contains("palace: alpha"), "slug must be present");
590        assert!(
591            raw.contains("schema_version: 1"),
592            "schema_version must be present"
593        );
594    }
595
596    /// Why: `read_project_pin` must return `None` (not an error) when no pin
597    /// file has been written yet, so callers can fall through to basename
598    /// derivation without unwrapping an error.
599    /// Test: itself.
600    #[test]
601    fn read_project_pin_returns_none_when_absent() {
602        let tmp = tempfile::tempdir().expect("tempdir");
603        let result = read_project_pin(tmp.path()).expect("no error");
604        assert!(result.is_none(), "absent pin must yield None");
605    }
606
607    // -----------------------------------------------------------------------
608    // Phase-1 resolution order in project_slug_at
609    // -----------------------------------------------------------------------
610
611    /// Why: when a pin file is present it must override the directory basename,
612    /// which is the core goal of Phase 1.
613    /// What: create a root named `actual-dir`, write a pin file with
614    /// `palace: pinned-slug`, then assert `project_slug_at` from a sub-
615    /// directory returns `"pinned-slug"` (not `"actual-dir"`).
616    /// Test: itself.
617    #[test]
618    fn pin_file_read_when_present() {
619        let tmp = tempfile::tempdir().expect("tempdir");
620        let root = tmp.path().join("actual-dir");
621        fs::create_dir_all(root.join(".git")).unwrap();
622        let pin = ProjectPin {
623            schema_version: PIN_SCHEMA_VERSION,
624            palace: "pinned-slug".to_string(),
625            note: None,
626        };
627        write_project_pin(&root, &pin).expect("write pin");
628
629        let sub = root.join("src");
630        fs::create_dir_all(&sub).unwrap();
631        let slug = project_slug_at(&sub).expect("slug");
632        assert_eq!(
633            slug, "pinned-slug",
634            "pin file must override the directory basename"
635        );
636    }
637
638    /// Why: when no pin file exists, `project_slug_at` must lazily create one
639    /// so subsequent calls (or after a rename) use the file instead of the
640    /// basename.
641    /// What: create a project root with a `.git` marker but no pin file; call
642    /// `project_slug_at`; assert the pin file was created with the expected slug.
643    /// Test: itself.
644    #[test]
645    fn absent_pin_writes_computed_slug() {
646        let tmp = tempfile::tempdir().expect("tempdir");
647        let root = tmp.path().join("my-cool-project");
648        fs::create_dir_all(root.join(".git")).unwrap();
649
650        // No pin file yet.
651        assert!(
652            read_project_pin(&root).expect("no err").is_none(),
653            "no pin before first call"
654        );
655
656        let slug = project_slug_at(&root).expect("slug");
657        assert_eq!(slug, "my-cool-project");
658
659        // Pin file must now exist.
660        let pin = read_project_pin(&root)
661            .expect("no err")
662            .expect("pin written");
663        assert_eq!(pin.palace, "my-cool-project");
664        assert_eq!(pin.schema_version, PIN_SCHEMA_VERSION);
665    }
666
667    /// Why: the central use-case for Phase 1 — a project with a pin file
668    /// returns the original slug even after the directory is renamed.
669    /// What: create `old-name/` with `.git` + a pin file set to
670    /// `"original-slug"`; rename the directory to `new-name/`; assert that
671    /// `project_slug_at` from inside `new-name/` returns `"original-slug"`.
672    /// Test: itself.
673    #[test]
674    fn renamed_dir_with_pin_resolves_to_original_slug() {
675        let tmp = tempfile::tempdir().expect("tempdir");
676        let old_root = tmp.path().join("old-name");
677        fs::create_dir_all(old_root.join(".git")).unwrap();
678        let pin = ProjectPin {
679            schema_version: PIN_SCHEMA_VERSION,
680            palace: "original-slug".to_string(),
681            note: None,
682        };
683        write_project_pin(&old_root, &pin).expect("write pin");
684
685        // Simulate a directory rename.
686        let new_root = tmp.path().join("new-name");
687        fs::rename(&old_root, &new_root).expect("rename");
688
689        let sub = new_root.join("src");
690        fs::create_dir_all(&sub).unwrap();
691        let slug = project_slug_at(&sub).expect("slug after rename");
692        assert_eq!(
693            slug, "original-slug",
694            "pin file must survive the directory rename"
695        );
696    }
697
698    /// Why: decision D5 — a directory containing only `.trusty-tools/` must be
699    /// recognised as a project root so the pin file can be found without any
700    /// other ecosystem marker (`.git`, `Cargo.toml`, etc.).
701    /// What: create a bare tempdir, add only `.trusty-tools/`, assert that
702    /// `find_project_root` identifies it as the root.
703    /// Test: itself.
704    #[test]
705    fn trusty_tools_dir_is_project_marker() {
706        let tmp = tempfile::tempdir().expect("tempdir");
707        fs::create_dir_all(tmp.path().join(TRUSTY_TOOLS_DIR)).unwrap();
708        let found = find_project_root(tmp.path());
709        assert!(
710            found.is_some(),
711            ".trusty-tools must trigger project-root detection"
712        );
713    }
714
715    /// Why: the lazy write in `project_slug_at` must be non-fatal when the
716    /// target directory is read-only. Otherwise, memory operations would panic
717    /// or return an error in environments where the project tree is immutable.
718    /// What: create a project root, make it read-only, call `project_slug_at`
719    /// from inside it, and assert we still get a slug (not None or an error).
720    /// Test: itself.
721    #[cfg(unix)]
722    #[test]
723    fn lazy_write_non_fatal_on_readonly_dir() {
724        use std::os::unix::fs::PermissionsExt;
725        let tmp = tempfile::tempdir().expect("tempdir");
726        let root = tmp.path().join("ro-project");
727        fs::create_dir_all(root.join(".git")).unwrap();
728
729        // Make the root read-only so the lazy write cannot create `.trusty-tools/`.
730        let mut perms = fs::metadata(&root).unwrap().permissions();
731        perms.set_mode(0o555);
732        fs::set_permissions(&root, perms).unwrap();
733
734        let slug = project_slug_at(&root);
735        // Restore permissions before the tempdir drops (so cleanup works).
736        let mut restore = fs::metadata(&root).unwrap().permissions();
737        restore.set_mode(0o755);
738        fs::set_permissions(&root, restore).unwrap();
739
740        assert!(
741            slug.is_some(),
742            "slug must be returned even when the pin write fails"
743        );
744        assert_eq!(slug.unwrap(), "ro-project");
745    }
746}