Skip to main content

trusty_memory/project_root/
detection.rs

1//! Project-root detection and filesystem walking.
2//!
3//! Why: Isolating the detection walk from the pin-file I/O keeps each file
4//! under the 500-SLOC cap and makes the walk independently testable.
5//! What: `find_project_root`, `find_project_root_with_marker`,
6//! `is_unsafe_pin_location`, `PROJECT_MARKERS`, `TRUSTY_TOOLS_DIR`,
7//! `PERSONAL_PALACE`.
8//! Test: `project_slug_finds_git_root`, `project_slug_returns_none_without_markers`,
9//! `project_slug_uses_first_ancestor_marker`, `trusty_tools_dir_is_project_marker`.
10
11use std::path::{Path, PathBuf};
12
13/// The `.trusty-tools/` directory name (used as a project marker).
14///
15/// Why: a project that already contains `.trusty-tools/trusty-memory.yaml`
16/// should be recognised as a project root even if it has no `.git` or
17/// `Cargo.toml`. Adding the directory itself to `PROJECT_MARKERS` (decision
18/// D5) lets `find_project_root` detect this case without special-casing.
19/// What: `".trusty-tools"`.
20/// Test: `trusty_tools_dir_is_project_marker`.
21pub const TRUSTY_TOOLS_DIR: &str = ".trusty-tools";
22
23/// Sentinel palace name that is always valid regardless of project context.
24///
25/// Why: users operating outside any project root (global notes, exploratory
26/// sessions, personal task lists) need a stable palace that can receive
27/// memories without failing the project-enforcement gate. The name `personal`
28/// is the single reserved identifier for this purpose.
29/// What: a `&str` constant that the enforcement logic tests against before
30/// applying project-slug validation.
31/// Test: `project_slug_personal_always_allowed`.
32pub const PERSONAL_PALACE: &str = "personal";
33
34/// File names that mark a directory as a project root.
35///
36/// Why: different ecosystems use different conventions for the project root;
37/// we want a single, ordered list that every part of the codebase agrees on
38/// so project detection is consistent whether invoked from CLI, MCP, or
39/// tests. `.git` comes first because it is the most universal signal.
40/// `.trusty-tools` is included (decision D5) so a directory that already
41/// carries a pin file is recognised even without a `.git` or build manifest.
42/// What: an ordered slice of filenames checked by `find_project_root`. A
43/// directory is considered a project root when it contains *any* of these.
44/// Test: `project_slug_uses_first_ancestor_marker`,
45///       `trusty_tools_dir_is_project_marker`.
46pub const PROJECT_MARKERS: &[&str] = &[
47    ".git",
48    "Cargo.toml",
49    "pyproject.toml",
50    "package.json",
51    "go.mod",
52    ".project-root",
53    TRUSTY_TOOLS_DIR,
54];
55
56/// Walk upward from `start` and return the first ancestor directory (inclusive)
57/// that contains at least one project marker.
58///
59/// Why: keeping the filesystem walk in a dedicated helper makes both the slug
60/// derivation function and the tests easier to reason about — callers get the
61/// root path, not just the slug.
62/// What: starts at `start`, checks for every [`PROJECT_MARKERS`] file/dir,
63/// and ascends to `parent()` until a root is found or the filesystem root is
64/// reached. Returns `None` when no project root is found.
65/// Test: `project_slug_finds_git_root`, `project_slug_uses_first_ancestor_marker`.
66pub fn find_project_root(start: &Path) -> Option<PathBuf> {
67    find_project_root_with_marker(start).map(|(root, _)| root)
68}
69
70/// Walk upward from `start` and return the first ancestor directory (inclusive)
71/// that contains at least one project marker, together with the name of the
72/// marker that was found.
73///
74/// Why: the lazy-write path in `project_slug_at` needs to know whether the
75/// detected root was anchored by a real marker (`.git`, `Cargo.toml`, etc.)
76/// or whether no marker was found at all (in which case `find_project_root`
77/// would already have returned `None` — this function's second return value
78/// is always `Some` when the `PathBuf` is `Some`). The guard in
79/// `project_slug_at` uses the marker name to distinguish a real project root
80/// from a directory that only became the root by coincidence (e.g. a stale
81/// pin file in `/tmp`).
82/// What: same walk as `find_project_root`; returns `Some((root, marker))` on
83/// success where `marker` is one of the strings from [`PROJECT_MARKERS`].
84/// Returns `None` when no project root is found.
85/// Test: indirectly via `find_project_root`, `project_slug_at`, and the
86/// guard tests `lazy_write_skipped_for_temp_dir_root`.
87pub(super) fn find_project_root_with_marker(start: &Path) -> Option<(PathBuf, &'static str)> {
88    let mut current = start.to_path_buf();
89    // Canonicalize to resolve symlinks before walking (best-effort; fall back
90    // to the original path if canonicalization fails, e.g. path does not exist
91    // yet).
92    if let Ok(canonical) = std::fs::canonicalize(&current) {
93        current = canonical;
94    }
95    loop {
96        for marker in PROJECT_MARKERS {
97            if current.join(marker).exists() {
98                return Some((current, marker));
99            }
100        }
101        // Ascend one level; stop at the filesystem root.
102        match current.parent() {
103            Some(parent) if parent != current => current = parent.to_path_buf(),
104            _ => return None,
105        }
106    }
107}
108
109/// Return `true` when `root` is an unsafe location where we must not
110/// lazily write a palace pin file.
111///
112/// Why (product guard): when `find_project_root` walks up from a temp or
113/// scratch directory and finds no real project marker, it can fall through
114/// to a fallback root such as the system temp dir, the user's home
115/// directory, or the filesystem root. Writing a pin file there silently
116/// poisons every future invocation from any subdirectory of that path —
117/// including every `tempfile::tempdir()` in the test suite (which resolves
118/// to a child of `/tmp`). The guard intercepts this before the write so
119/// only genuine project roots ever receive a pin file.
120/// What: canonicalises `root` and compares it against `std::env::temp_dir()`
121/// (canonicalised), `dirs::home_dir()` (canonicalised, best-effort), and the
122/// filesystem root `/`. Returns `true` when any comparison matches.
123/// Test: `lazy_write_skipped_for_temp_dir_root`.
124pub(super) fn is_unsafe_pin_location(root: &Path) -> bool {
125    let canonical = match std::fs::canonicalize(root) {
126        Ok(c) => c,
127        // If we can't canonicalise, treat as unsafe to be conservative.
128        Err(_) => return true,
129    };
130
131    // System temp dir (handles /tmp → /private/tmp on macOS).
132    let temp = std::fs::canonicalize(std::env::temp_dir()).unwrap_or_else(|_| std::env::temp_dir());
133    if canonical == temp {
134        return true;
135    }
136
137    // User home directory.
138    if let Some(home) = dirs::home_dir() {
139        let home_canon = std::fs::canonicalize(&home).unwrap_or(home);
140        if canonical == home_canon {
141            return true;
142        }
143    }
144
145    // Filesystem root.
146    if canonical == std::path::Path::new("/") {
147        return true;
148    }
149
150    false
151}