Skip to main content

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}