1use std::path::{Path, PathBuf};
11
12use crate::error::{Error, Result};
13use crate::paths;
14
15#[derive(Debug, Default, Clone)]
17pub struct MigrateReport {
18 pub moved: Vec<(PathBuf, PathBuf)>,
20 pub skipped: Vec<(PathBuf, PathBuf)>,
23 pub already_clean: bool,
25 pub removed_empty_dotrecursive: bool,
28}
29
30pub fn migrate_workspace(workspace: &Path, dry_run: bool) -> Result<MigrateReport> {
35 let mut report = MigrateReport::default();
36 let legacy = paths::legacy_paths_in_workspace(workspace);
37 if legacy.is_empty() {
38 report.already_clean = true;
39 return Ok(report);
40 }
41
42 let target_dir = paths::user_workspace_dir(workspace)?;
43
44 for src in legacy {
45 let name = src
46 .file_name()
47 .ok_or_else(|| Error::Tool {
48 name: "migrate".into(),
49 call_id: None,
50 message: format!("legacy path has no file name: {}", src.display()),
51 })?
52 .to_owned();
53 let dst = target_dir.join(&name);
54
55 if dst.exists() {
56 report.skipped.push((src, dst));
57 continue;
58 }
59
60 if dry_run {
61 report.moved.push((src, dst));
62 continue;
63 }
64
65 if let Err(e) = std::fs::rename(&src, &dst) {
68 if e.raw_os_error() == Some(libc_exdev()) {
70 copy_recursively(&src, &dst).map_err(|e| Error::Tool {
71 name: "migrate".into(),
72 call_id: None,
73 message: format!("copy across mounts failed for {}: {e}", src.display()),
74 })?;
75 if src.is_dir() {
76 std::fs::remove_dir_all(&src).map_err(Error::Io)?;
77 } else {
78 std::fs::remove_file(&src).map_err(Error::Io)?;
79 }
80 } else {
81 return Err(Error::Tool {
82 name: "migrate".into(),
83 call_id: None,
84 message: format!("rename {} -> {} failed: {e}", src.display(), dst.display()),
85 });
86 }
87 }
88 report.moved.push((src, dst));
89 }
90
91 if !dry_run {
94 let dotrec = workspace.join(".recursive");
95 if dotrec.is_dir() {
96 if let Ok(mut iter) = std::fs::read_dir(&dotrec) {
97 if iter.next().is_none() && std::fs::remove_dir(&dotrec).is_ok() {
98 report.removed_empty_dotrecursive = true;
99 }
100 }
101 }
102 }
103
104 Ok(report)
105}
106
107fn libc_exdev() -> i32 {
109 18
110}
111
112fn copy_recursively(src: &Path, dst: &Path) -> std::io::Result<()> {
113 if src.is_dir() {
114 std::fs::create_dir_all(dst)?;
115 for entry in std::fs::read_dir(src)? {
116 let entry = entry?;
117 copy_recursively(&entry.path(), &dst.join(entry.file_name()))?;
118 }
119 Ok(())
120 } else {
121 if let Some(parent) = dst.parent() {
122 std::fs::create_dir_all(parent)?;
123 }
124 std::fs::copy(src, dst).map(|_| ())
125 }
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use crate::test_util::PinnedRecursiveHome;
132
133 fn with_home<F: FnOnce()>(f: F) {
134 let home = tempfile::tempdir().unwrap();
135 let _g = PinnedRecursiveHome::new(home.path());
136 f();
137 }
138
139 #[test]
140 fn migrate_clean_workspace_is_noop() {
141 with_home(|| {
142 let ws = tempfile::tempdir().unwrap();
143 let r = migrate_workspace(ws.path(), false).unwrap();
144 assert!(r.already_clean);
145 assert!(r.moved.is_empty());
146 });
147 }
148
149 #[test]
150 fn migrate_moves_sessions_and_shadow_git() {
151 with_home(|| {
152 let ws = tempfile::tempdir().unwrap();
153 let dotrec = ws.path().join(".recursive");
154 std::fs::create_dir_all(dotrec.join("sessions/foo")).unwrap();
155 std::fs::write(dotrec.join("sessions/foo/.meta.json"), "{}").unwrap();
156 std::fs::create_dir_all(dotrec.join("shadow-git")).unwrap();
157 std::fs::write(dotrec.join("shadow-git/HEAD"), "ref: refs/heads/master\n").unwrap();
158 std::fs::write(dotrec.join("scratchpad.json"), r#"{"entries":[]}"#).unwrap();
159
160 let r = migrate_workspace(ws.path(), false).unwrap();
161 assert!(!r.already_clean);
162 assert_eq!(r.moved.len(), 3);
163
164 assert!(!dotrec.join("sessions").exists());
166 assert!(!dotrec.join("shadow-git").exists());
167 assert!(!dotrec.join("scratchpad.json").exists());
168
169 let target = paths::user_workspace_dir(ws.path()).unwrap();
171 assert!(target.join("sessions").is_dir());
172 assert!(target.join("shadow-git").is_dir());
173 assert!(target.join("scratchpad.json").is_file());
174 });
175 }
176
177 #[test]
178 fn migrate_skips_skills_and_mcp_json() {
179 with_home(|| {
180 let ws = tempfile::tempdir().unwrap();
181 let dotrec = ws.path().join(".recursive");
182 std::fs::create_dir_all(dotrec.join("sessions")).unwrap();
183 std::fs::create_dir_all(dotrec.join("skills")).unwrap();
184 std::fs::write(dotrec.join("mcp.json"), "{}").unwrap();
185
186 let r = migrate_workspace(ws.path(), false).unwrap();
187 assert_eq!(r.moved.len(), 1);
188 assert!(dotrec.join("skills").is_dir());
190 assert!(dotrec.join("mcp.json").is_file());
191 });
192 }
193
194 #[test]
195 fn migrate_aborts_on_destination_collision() {
196 with_home(|| {
197 let ws = tempfile::tempdir().unwrap();
198 let dotrec = ws.path().join(".recursive");
199 std::fs::create_dir_all(dotrec.join("sessions")).unwrap();
200
201 let target = paths::user_workspace_dir(ws.path()).unwrap();
203 std::fs::create_dir_all(target.join("sessions")).unwrap();
204
205 let r = migrate_workspace(ws.path(), false).unwrap();
206 assert_eq!(r.moved.len(), 0);
207 assert_eq!(r.skipped.len(), 1);
208 assert!(dotrec.join("sessions").exists());
210 });
211 }
212
213 #[test]
214 fn migrate_dry_run_does_not_mutate() {
215 with_home(|| {
216 let ws = tempfile::tempdir().unwrap();
217 let dotrec = ws.path().join(".recursive");
218 std::fs::create_dir_all(dotrec.join("sessions")).unwrap();
219
220 let r = migrate_workspace(ws.path(), true).unwrap();
221 assert_eq!(r.moved.len(), 1);
222
223 assert!(dotrec.join("sessions").exists());
225 let target = paths::user_workspace_dir(ws.path()).unwrap();
227 assert!(!target.join("sessions").exists());
228 });
229 }
230
231 #[test]
232 fn migrate_removes_empty_dotrecursive_when_only_byproducts_existed() {
233 with_home(|| {
234 let ws = tempfile::tempdir().unwrap();
235 let dotrec = ws.path().join(".recursive");
236 std::fs::create_dir_all(dotrec.join("sessions")).unwrap();
237
238 let r = migrate_workspace(ws.path(), false).unwrap();
239 assert!(r.removed_empty_dotrecursive);
240 assert!(!dotrec.exists());
241 });
242 }
243
244 #[test]
245 fn libc_exdev_is_18() {
246 assert_eq!(
248 libc_exdev(),
249 18,
250 "EXDEV must be 18 (the Linux errno for cross-device link)"
251 );
252 }
253
254 #[test]
255 fn copy_recursively_copies_file_to_new_path() {
256 let src_dir = tempfile::TempDir::new().unwrap();
258 let dst_dir = tempfile::TempDir::new().unwrap();
259 let src_file = src_dir.path().join("hello.txt");
260 std::fs::write(&src_file, "hello").unwrap();
261
262 let dst_file = dst_dir.path().join("subdir").join("hello.txt");
263 copy_recursively(&src_file, &dst_file).unwrap();
264
265 let content = std::fs::read_to_string(&dst_file).unwrap();
266 assert_eq!(content, "hello");
267 }
268
269 #[test]
270 fn copy_recursively_copies_directory_tree() {
271 let src_dir = tempfile::TempDir::new().unwrap();
273 let dst_dir = tempfile::TempDir::new().unwrap();
274
275 let sub = src_dir.path().join("subdir");
276 std::fs::create_dir(&sub).unwrap();
277 std::fs::write(sub.join("a.txt"), "aaa").unwrap();
278 std::fs::write(src_dir.path().join("b.txt"), "bbb").unwrap();
279
280 let dst = dst_dir.path().join("dest");
281 copy_recursively(src_dir.path(), &dst).unwrap();
282
283 let a = std::fs::read_to_string(dst.join("subdir").join("a.txt")).unwrap();
284 let b = std::fs::read_to_string(dst.join("b.txt")).unwrap();
285 assert_eq!(a, "aaa");
286 assert_eq!(b, "bbb");
287 }
288}