Skip to main content

recursive/
migrate.rs

1//! Migrate legacy in-tree state to the per-user data dir.
2//!
3//! Pre-g142, sessions / shadow-git / scratchpad lived under
4//! `<workspace>/.recursive/`. This module moves any such state to
5//! `~/.recursive/workspaces/<ws-hash>/...` and is invoked by the
6//! `recursive migrate` CLI command.
7//!
8//! Project-bundled assets (`skills/`, `mcp.json`) are left in place.
9
10use std::path::{Path, PathBuf};
11
12use crate::error::{Error, Result};
13use crate::paths;
14
15/// Outcome of a migration attempt.
16#[derive(Debug, Default, Clone)]
17pub struct MigrateReport {
18    /// Items that were moved (legacy_path, new_path).
19    pub moved: Vec<(PathBuf, PathBuf)>,
20    /// Items skipped because the destination already had data
21    /// (legacy_path, destination).
22    pub skipped: Vec<(PathBuf, PathBuf)>,
23    /// True if the workspace had no legacy state at all.
24    pub already_clean: bool,
25    /// True if `<workspace>/.recursive/` was empty after the migration
26    /// and was therefore removed.
27    pub removed_empty_dotrecursive: bool,
28}
29
30/// Plan + execute the migration.
31///
32/// `dry_run = true` returns a report describing what would happen
33/// without touching the filesystem.
34pub 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        // Try `rename` first (cheap, atomic on same FS). Fall back to
66        // copy + remove if the user's home is on a different mount.
67        if let Err(e) = std::fs::rename(&src, &dst) {
68            // EXDEV (cross-device link) → fall back to copy + remove.
69            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    // Try to remove `<workspace>/.recursive/` if it's now empty
92    // (skills/ and mcp.json being there will keep us out).
93    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
107/// macOS / Linux EXDEV constant. Avoids pulling in `libc` for one number.
108fn 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            // Old paths gone
165            assert!(!dotrec.join("sessions").exists());
166            assert!(!dotrec.join("shadow-git").exists());
167            assert!(!dotrec.join("scratchpad.json").exists());
168
169            // New paths in place
170            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            // skills/ and mcp.json untouched
189            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            // Pre-create the destination so migrate sees a collision.
202            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            // Source untouched.
209            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            // Source still there
224            assert!(dotrec.join("sessions").exists());
225            // Dest not created
226            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        // kills `replace libc_exdev -> i32 with 0` and literal mutations
247        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        // kills `is_dir()` → always-true and function-level replacement mutations
257        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        // kills mutations in the `is_dir()` branch that handle recursive copies
272        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}