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