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> {
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
66pub fn user_shadow_git_dir(workspace: &Path) -> Result<PathBuf> {
69 let dir = user_workspace_dir(workspace)?.join("shadow-git");
70 Ok(dir)
71}
72
73pub fn user_scratchpad_path(workspace: &Path) -> Result<PathBuf> {
75 Ok(user_workspace_dir(workspace)?.join("scratchpad.json"))
76}
77
78pub 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 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
106pub 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 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 assert!(legacy_paths_in_workspace(workspace.path()).is_empty());
193 }
194}