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 message: format!("legacy path has no file name: {}", src.display()),
50 })?
51 .to_owned();
52 let dst = target_dir.join(&name);
53
54 if dst.exists() {
55 report.skipped.push((src, dst));
56 continue;
57 }
58
59 if dry_run {
60 report.moved.push((src, dst));
61 continue;
62 }
63
64 if let Err(e) = std::fs::rename(&src, &dst) {
67 if e.raw_os_error() == Some(libc_exdev()) {
69 copy_recursively(&src, &dst).map_err(|e| Error::Tool {
70 name: "migrate".into(),
71 message: format!("copy across mounts failed for {}: {e}", src.display()),
72 })?;
73 if src.is_dir() {
74 std::fs::remove_dir_all(&src).map_err(Error::Io)?;
75 } else {
76 std::fs::remove_file(&src).map_err(Error::Io)?;
77 }
78 } else {
79 return Err(Error::Tool {
80 name: "migrate".into(),
81 message: format!("rename {} -> {} failed: {e}", src.display(), dst.display()),
82 });
83 }
84 }
85 report.moved.push((src, dst));
86 }
87
88 if !dry_run {
91 let dotrec = workspace.join(".recursive");
92 if dotrec.is_dir() {
93 if let Ok(mut iter) = std::fs::read_dir(&dotrec) {
94 if iter.next().is_none() && std::fs::remove_dir(&dotrec).is_ok() {
95 report.removed_empty_dotrecursive = true;
96 }
97 }
98 }
99 }
100
101 Ok(report)
102}
103
104fn libc_exdev() -> i32 {
106 18
107}
108
109fn copy_recursively(src: &Path, dst: &Path) -> std::io::Result<()> {
110 if src.is_dir() {
111 std::fs::create_dir_all(dst)?;
112 for entry in std::fs::read_dir(src)? {
113 let entry = entry?;
114 copy_recursively(&entry.path(), &dst.join(entry.file_name()))?;
115 }
116 Ok(())
117 } else {
118 if let Some(parent) = dst.parent() {
119 std::fs::create_dir_all(parent)?;
120 }
121 std::fs::copy(src, dst).map(|_| ())
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128 use crate::test_util::PinnedRecursiveHome;
129
130 fn with_home<F: FnOnce()>(f: F) {
131 let home = tempfile::tempdir().unwrap();
132 let _g = PinnedRecursiveHome::new(home.path());
133 f();
134 }
135
136 #[test]
137 fn migrate_clean_workspace_is_noop() {
138 with_home(|| {
139 let ws = tempfile::tempdir().unwrap();
140 let r = migrate_workspace(ws.path(), false).unwrap();
141 assert!(r.already_clean);
142 assert!(r.moved.is_empty());
143 });
144 }
145
146 #[test]
147 fn migrate_moves_sessions_and_shadow_git() {
148 with_home(|| {
149 let ws = tempfile::tempdir().unwrap();
150 let dotrec = ws.path().join(".recursive");
151 std::fs::create_dir_all(dotrec.join("sessions/foo")).unwrap();
152 std::fs::write(dotrec.join("sessions/foo/.meta.json"), "{}").unwrap();
153 std::fs::create_dir_all(dotrec.join("shadow-git")).unwrap();
154 std::fs::write(dotrec.join("shadow-git/HEAD"), "ref: refs/heads/master\n").unwrap();
155 std::fs::write(dotrec.join("scratchpad.json"), r#"{"entries":[]}"#).unwrap();
156
157 let r = migrate_workspace(ws.path(), false).unwrap();
158 assert!(!r.already_clean);
159 assert_eq!(r.moved.len(), 3);
160
161 assert!(!dotrec.join("sessions").exists());
163 assert!(!dotrec.join("shadow-git").exists());
164 assert!(!dotrec.join("scratchpad.json").exists());
165
166 let target = paths::user_workspace_dir(ws.path()).unwrap();
168 assert!(target.join("sessions").is_dir());
169 assert!(target.join("shadow-git").is_dir());
170 assert!(target.join("scratchpad.json").is_file());
171 });
172 }
173
174 #[test]
175 fn migrate_skips_skills_and_mcp_json() {
176 with_home(|| {
177 let ws = tempfile::tempdir().unwrap();
178 let dotrec = ws.path().join(".recursive");
179 std::fs::create_dir_all(dotrec.join("sessions")).unwrap();
180 std::fs::create_dir_all(dotrec.join("skills")).unwrap();
181 std::fs::write(dotrec.join("mcp.json"), "{}").unwrap();
182
183 let r = migrate_workspace(ws.path(), false).unwrap();
184 assert_eq!(r.moved.len(), 1);
185 assert!(dotrec.join("skills").is_dir());
187 assert!(dotrec.join("mcp.json").is_file());
188 });
189 }
190
191 #[test]
192 fn migrate_aborts_on_destination_collision() {
193 with_home(|| {
194 let ws = tempfile::tempdir().unwrap();
195 let dotrec = ws.path().join(".recursive");
196 std::fs::create_dir_all(dotrec.join("sessions")).unwrap();
197
198 let target = paths::user_workspace_dir(ws.path()).unwrap();
200 std::fs::create_dir_all(target.join("sessions")).unwrap();
201
202 let r = migrate_workspace(ws.path(), false).unwrap();
203 assert_eq!(r.moved.len(), 0);
204 assert_eq!(r.skipped.len(), 1);
205 assert!(dotrec.join("sessions").exists());
207 });
208 }
209
210 #[test]
211 fn migrate_dry_run_does_not_mutate() {
212 with_home(|| {
213 let ws = tempfile::tempdir().unwrap();
214 let dotrec = ws.path().join(".recursive");
215 std::fs::create_dir_all(dotrec.join("sessions")).unwrap();
216
217 let r = migrate_workspace(ws.path(), true).unwrap();
218 assert_eq!(r.moved.len(), 1);
219
220 assert!(dotrec.join("sessions").exists());
222 let target = paths::user_workspace_dir(ws.path()).unwrap();
224 assert!(!target.join("sessions").exists());
225 });
226 }
227
228 #[test]
229 fn migrate_removes_empty_dotrecursive_when_only_byproducts_existed() {
230 with_home(|| {
231 let ws = tempfile::tempdir().unwrap();
232 let dotrec = ws.path().join(".recursive");
233 std::fs::create_dir_all(dotrec.join("sessions")).unwrap();
234
235 let r = migrate_workspace(ws.path(), false).unwrap();
236 assert!(r.removed_empty_dotrecursive);
237 assert!(!dotrec.exists());
238 });
239 }
240}