1use std::path::{Path, PathBuf};
17
18use crate::error::{Error, Result};
19
20pub 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 PathBuf::from(".recursive")
33}
34
35pub 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 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
57pub 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
73pub fn user_shadow_git_dir(workspace: &Path) -> Result<PathBuf> {
76 let dir = user_workspace_dir(workspace)?.join("shadow-git");
77 Ok(dir)
78}
79
80pub fn user_scratchpad_path(workspace: &Path) -> Result<PathBuf> {
82 Ok(user_workspace_dir(workspace)?.join("scratchpad.json"))
83}
84
85pub 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 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
113pub 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 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 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 assert!(legacy_paths_in_workspace(workspace.path()).is_empty());
222 }
223
224 #[test]
227 fn user_sessions_dir_creates_dir_when_absent() {
228 let home = tempfile::tempdir().unwrap();
230 let _g = PinnedRecursiveHome::new(home.path());
231 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 #[test]
255 fn user_shadow_git_dir_ends_with_shadow_git() {
256 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 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 #[test]
283 fn workspace_hash_is_non_empty_and_12_chars() {
284 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}