trusty_memory/project_root/mod.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//! Sub-modules:
12//! - `detection`: `find_project_root`, `PROJECT_MARKERS`, `TRUSTY_TOOLS_DIR`,
13//! `PERSONAL_PALACE`, `is_unsafe_pin_location`.
14//! - `pin_file`: `ProjectPin`, `PIN_SCHEMA_VERSION`, `PIN_FILE_REL`,
15//! `read_project_pin`, `write_project_pin`, `project_slug_at`,
16//! `project_slug_at_readonly`, `project_slug`, `project_slug_from_basename`.
17//! - `validation`: `validate_palace_name`.
18//!
19//! Test: `project_slug_finds_git_root`, `project_slug_returns_none_without_markers`,
20//! `project_slug_uses_first_ancestor_marker`,
21//! `project_slug_personal_always_allowed`,
22//! `pin_file_read_when_present`, `absent_pin_writes_computed_slug`,
23//! `renamed_dir_with_pin_resolves_to_original_slug`,
24//! `trusty_tools_dir_is_project_marker`,
25//! `lazy_write_non_fatal_on_readonly_dir`.
26
27mod detection;
28mod pin_file;
29mod validation;
30
31pub use detection::{find_project_root, PERSONAL_PALACE, PROJECT_MARKERS, TRUSTY_TOOLS_DIR};
32pub use pin_file::{
33 pinned_slug_at, project_slug, project_slug_at, project_slug_at_readonly,
34 project_slug_from_basename, read_project_pin, write_project_pin, ProjectPin, PIN_FILE_REL,
35 PIN_SCHEMA_VERSION,
36};
37pub use validation::validate_palace_name;
38
39#[cfg(test)]
40mod tests {
41 use super::*;
42 use std::fs;
43
44 // -----------------------------------------------------------------------
45 // find_project_root
46 // -----------------------------------------------------------------------
47
48 /// Why: the primary use-case — a nested directory inside a git repo must
49 /// resolve to the repo root, not just the immediate parent.
50 /// What: create a temp dir with a `.git` subdir, nest a subdirectory
51 /// inside it, and assert that `find_project_root` from the subdirectory
52 /// returns the outer root (the one with `.git`).
53 /// Test: itself.
54 #[test]
55 fn project_slug_finds_git_root() {
56 let tmp = tempfile::tempdir().expect("tempdir");
57 let root = tmp.path().to_path_buf();
58 // Create a .git marker at the root level.
59 fs::create_dir_all(root.join(".git")).unwrap();
60 // Create a nested subdirectory.
61 let nested = root.join("crates").join("foo");
62 fs::create_dir_all(&nested).unwrap();
63
64 let found = find_project_root(&nested);
65 assert!(found.is_some(), "should find project root");
66 // Canonicalize both sides so macOS /var vs /private/var symlinks
67 // do not cause false mismatches.
68 let found_canonical = fs::canonicalize(found.unwrap()).unwrap();
69 let root_canonical = fs::canonicalize(&root).unwrap();
70 assert_eq!(found_canonical, root_canonical);
71 }
72
73 /// Why: when the CWD is not inside any project, `find_project_root` must
74 /// return `None` so the caller can fall through to the `personal` palace.
75 /// What: create a temp dir with *no* marker files and assert the result
76 /// is `None`.
77 /// Test: itself.
78 #[test]
79 fn project_slug_returns_none_without_markers() {
80 let tmp = tempfile::tempdir().expect("tempdir");
81 // Bare directory — no .git, Cargo.toml, etc.
82 let found = find_project_root(tmp.path());
83 assert!(
84 found.is_none(),
85 "bare tempdir should not resolve to a project root"
86 );
87 }
88
89 /// Why: `Cargo.toml` is also a valid project marker; not every project
90 /// uses git.
91 /// What: create a temp dir with a `Cargo.toml` file and assert it is
92 /// detected as the project root from a subdirectory.
93 /// Test: itself.
94 #[test]
95 fn project_slug_uses_first_ancestor_marker() {
96 let tmp = tempfile::tempdir().expect("tempdir");
97 let root = tmp.path().to_path_buf();
98 fs::write(root.join("Cargo.toml"), "[package]").unwrap();
99 let sub = root.join("src");
100 fs::create_dir_all(&sub).unwrap();
101
102 let found = find_project_root(&sub);
103 assert!(found.is_some());
104 // Canonicalize both sides so macOS /var vs /private/var symlinks
105 // do not cause false mismatches.
106 let found_canonical = fs::canonicalize(found.unwrap()).unwrap();
107 let root_canonical = fs::canonicalize(&root).unwrap();
108 assert_eq!(found_canonical, root_canonical);
109 }
110
111 // -----------------------------------------------------------------------
112 // project_slug_at
113 // -----------------------------------------------------------------------
114
115 /// Why: the slug must be the slugified basename of the project root, not
116 /// the subdirectory we started from.
117 /// What: create a root named `my-project` with a `.git` marker; start
118 /// from a nested subdirectory; assert the slug is `my-project`.
119 /// Test: itself.
120 #[test]
121 fn project_slug_at_returns_root_basename_slug() {
122 let tmp = tempfile::tempdir().expect("tempdir");
123 let root = tmp.path().join("my-project");
124 fs::create_dir_all(root.join(".git")).unwrap();
125 let src = root.join("src");
126 fs::create_dir_all(&src).unwrap();
127
128 let slug = project_slug_at(&src).expect("should return slug");
129 assert_eq!(slug, "my-project");
130 }
131
132 /// Why: uppercase and underscores must be normalised by the slug derivation
133 /// so that `My_Project` and `my-project` resolve to the same palace.
134 /// What: create a root named `My_Project`; assert the derived slug is
135 /// `my-project`.
136 /// Test: itself.
137 #[test]
138 fn project_slug_at_normalises_case_and_underscores() {
139 let tmp = tempfile::tempdir().expect("tempdir");
140 let root = tmp.path().join("My_Project");
141 fs::create_dir_all(root.join(".git")).unwrap();
142
143 let slug = project_slug_at(&root).expect("should return slug");
144 assert_eq!(slug, "my-project");
145 }
146
147 /// Why: when no project root is found, `project_slug_at` must return
148 /// `None` so the caller knows to use `personal`.
149 /// Test: itself.
150 #[test]
151 fn project_slug_at_returns_none_without_markers() {
152 let tmp = tempfile::tempdir().expect("tempdir");
153 assert!(project_slug_at(tmp.path()).is_none());
154 }
155
156 // -----------------------------------------------------------------------
157 // validate_palace_name
158 // -----------------------------------------------------------------------
159
160 /// Why: `personal` is the sanctioned escape hatch; it must always be
161 /// accepted regardless of whether a project root is found.
162 /// What: run `validate_palace_name("personal", …)` from a plain temp
163 /// dir (no project markers); assert `Ok(())`.
164 /// Test: itself.
165 #[test]
166 fn validate_palace_name_accepts_personal() {
167 let tmp = tempfile::tempdir().expect("tempdir");
168 let result = validate_palace_name(PERSONAL_PALACE, tmp.path());
169 assert!(
170 result.is_ok(),
171 "personal must always be accepted; got {result:?}"
172 );
173 }
174
175 /// Why: when the name exactly matches the derived slug the creation must
176 /// succeed.
177 /// What: create a project root named `cool-app`; assert that
178 /// `validate_palace_name("cool-app", subdir)` returns `Ok(())`.
179 /// Test: itself.
180 #[test]
181 fn validate_palace_name_accepts_matching_slug() {
182 let tmp = tempfile::tempdir().expect("tempdir");
183 let root = tmp.path().join("cool-app");
184 fs::create_dir_all(root.join(".git")).unwrap();
185 let sub = root.join("src");
186 fs::create_dir_all(&sub).unwrap();
187
188 let result = validate_palace_name("cool-app", &sub);
189 assert!(result.is_ok(), "matching slug must be accepted: {result:?}");
190 }
191
192 /// Why: a mismatched name must be rejected with an actionable error that
193 /// tells the user which slug is expected.
194 /// What: create a project root named `cool-app`; assert that
195 /// `validate_palace_name("wrong-name", subdir)` returns `Err` and the
196 /// error message mentions `cool-app`.
197 /// Test: itself.
198 #[test]
199 fn validate_palace_name_rejects_mismatch() {
200 let tmp = tempfile::tempdir().expect("tempdir");
201 let root = tmp.path().join("cool-app");
202 fs::create_dir_all(root.join(".git")).unwrap();
203 let sub = root.join("src");
204 fs::create_dir_all(&sub).unwrap();
205
206 let result = validate_palace_name("wrong-name", &sub);
207 assert!(result.is_err(), "mismatched name must be rejected");
208 let msg = result.unwrap_err().to_string();
209 assert!(
210 msg.contains("cool-app"),
211 "error must mention the expected slug; got: {msg}"
212 );
213 }
214
215 /// Why: outside a project directory, only `personal` is allowed; any
216 /// other name must be rejected.
217 /// What: use a plain tempdir (no markers); assert that any non-`personal`
218 /// name returns `Err`.
219 /// Test: itself.
220 #[test]
221 fn validate_palace_name_rejects_non_personal_without_project() {
222 let tmp = tempfile::tempdir().expect("tempdir");
223 let result = validate_palace_name("my-notes", tmp.path());
224 assert!(
225 result.is_err(),
226 "non-personal name outside a project must be rejected"
227 );
228 let msg = result.unwrap_err().to_string();
229 assert!(
230 msg.contains("personal"),
231 "error must mention 'personal'; got: {msg}"
232 );
233 }
234
235 // -----------------------------------------------------------------------
236 // Pin-file helpers: read_project_pin / write_project_pin
237 // -----------------------------------------------------------------------
238
239 /// Why: the round-trip must be lossless — what we write we must be able
240 /// to read back with the same slug value.
241 /// What: writes a pin, reads it back, asserts all fields match.
242 /// Test: itself.
243 #[test]
244 fn write_and_read_pin_round_trips() {
245 let tmp = tempfile::tempdir().expect("tempdir");
246 let pin = ProjectPin {
247 schema_version: PIN_SCHEMA_VERSION,
248 palace: "my-project".to_string(),
249 note: None,
250 };
251 write_project_pin(tmp.path(), &pin).expect("write ok");
252 let read_back = read_project_pin(tmp.path())
253 .expect("read ok")
254 .expect("Some(pin)");
255 assert_eq!(read_back, pin);
256 }
257
258 /// Why: the `note` field is optional; serialising without it must not emit
259 /// a `note: null` line in the YAML (which would confuse minimal parsers).
260 /// What: write a pin without `note`, read the raw YAML, assert it does not
261 /// contain the word `null`.
262 /// Test: itself.
263 #[test]
264 fn write_pin_omits_null_note() {
265 let tmp = tempfile::tempdir().expect("tempdir");
266 let pin = ProjectPin {
267 schema_version: PIN_SCHEMA_VERSION,
268 palace: "alpha".to_string(),
269 note: None,
270 };
271 let path = write_project_pin(tmp.path(), &pin).expect("write ok");
272 let raw = std::fs::read_to_string(&path).expect("read raw ok");
273 assert!(
274 !raw.contains("null"),
275 "null note must be omitted; got:\n{raw}"
276 );
277 assert!(raw.contains("palace: alpha"), "slug must be present");
278 assert!(
279 raw.contains("schema_version: 1"),
280 "schema_version must be present"
281 );
282 }
283
284 /// Why: `read_project_pin` must return `None` (not an error) when no pin
285 /// file has been written yet, so callers can fall through to basename
286 /// derivation without unwrapping an error.
287 /// Test: itself.
288 #[test]
289 fn read_project_pin_returns_none_when_absent() {
290 let tmp = tempfile::tempdir().expect("tempdir");
291 let result = read_project_pin(tmp.path()).expect("no error");
292 assert!(result.is_none(), "absent pin must yield None");
293 }
294
295 // -----------------------------------------------------------------------
296 // Phase-1 resolution order in project_slug_at
297 // -----------------------------------------------------------------------
298
299 /// Why: when a pin file is present it must override the directory basename,
300 /// which is the core goal of Phase 1.
301 /// What: create a root named `actual-dir`, write a pin file with
302 /// `palace: pinned-slug`, then assert `project_slug_at` from a sub-
303 /// directory returns `"pinned-slug"` (not `"actual-dir"`).
304 /// Test: itself.
305 #[test]
306 fn pin_file_read_when_present() {
307 let tmp = tempfile::tempdir().expect("tempdir");
308 let root = tmp.path().join("actual-dir");
309 fs::create_dir_all(root.join(".git")).unwrap();
310 let pin = ProjectPin {
311 schema_version: PIN_SCHEMA_VERSION,
312 palace: "pinned-slug".to_string(),
313 note: None,
314 };
315 write_project_pin(&root, &pin).expect("write pin");
316
317 let sub = root.join("src");
318 fs::create_dir_all(&sub).unwrap();
319 let slug = project_slug_at(&sub).expect("slug");
320 assert_eq!(
321 slug, "pinned-slug",
322 "pin file must override the directory basename"
323 );
324 }
325
326 /// Why: when no pin file exists, `project_slug_at` must lazily create one
327 /// so subsequent calls (or after a rename) use the file instead of the
328 /// basename.
329 /// What: create a project root with a `.git` marker but no pin file; call
330 /// `project_slug_at`; assert the pin file was created with the expected slug.
331 /// Test: itself.
332 #[test]
333 fn absent_pin_writes_computed_slug() {
334 let tmp = tempfile::tempdir().expect("tempdir");
335 let root = tmp.path().join("my-cool-project");
336 fs::create_dir_all(root.join(".git")).unwrap();
337
338 // No pin file yet.
339 assert!(
340 read_project_pin(&root).expect("no err").is_none(),
341 "no pin before first call"
342 );
343
344 let slug = project_slug_at(&root).expect("slug");
345 assert_eq!(slug, "my-cool-project");
346
347 // Pin file must now exist.
348 let pin = read_project_pin(&root)
349 .expect("no err")
350 .expect("pin written");
351 assert_eq!(pin.palace, "my-cool-project");
352 assert_eq!(pin.schema_version, PIN_SCHEMA_VERSION);
353 }
354
355 /// Why: the central use-case for Phase 1 — a project with a pin file
356 /// returns the original slug even after the directory is renamed.
357 /// What: create `old-name/` with `.git` + a pin file set to
358 /// `"original-slug"`; rename the directory to `new-name/`; assert that
359 /// `project_slug_at` from inside `new-name/` returns `"original-slug"`.
360 /// Test: itself.
361 #[test]
362 fn renamed_dir_with_pin_resolves_to_original_slug() {
363 let tmp = tempfile::tempdir().expect("tempdir");
364 let old_root = tmp.path().join("old-name");
365 fs::create_dir_all(old_root.join(".git")).unwrap();
366 let pin = ProjectPin {
367 schema_version: PIN_SCHEMA_VERSION,
368 palace: "original-slug".to_string(),
369 note: None,
370 };
371 write_project_pin(&old_root, &pin).expect("write pin");
372
373 // Simulate a directory rename.
374 let new_root = tmp.path().join("new-name");
375 fs::rename(&old_root, &new_root).expect("rename");
376
377 let sub = new_root.join("src");
378 fs::create_dir_all(&sub).unwrap();
379 let slug = project_slug_at(&sub).expect("slug after rename");
380 assert_eq!(
381 slug, "original-slug",
382 "pin file must survive the directory rename"
383 );
384 }
385
386 /// Why: decision D5 — a directory containing only `.trusty-tools/` must be
387 /// recognised as a project root so the pin file can be found without any
388 /// other ecosystem marker (`.git`, `Cargo.toml`, etc.).
389 /// What: create a bare tempdir, add only `.trusty-tools/`, assert that
390 /// `find_project_root` identifies it as the root.
391 /// Test: itself.
392 #[test]
393 fn trusty_tools_dir_is_project_marker() {
394 let tmp = tempfile::tempdir().expect("tempdir");
395 fs::create_dir_all(tmp.path().join(TRUSTY_TOOLS_DIR)).unwrap();
396 let found = find_project_root(tmp.path());
397 assert!(
398 found.is_some(),
399 ".trusty-tools must trigger project-root detection"
400 );
401 }
402
403 // -----------------------------------------------------------------------
404 // pinned_slug_at (issue #1217)
405 // -----------------------------------------------------------------------
406
407 /// Why: `pinned_slug_at` must return the pinned slug when a pin file exists
408 /// — this is the backward-compat anchor that keeps already-pinned projects
409 /// from being re-derived by the new git/dir scheme.
410 /// What: write a pin for `original-slug` under a `.git` root, call from a
411 /// subdirectory, assert the pinned slug is returned.
412 /// Test: itself.
413 #[test]
414 fn pinned_slug_at_returns_pin_when_present() {
415 let tmp = tempfile::tempdir().expect("tempdir");
416 let root = tmp.path().join("renamed-dir");
417 fs::create_dir_all(root.join(".git")).unwrap();
418 let pin = ProjectPin {
419 schema_version: PIN_SCHEMA_VERSION,
420 palace: "original-slug".to_string(),
421 note: None,
422 };
423 write_project_pin(&root, &pin).expect("write pin");
424 let sub = root.join("src");
425 fs::create_dir_all(&sub).unwrap();
426 assert_eq!(pinned_slug_at(&sub).as_deref(), Some("original-slug"));
427 }
428
429 /// Why: when no pin file exists `pinned_slug_at` must return `None` (NOT the
430 /// basename) so the caller falls through to identity derivation. This is the
431 /// behavioural difference from `project_slug_at_readonly`.
432 /// What: a `.git` root with no pin file; assert `None` and that no pin file
433 /// was created as a side-effect.
434 /// Test: itself.
435 #[test]
436 fn pinned_slug_at_returns_none_without_pin() {
437 let tmp = tempfile::tempdir().expect("tempdir");
438 let root = tmp.path().join("my-repo");
439 fs::create_dir_all(root.join(".git")).unwrap();
440 assert!(
441 pinned_slug_at(&root).is_none(),
442 "no pin file must yield None, not the basename"
443 );
444 // Must not write a pin file.
445 assert!(read_project_pin(&root).expect("no err").is_none());
446 }
447
448 // -----------------------------------------------------------------------
449 // project_slug_at_readonly
450 // -----------------------------------------------------------------------
451
452 /// Why: the hook read path must return the pinned slug without creating a
453 /// new pin file when one already exists — same authoritative result as the
454 /// writing variant but with no side-effects.
455 /// What: create a project root with a pin file, call `project_slug_at_readonly`
456 /// from a subdirectory, assert the pinned slug is returned and no new file
457 /// is written.
458 /// Test: itself.
459 #[test]
460 fn project_slug_at_readonly_reads_existing_pin() {
461 let tmp = tempfile::tempdir().expect("tempdir");
462 let root = tmp.path().join("some-dir");
463 fs::create_dir_all(root.join(".git")).unwrap();
464 let pin = ProjectPin {
465 schema_version: PIN_SCHEMA_VERSION,
466 palace: "canonical-slug".to_string(),
467 note: None,
468 };
469 write_project_pin(&root, &pin).expect("write pin");
470
471 let sub = root.join("nested");
472 fs::create_dir_all(&sub).unwrap();
473 let slug = project_slug_at_readonly(&sub).expect("slug");
474 assert_eq!(
475 slug, "canonical-slug",
476 "readonly path must return the pinned slug"
477 );
478 }
479
480 /// Why: the hook read path must NOT create a pin file when none exists — the
481 /// lazy-write side-effect is only appropriate for interactive commands.
482 /// What: create a project root with no pin file, call `project_slug_at_readonly`,
483 /// assert the basename slug is returned but the pin file is NOT created.
484 /// Test: itself.
485 #[test]
486 fn project_slug_at_readonly_no_write_when_absent() {
487 let tmp = tempfile::tempdir().expect("tempdir");
488 let root = tmp.path().join("my-repo");
489 fs::create_dir_all(root.join(".git")).unwrap();
490
491 // No pin file before the call.
492 assert!(
493 read_project_pin(&root).expect("no err").is_none(),
494 "no pin before call"
495 );
496
497 let slug = project_slug_at_readonly(&root).expect("slug");
498 assert_eq!(slug, "my-repo", "should derive from basename");
499
500 // Pin file must NOT have been created.
501 assert!(
502 read_project_pin(&root).expect("no err").is_none(),
503 "pin file must NOT be written by the readonly variant"
504 );
505 }
506
507 /// Why: `project_slug_at_readonly` must walk upward just like the writing
508 /// variant so it works from any subdirectory, not just the project root.
509 /// What: create a project root with a pin, start from a deep subdirectory,
510 /// assert the pinned slug is returned.
511 /// Test: itself.
512 #[test]
513 fn project_slug_at_readonly_falls_back_to_basename() {
514 let tmp = tempfile::tempdir().expect("tempdir");
515 let root = tmp.path().join("basename-project");
516 fs::create_dir_all(root.join(".git")).unwrap();
517 // No pin file — readonly path must fall back to basename.
518 let slug = project_slug_at_readonly(&root).expect("slug");
519 assert_eq!(slug, "basename-project");
520 // Still no pin file.
521 assert!(read_project_pin(&root).unwrap().is_none());
522 }
523
524 // -----------------------------------------------------------------------
525 // Change 2: validate_palace_name with pin-file cwd
526 // -----------------------------------------------------------------------
527
528 /// Why: Change 2 — when the caller passes a `cwd` path that contains
529 /// (or is above) a `.trusty-tools/trusty-memory.yaml` pin file,
530 /// `validate_palace_name` must accept the pinned slug rather than the
531 /// basename of the CWD directory. This is the core correctness guarantee
532 /// for multi-checkout and drive-reorg scenarios.
533 /// What: create a project root named `new-name` with a `.git` marker and
534 /// a pin file for `original-slug`; assert `validate_palace_name(
535 /// "original-slug", new-name/src)` returns `Ok(())`.
536 /// Test: itself.
537 #[test]
538 fn validate_palace_name_accepts_pinned_slug_via_cwd() {
539 let tmp = tempfile::tempdir().expect("tempdir");
540 let root = tmp.path().join("new-name");
541 fs::create_dir_all(root.join(".git")).unwrap();
542 let pin = ProjectPin {
543 schema_version: PIN_SCHEMA_VERSION,
544 palace: "original-slug".to_string(),
545 note: None,
546 };
547 write_project_pin(&root, &pin).expect("write pin");
548
549 let sub = root.join("src");
550 fs::create_dir_all(&sub).unwrap();
551
552 // The pinned slug must be accepted even though the dir is "new-name".
553 let result = validate_palace_name("original-slug", &sub);
554 assert!(
555 result.is_ok(),
556 "pinned slug must be accepted when cwd resolves to pin: {result:?}"
557 );
558
559 // The basename slug must be rejected (it is not in the pin file).
560 let mismatch = validate_palace_name("new-name", &sub);
561 assert!(
562 mismatch.is_err(),
563 "non-pinned name must be rejected when pin file exists"
564 );
565 }
566
567 // Note: the bypass-env contract (TRUSTY_SKIP_PALACE_ENFORCEMENT=1 allows any
568 // name) is covered by `dispatch_palace_create_persists` in tools.rs, which
569 // sets the env var in the test harness. No unit test here — the env-var
570 // bypass is a test-only escape hatch and not part of the public API contract.
571
572 #[cfg(unix)]
573 #[test]
574 fn lazy_write_non_fatal_on_readonly_dir() {
575 use std::os::unix::fs::PermissionsExt;
576 let tmp = tempfile::tempdir().expect("tempdir");
577 let root = tmp.path().join("ro-project");
578 fs::create_dir_all(root.join(".git")).unwrap();
579
580 // Make the root read-only so the lazy write cannot create `.trusty-tools/`.
581 let mut perms = fs::metadata(&root).unwrap().permissions();
582 perms.set_mode(0o555);
583 fs::set_permissions(&root, perms).unwrap();
584
585 let slug = project_slug_at(&root);
586 // Restore permissions before the tempdir drops (so cleanup works).
587 let mut restore = fs::metadata(&root).unwrap().permissions();
588 restore.set_mode(0o755);
589 fs::set_permissions(&root, restore).unwrap();
590
591 assert!(
592 slug.is_some(),
593 "slug must be returned even when the pin write fails"
594 );
595 assert_eq!(slug.unwrap(), "ro-project");
596 }
597}