trusty_memory/project_root/pin_file.rs
1//! Pin-file I/O — read, write, and slug derivation helpers.
2//!
3//! Why: Isolating the pin-file I/O from detection and validation keeps each
4//! file under the 500-SLOC cap and groups the YAML serialization in one place.
5//! What: `ProjectPin`, `PIN_SCHEMA_VERSION`, `PIN_FILE_REL`, `read_project_pin`,
6//! `write_project_pin`, `project_slug_from_basename`, `project_slug_at`,
7//! `project_slug_at_readonly`, `project_slug`.
8//! Test: `pin_file_read_when_present`, `absent_pin_writes_computed_slug`,
9//! `renamed_dir_with_pin_resolves_to_original_slug`.
10
11use anyhow::Result;
12use serde::{Deserialize, Serialize};
13use std::path::{Path, PathBuf};
14
15use crate::messaging::slugify_string;
16
17use super::detection::{find_project_root, is_unsafe_pin_location, TRUSTY_TOOLS_DIR};
18
19/// Schema version for `.trusty-tools/trusty-memory.yaml`.
20///
21/// Why: forward-proofing — a future phase may need to distinguish older pin
22/// files that lack new fields. Hard-coding `1` now makes that migration
23/// straightforward: read `schema_version`, branch on the value.
24/// What: the `u32` constant `1`.
25/// Test: `write_project_pin` embeds this value; `read_project_pin` accepts it.
26pub const PIN_SCHEMA_VERSION: u32 = 1;
27
28/// Relative path of the pin file within a project root.
29///
30/// Why: defined as a constant so every call site (`read_project_pin`,
31/// `write_project_pin`, `find_project_root`) agrees on the same path and
32/// tests can compare against this value instead of a bare string literal.
33/// What: `".trusty-tools/trusty-memory.yaml"`.
34/// Test: used in every pin-file test in this module.
35pub const PIN_FILE_REL: &str = ".trusty-tools/trusty-memory.yaml";
36
37/// Serialisable schema for `.trusty-tools/trusty-memory.yaml`.
38///
39/// Why: a typed struct with `serde` makes the YAML schema self-documenting
40/// and prevents future fields from silently deserialising to wrong types.
41/// What: holds `schema_version` (always 1 for Phase 1) and `palace` (the
42/// pinned slug string). An optional `note` field is supported for humans who
43/// want to document why the slug was pinned.
44/// Test: `write_project_pin` round-trips through `read_project_pin`.
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
46pub struct ProjectPin {
47 /// Pin-file format version. Always `1` in Phase 1.
48 pub schema_version: u32,
49 /// The pinned palace slug — stored verbatim, no re-slugification.
50 pub palace: String,
51 /// Optional human note (e.g. "pinned before drive reorg 2026-06").
52 #[serde(skip_serializing_if = "Option::is_none")]
53 pub note: Option<String>,
54}
55
56/// Read the palace pin from `.trusty-tools/trusty-memory.yaml` at `root`.
57///
58/// Why: the pin file is the authoritative source for a project's palace slug
59/// when present. Reading it in a dedicated helper keeps the I/O concern
60/// separate from the slug-derivation logic and makes it easy to test the
61/// round-trip in isolation.
62/// What: constructs the path `root/.trusty-tools/trusty-memory.yaml`, reads
63/// it, and deserialises with `serde_yaml`. Returns `None` when the file does
64/// not exist. Returns `Err` only on I/O or parse failures.
65/// Test: `pin_file_read_when_present`, `read_project_pin_returns_none_when_absent`.
66pub fn read_project_pin(root: &Path) -> Result<Option<ProjectPin>> {
67 let pin_path = root.join(PIN_FILE_REL);
68 match std::fs::read_to_string(&pin_path) {
69 Ok(s) => {
70 let pin: ProjectPin = serde_yaml::from_str(&s)
71 .map_err(|e| anyhow::anyhow!("parse {}: {e}", pin_path.display()))?;
72 Ok(Some(pin))
73 }
74 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
75 Err(e) => Err(anyhow::anyhow!("read {}: {e}", pin_path.display())),
76 }
77}
78
79/// Write a palace pin to `.trusty-tools/trusty-memory.yaml` at `root`.
80///
81/// Why: the lazy-write path in `project_slug_at` and the explicit
82/// `trusty-memory link` backfill command both need to emit the same YAML
83/// schema. A single writer keeps the format consistent and avoids duplicated
84/// YAML-construction logic.
85/// What: creates `.trusty-tools/` if missing, serialises `pin` with
86/// `serde_yaml`, and writes it atomically (write to `<file>.tmp`, then
87/// rename). Returns the path that was written.
88/// Test: `write_project_pin_creates_expected_yaml`,
89/// `write_project_pin_round_trips_through_read`.
90pub fn write_project_pin(root: &Path, pin: &ProjectPin) -> Result<PathBuf> {
91 let dir = root.join(TRUSTY_TOOLS_DIR);
92 std::fs::create_dir_all(&dir).map_err(|e| anyhow::anyhow!("create {}: {e}", dir.display()))?;
93 let pin_path = root.join(PIN_FILE_REL);
94 let tmp_path = pin_path.with_extension("yaml.tmp");
95 let yaml = serde_yaml::to_string(pin).map_err(|e| anyhow::anyhow!("serialise pin: {e}"))?;
96 let header = "# .trusty-tools/trusty-memory.yaml\n\
97 # This file pins the trusty-memory palace slug for this project.\n\
98 # Commit it so the linkage survives directory renames and drive reorgs.\n\
99 # Schema: https://github.com/bobmatnyc/trusty-tools (trusty-tools convention)\n\n";
100 let content = format!("{header}{yaml}");
101 std::fs::write(&tmp_path, &content)
102 .map_err(|e| anyhow::anyhow!("write {}: {e}", tmp_path.display()))?;
103 std::fs::rename(&tmp_path, &pin_path).map_err(|e| {
104 anyhow::anyhow!(
105 "rename {} → {}: {e}",
106 tmp_path.display(),
107 pin_path.display()
108 )
109 })?;
110 Ok(pin_path)
111}
112
113/// Compute the palace slug purely from the directory basename (the pre-Phase-1
114/// logic, now extracted for composability).
115///
116/// Why: the resolution order in `project_slug_at` needs to call the basename
117/// derivation without triggering the pin-file read/write side effects. Exposing
118/// this as a separate function makes both paths testable in isolation.
119/// What: calls `slugify_string` on the last path component of `root`. Returns
120/// `None` when the basename is empty or slugifies to an empty string.
121/// Test: `project_slug_from_basename_basic`.
122pub fn project_slug_from_basename(root: &Path) -> Option<String> {
123 let basename = root.file_name()?.to_str()?;
124 let slug = slugify_string(basename);
125 if slug.is_empty() {
126 None
127 } else {
128 Some(slug)
129 }
130}
131
132/// Derive a palace slug from the project root found at or above `start`.
133///
134/// Why: the core of issue #88 with Phase-1 pin-file support. Palace names
135/// must match the canonical slug of the project they belong to, and that slug
136/// must survive directory renames. The pin file provides the stable anchor.
137/// What: implements the two-step resolution order:
138/// a. Walk up to the project root. If `.trusty-tools/trusty-memory.yaml`
139/// exists, return `pin.palace` (authoritative — survives renames).
140/// b. If absent, compute the slug via `project_slug_from_basename`, then
141/// lazily write the pin file (best-effort, non-fatal) so future calls
142/// always land on path (a).
143/// Returns `None` when no project root is found.
144/// Test: `pin_file_read_when_present`, `absent_pin_writes_computed_slug`,
145/// `renamed_dir_with_pin_resolves_to_original_slug`.
146pub fn project_slug_at(start: &Path) -> Option<String> {
147 let root = find_project_root(start)?;
148
149 // Step (a): check for a committed pin file.
150 match read_project_pin(&root) {
151 Ok(Some(pin)) => return Some(pin.palace),
152 Ok(None) => {} // absent — fall through to step (b)
153 Err(e) => {
154 // Corrupt or unreadable pin file: log to stderr and fall through
155 // to the basename derivation so memory operations are not blocked.
156 tracing::warn!(
157 path = %root.join(PIN_FILE_REL).display(),
158 "could not read palace pin file ({e:#}); falling back to basename slug"
159 );
160 }
161 }
162
163 // Step (b): compute from basename and lazily write the pin file.
164 // Guard: never write into a system temp dir, home dir, or filesystem root —
165 // those are unsafe pin locations that would poison every subdirectory.
166 // `is_unsafe_pin_location` was introduced in PR #492 for exactly this
167 // case; if the resolved root is unsafe we still return the derived slug
168 // (so memory operations work) but skip the write.
169 let slug = project_slug_from_basename(&root)?;
170 if is_unsafe_pin_location(&root) {
171 tracing::debug!(
172 slug = %slug,
173 root = %root.display(),
174 "skipping lazy pin write: root is a system/home/temp dir"
175 );
176 return Some(slug);
177 }
178 let pin = ProjectPin {
179 schema_version: PIN_SCHEMA_VERSION,
180 palace: slug.clone(),
181 note: None,
182 };
183 match write_project_pin(&root, &pin) {
184 Ok(path) => {
185 tracing::debug!(
186 slug = %slug,
187 path = %path.display(),
188 "wrote palace pin file (lazy init)"
189 );
190 }
191 Err(e) => {
192 // Read-only tree, insufficient permissions, etc. — non-fatal.
193 tracing::warn!(
194 slug = %slug,
195 root = %root.display(),
196 "could not write palace pin file ({e:#}); slug will remain basename-derived"
197 );
198 }
199 }
200 Some(slug)
201}
202
203/// Return the *pinned* palace slug for the project at or above `start`, and
204/// ONLY when a committed pin file exists — never the basename fallback.
205///
206/// Why: issue #1217 inserts git-`owner/repo` identity derivation between the
207/// pin file (authoritative, rename-stable) and the directory-basename
208/// fallback. The default-palace resolver therefore needs to consult the pin
209/// file *in isolation* — if it used `project_slug_at_readonly` it would also
210/// receive the basename slug, which would shadow the new git derivation and
211/// reduce the change to a no-op inside any project directory. This helper
212/// returns `Some` strictly when a pin exists, so callers can honour the pin
213/// first and fall through to identity derivation when absent.
214/// What: walks up to the project root, reads `.trusty-tools/trusty-memory.yaml`
215/// via [`read_project_pin`], and returns `Some(pin.palace)` when present.
216/// Returns `None` when no project root is found OR no pin file exists OR the
217/// pin file is unreadable (logged, non-fatal — never panics, never writes).
218/// Test: `pinned_slug_at_returns_pin_when_present`,
219/// `pinned_slug_at_returns_none_without_pin`.
220pub fn pinned_slug_at(start: &Path) -> Option<String> {
221 let root = find_project_root(start)?;
222 match read_project_pin(&root) {
223 Ok(Some(pin)) if !pin.palace.is_empty() => Some(pin.palace),
224 Ok(_) => None,
225 Err(e) => {
226 tracing::warn!(
227 path = %root.join(PIN_FILE_REL).display(),
228 "could not read palace pin file ({e:#}); ignoring pin and falling through"
229 );
230 None
231 }
232 }
233}
234
235/// Derive a palace slug from the project root found at or above `start`,
236/// WITHOUT the lazy-write side-effect.
237///
238/// Why: the `prompt-context` hook runs in read-only or short-lived contexts
239/// where creating `.trusty-tools/trusty-memory.yaml` would be surprising and
240/// potentially disruptive. The slug is still resolved via the pin-file when
241/// one already exists (step a), and falls back to the basename slug (step b)
242/// without ever writing a new file. This makes `cwd_palace_slug_at` safe to
243/// call unconditionally from hooks. The writing variant (`project_slug_at`)
244/// remains the right choice for interactive commands (`trusty-memory link`,
245/// `trusty-memory remember`) that want to stabilise the slug.
246/// What: same two-step resolution as `project_slug_at` but step (b) only
247/// computes and returns the basename slug — it does NOT write the pin file.
248/// Returns `None` when no project root is found.
249/// Test: `project_slug_at_readonly_no_write_when_absent`,
250/// `project_slug_at_readonly_reads_existing_pin`,
251/// `project_slug_at_readonly_falls_back_to_basename`.
252pub fn project_slug_at_readonly(start: &Path) -> Option<String> {
253 let root = find_project_root(start)?;
254
255 // Step (a): if a pin file exists, use it authoritatively.
256 match read_project_pin(&root) {
257 Ok(Some(pin)) => return Some(pin.palace),
258 Ok(None) => {} // absent — fall through to step (b)
259 Err(e) => {
260 // Corrupt or unreadable pin file: log to stderr and fall through
261 // so the hook is not blocked.
262 tracing::warn!(
263 path = %root.join(PIN_FILE_REL).display(),
264 "could not read palace pin file ({e:#}); falling back to basename slug (read-only)"
265 );
266 }
267 }
268
269 // Step (b): compute from basename — but do NOT write a pin file.
270 project_slug_from_basename(&root)
271}
272
273/// Derive a palace slug for the current working directory.
274///
275/// Why: convenience wrapper over `project_slug_at` for callers that want
276/// the "natural" project slug (CLI commands, MCP handlers, tests running
277/// inside a repo).
278/// What: calls `std::env::current_dir()`, propagates the error if the syscall
279/// fails, then delegates to [`project_slug_at`].
280/// Test: `project_slug_finds_git_root` (run from inside the trusty-tools repo
281/// which is a git checkout).
282pub fn project_slug() -> Result<Option<String>> {
283 let cwd = std::env::current_dir().map_err(|e| anyhow::anyhow!("read cwd: {e}"))?;
284 Ok(project_slug_at(&cwd))
285}