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/`.
58pub fn user_sessions_dir(workspace: &Path) -> Result<PathBuf> {
59    let dir = user_workspace_dir(workspace)?.join("sessions");
60    if !dir.exists() {
61        std::fs::create_dir_all(&dir).map_err(Error::Io)?;
62    }
63    Ok(dir)
64}
65
66/// `<user_workspace_dir>/shadow-git/` (parent only — caller is
67/// responsible for `git init --bare`).
68pub fn user_shadow_git_dir(workspace: &Path) -> Result<PathBuf> {
69    let dir = user_workspace_dir(workspace)?.join("shadow-git");
70    Ok(dir)
71}
72
73/// `<user_workspace_dir>/scratchpad.json`.
74pub fn user_scratchpad_path(workspace: &Path) -> Result<PathBuf> {
75    Ok(user_workspace_dir(workspace)?.join("scratchpad.json"))
76}
77
78/// 12-char workspace hash. Stable across calls for the same canonical
79/// path. Public for diagnostics.
80pub fn workspace_hash(workspace: &Path) -> String {
81    let abs = canonicalize_workspace(workspace).unwrap_or_else(|_| workspace.to_path_buf());
82    workspace_hash_from_canonical(&abs)
83}
84
85fn workspace_hash_from_canonical(abs: &Path) -> String {
86    let bytes = abs.as_os_str().to_string_lossy();
87    let hash = blake3::hash(bytes.as_bytes());
88    hash.to_hex().chars().take(12).collect()
89}
90
91fn canonicalize_workspace(workspace: &Path) -> Result<PathBuf> {
92    // Avoid failing when the workspace path is not yet canonicalisable
93    // (e.g. brand-new dir resolved to "."). Fall back to absolutising
94    // via cwd.
95    if let Ok(abs) = workspace.canonicalize() {
96        return Ok(abs);
97    }
98    let cwd = std::env::current_dir().map_err(Error::Io)?;
99    Ok(if workspace.is_absolute() {
100        workspace.to_path_buf()
101    } else {
102        cwd.join(workspace)
103    })
104}
105
106/// Detect any legacy in-tree state files that should now live under
107/// the user data dir. Returns the absolute paths that exist.
108///
109/// Used by the startup warning and by `recursive migrate`.
110pub fn legacy_paths_in_workspace(workspace: &Path) -> Vec<PathBuf> {
111    let root = workspace.join(".recursive");
112    if !root.exists() {
113        return vec![];
114    }
115    [
116        root.join("sessions"),
117        root.join("shadow-git"),
118        root.join("scratchpad.json"),
119    ]
120    .into_iter()
121    .filter(|p| p.exists())
122    .collect()
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::test_util::PinnedRecursiveHome;
129
130    #[test]
131    fn user_data_dir_honors_env_override() {
132        let _g = PinnedRecursiveHome::new("/tmp/recursive-test-fixed");
133        assert_eq!(user_data_dir(), PathBuf::from("/tmp/recursive-test-fixed"));
134    }
135
136    #[test]
137    fn workspace_hash_is_stable_across_calls() {
138        let p = Path::new("/tmp/some/path");
139        assert_eq!(workspace_hash(p), workspace_hash(p));
140        assert_eq!(workspace_hash(p).len(), 12);
141    }
142
143    #[test]
144    fn workspace_hash_differs_for_different_paths() {
145        let a = workspace_hash(Path::new("/tmp/a"));
146        let b = workspace_hash(Path::new("/tmp/b"));
147        assert_ne!(a, b);
148    }
149
150    #[test]
151    fn user_workspace_dir_writes_path_txt() {
152        let home = tempfile::tempdir().unwrap();
153        let _g = PinnedRecursiveHome::new(home.path());
154
155        let workspace = tempfile::tempdir().unwrap();
156        let ws_dir = user_workspace_dir(workspace.path()).unwrap();
157        assert!(ws_dir.exists());
158        let marker = ws_dir.join("path.txt");
159        assert!(marker.exists());
160        let contents = std::fs::read_to_string(&marker).unwrap();
161        // Marker should resolve to the canonical workspace path.
162        let abs = workspace.path().canonicalize().unwrap();
163        assert_eq!(contents, abs.display().to_string());
164    }
165
166    #[test]
167    fn legacy_paths_detects_in_tree_state() {
168        let workspace = tempfile::tempdir().unwrap();
169        let dotrec = workspace.path().join(".recursive");
170        std::fs::create_dir_all(dotrec.join("sessions")).unwrap();
171        std::fs::create_dir_all(dotrec.join("shadow-git")).unwrap();
172        std::fs::write(dotrec.join("scratchpad.json"), "{}").unwrap();
173
174        let found = legacy_paths_in_workspace(workspace.path());
175        assert_eq!(found.len(), 3, "expected all 3 legacy paths, got {found:?}");
176    }
177
178    #[test]
179    fn legacy_paths_returns_empty_when_clean() {
180        let workspace = tempfile::tempdir().unwrap();
181        assert!(legacy_paths_in_workspace(workspace.path()).is_empty());
182    }
183
184    #[test]
185    fn legacy_paths_does_not_flag_skills_or_mcp_json() {
186        let workspace = tempfile::tempdir().unwrap();
187        let dotrec = workspace.path().join(".recursive");
188        std::fs::create_dir_all(dotrec.join("skills")).unwrap();
189        std::fs::write(dotrec.join("mcp.json"), "{}").unwrap();
190        // No sessions/, shadow-git/, or scratchpad.json → still clean
191        // from the migrator's perspective.
192        assert!(legacy_paths_in_workspace(workspace.path()).is_empty());
193    }
194}