Skip to main content

recursive/
paths.rs

1//! Centralised path resolution for per-user and per-workspace state.
2//!
3//! Recursive separates state into three buckets:
4//!
5//! - **Per-user** (`~/.recursive/...`): config, global memory, facts.
6//! - **Per-user, per-workspace** (`~/.recursive/workspaces/<hash>/...`):
7//!   sessions, shadow-git checkpoints, scratchpad. These are the
8//!   files this module owns.
9//! - **Project-bundled** (`<workspace>/.recursive/...`): `skills/`,
10//!   `mcp.json`. These ship with the project; this module never
11//!   touches them.
12//!
13//! Tests can redirect the per-user root by setting
14//! `RECURSIVE_HOME=<dir>`.
15
16use std::path::{Path, PathBuf};
17
18use crate::error::{Error, Result};
19
20/// Per-user data root. Honors `RECURSIVE_HOME` for tests, otherwise
21/// `$HOME/.recursive`.
22pub fn user_data_dir() -> PathBuf {
23    if let Some(custom) = std::env::var_os("RECURSIVE_HOME") {
24        return PathBuf::from(custom);
25    }
26    if let Some(home) = dirs::home_dir() {
27        return home.join(".recursive");
28    }
29    // Last resort: relative path. Should not happen on supported
30    // platforms; using the cwd would silently regress to the old
31    // behavior we're trying to escape.
32    PathBuf::from(".recursive")
33}
34
35/// Per-user, per-workspace data dir.
36///
37/// Resolves to `<user_data_dir>/workspaces/<ws-hash>/`, creating it
38/// on first call and writing a `path.txt` marker so a human can map
39/// the hash back to the original workspace path.
40pub fn user_workspace_dir(workspace: &Path) -> Result<PathBuf> {
41    let abs = canonicalize_workspace(workspace)?;
42    let hash = workspace_hash_from_canonical(&abs);
43    let dir = user_data_dir().join("workspaces").join(&hash);
44    if !dir.exists() {
45        std::fs::create_dir_all(&dir).map_err(Error::Io)?;
46        // Write path marker — best-effort; failing here would be
47        // weird given we just created the directory, but we don't
48        // want a marker bug to brick the run.
49        let marker = dir.join("path.txt");
50        if !marker.exists() {
51            let _ = std::fs::write(&marker, abs.display().to_string());
52        }
53    }
54    Ok(dir)
55}
56
57/// `<user_workspace_dir>/sessions/`. Honors `RECURSIVE_SESSIONS_DIR`
58/// as a hard override (Goal-H J1) — useful for tests and
59/// integrations that need to know exactly where the binary writes
60/// a session for a given workspace, without going through the
61/// user-data + workspace-hash layout.
62pub fn user_sessions_dir(workspace: &Path) -> Result<PathBuf> {
63    if let Some(custom) = std::env::var_os("RECURSIVE_SESSIONS_DIR") {
64        return Ok(PathBuf::from(custom));
65    }
66    let dir = user_workspace_dir(workspace)?.join("sessions");
67    if !dir.exists() {
68        std::fs::create_dir_all(&dir).map_err(Error::Io)?;
69    }
70    Ok(dir)
71}
72
73/// `<user_workspace_dir>/shadow-git/` (parent only — caller is
74/// responsible for `git init --bare`).
75pub fn user_shadow_git_dir(workspace: &Path) -> Result<PathBuf> {
76    let dir = user_workspace_dir(workspace)?.join("shadow-git");
77    Ok(dir)
78}
79
80/// `<user_workspace_dir>/scratchpad.json`.
81pub fn user_scratchpad_path(workspace: &Path) -> Result<PathBuf> {
82    Ok(user_workspace_dir(workspace)?.join("scratchpad.json"))
83}
84
85/// 12-char workspace hash. Stable across calls for the same canonical
86/// path. Public for diagnostics.
87pub fn workspace_hash(workspace: &Path) -> String {
88    let abs = canonicalize_workspace(workspace).unwrap_or_else(|_| workspace.to_path_buf());
89    workspace_hash_from_canonical(&abs)
90}
91
92fn workspace_hash_from_canonical(abs: &Path) -> String {
93    let bytes = abs.as_os_str().to_string_lossy();
94    let hash = blake3::hash(bytes.as_bytes());
95    hash.to_hex().chars().take(12).collect()
96}
97
98fn canonicalize_workspace(workspace: &Path) -> Result<PathBuf> {
99    // Avoid failing when the workspace path is not yet canonicalisable
100    // (e.g. brand-new dir resolved to "."). Fall back to absolutising
101    // via cwd.
102    if let Ok(abs) = workspace.canonicalize() {
103        return Ok(abs);
104    }
105    let cwd = std::env::current_dir().map_err(Error::Io)?;
106    Ok(if workspace.is_absolute() {
107        workspace.to_path_buf()
108    } else {
109        cwd.join(workspace)
110    })
111}
112
113/// Detect any legacy in-tree state files that should now live under
114/// the user data dir. Returns the absolute paths that exist.
115///
116/// Used by the startup warning and by `recursive migrate`.
117pub fn legacy_paths_in_workspace(workspace: &Path) -> Vec<PathBuf> {
118    let root = workspace.join(".recursive");
119    if !root.exists() {
120        return vec![];
121    }
122    [
123        root.join("sessions"),
124        root.join("shadow-git"),
125        root.join("scratchpad.json"),
126    ]
127    .into_iter()
128    .filter(|p| p.exists())
129    .collect()
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use crate::test_util::PinnedRecursiveHome;
136
137    #[test]
138    fn user_data_dir_honors_env_override() {
139        let _g = PinnedRecursiveHome::new("/tmp/recursive-test-fixed");
140        assert_eq!(user_data_dir(), PathBuf::from("/tmp/recursive-test-fixed"));
141    }
142
143    #[test]
144    fn sessions_dir_honors_recursive_sessions_dir_override() {
145        // Goal-H J1: the e2e smoke-01 session assertion
146        // previously pointed at <workspace>/.recursive/sessions/
147        // while the binary wrote to
148        // <RECURSIVE_HOME>/workspaces/<hash>/sessions/. The fix
149        // is RECURSIVE_SESSIONS_DIR — a hard override that
150        // bypasses the user-data + workspace-hash layout. This
151        // test pins that the override is honored and that
152        // user_workspace_dir is **not** called (which would
153        // canonicalize the path and crash on the empty
154        // /tmp/recursive-test-fixed fixture).
155        let prev = std::env::var_os("RECURSIVE_SESSIONS_DIR");
156        std::env::set_var("RECURSIVE_SESSIONS_DIR", "/tmp/explicit-sessions");
157        let dir = user_sessions_dir(Path::new("/tmp/recursive-test-fixed")).unwrap();
158        assert_eq!(dir, PathBuf::from("/tmp/explicit-sessions"));
159        match prev {
160            Some(v) => std::env::set_var("RECURSIVE_SESSIONS_DIR", v),
161            None => std::env::remove_var("RECURSIVE_SESSIONS_DIR"),
162        }
163    }
164
165    #[test]
166    fn workspace_hash_is_stable_across_calls() {
167        let p = Path::new("/tmp/some/path");
168        assert_eq!(workspace_hash(p), workspace_hash(p));
169        assert_eq!(workspace_hash(p).len(), 12);
170    }
171
172    #[test]
173    fn workspace_hash_differs_for_different_paths() {
174        let a = workspace_hash(Path::new("/tmp/a"));
175        let b = workspace_hash(Path::new("/tmp/b"));
176        assert_ne!(a, b);
177    }
178
179    #[test]
180    fn user_workspace_dir_writes_path_txt() {
181        let home = tempfile::tempdir().unwrap();
182        let _g = PinnedRecursiveHome::new(home.path());
183
184        let workspace = tempfile::tempdir().unwrap();
185        let ws_dir = user_workspace_dir(workspace.path()).unwrap();
186        assert!(ws_dir.exists());
187        let marker = ws_dir.join("path.txt");
188        assert!(marker.exists());
189        let contents = std::fs::read_to_string(&marker).unwrap();
190        // Marker should resolve to the canonical workspace path.
191        let abs = workspace.path().canonicalize().unwrap();
192        assert_eq!(contents, abs.display().to_string());
193    }
194
195    #[test]
196    fn legacy_paths_detects_in_tree_state() {
197        let workspace = tempfile::tempdir().unwrap();
198        let dotrec = workspace.path().join(".recursive");
199        std::fs::create_dir_all(dotrec.join("sessions")).unwrap();
200        std::fs::create_dir_all(dotrec.join("shadow-git")).unwrap();
201        std::fs::write(dotrec.join("scratchpad.json"), "{}").unwrap();
202
203        let found = legacy_paths_in_workspace(workspace.path());
204        assert_eq!(found.len(), 3, "expected all 3 legacy paths, got {found:?}");
205    }
206
207    #[test]
208    fn legacy_paths_returns_empty_when_clean() {
209        let workspace = tempfile::tempdir().unwrap();
210        assert!(legacy_paths_in_workspace(workspace.path()).is_empty());
211    }
212
213    #[test]
214    fn legacy_paths_does_not_flag_skills_or_mcp_json() {
215        let workspace = tempfile::tempdir().unwrap();
216        let dotrec = workspace.path().join(".recursive");
217        std::fs::create_dir_all(dotrec.join("skills")).unwrap();
218        std::fs::write(dotrec.join("mcp.json"), "{}").unwrap();
219        // No sessions/, shadow-git/, or scratchpad.json → still clean
220        // from the migrator's perspective.
221        assert!(legacy_paths_in_workspace(workspace.path()).is_empty());
222    }
223
224    // ── user_sessions_dir creates dir even when it doesn't exist ─────────────
225
226    #[test]
227    fn user_sessions_dir_creates_dir_when_absent() {
228        // kills `delete ! in user_sessions_dir` line 67
229        let home = tempfile::tempdir().unwrap();
230        let _g = PinnedRecursiveHome::new(home.path());
231        // Ensure RECURSIVE_SESSIONS_DIR is not set
232        let prev = std::env::var_os("RECURSIVE_SESSIONS_DIR");
233        std::env::remove_var("RECURSIVE_SESSIONS_DIR");
234
235        let workspace = tempfile::tempdir().unwrap();
236        let sessions = user_sessions_dir(workspace.path()).unwrap();
237        assert!(
238            sessions.exists(),
239            "user_sessions_dir must create the directory if absent, got: {sessions:?}"
240        );
241        assert!(
242            sessions.ends_with("sessions"),
243            "path must end with 'sessions', got: {sessions:?}"
244        );
245
246        match prev {
247            Some(v) => std::env::set_var("RECURSIVE_SESSIONS_DIR", v),
248            None => std::env::remove_var("RECURSIVE_SESSIONS_DIR"),
249        }
250    }
251
252    // ── user_shadow_git_dir / user_scratchpad_path path components ───────────
253
254    #[test]
255    fn user_shadow_git_dir_ends_with_shadow_git() {
256        // kills `replace user_shadow_git_dir -> Result<PathBuf> with Ok(Default::default())`
257        let home = tempfile::tempdir().unwrap();
258        let _g = PinnedRecursiveHome::new(home.path());
259        let workspace = tempfile::tempdir().unwrap();
260        let p = user_shadow_git_dir(workspace.path()).unwrap();
261        assert!(
262            p.ends_with("shadow-git"),
263            "user_shadow_git_dir must end with 'shadow-git', got: {p:?}"
264        );
265    }
266
267    #[test]
268    fn user_scratchpad_path_ends_with_scratchpad_json() {
269        // kills `replace user_scratchpad_path -> Result<PathBuf> with Ok(Default::default())`
270        let home = tempfile::tempdir().unwrap();
271        let _g = PinnedRecursiveHome::new(home.path());
272        let workspace = tempfile::tempdir().unwrap();
273        let p = user_scratchpad_path(workspace.path()).unwrap();
274        assert!(
275            p.ends_with("scratchpad.json"),
276            "user_scratchpad_path must end with 'scratchpad.json', got: {p:?}"
277        );
278    }
279
280    // ── workspace_hash properties ─────────────────────────────────────────────
281
282    #[test]
283    fn workspace_hash_is_non_empty_and_12_chars() {
284        // kills `replace workspace_hash_from_canonical -> String with String::new()`
285        let h = workspace_hash(Path::new("/tmp/workspace-hash-test"));
286        assert_eq!(h.len(), 12, "hash must be exactly 12 chars");
287        assert!(!h.is_empty(), "hash must not be empty");
288        assert_ne!(h, "xyzzy", "hash must not be placeholder");
289    }
290}