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                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        // Try `rename` first (cheap, atomic on same FS). Fall back to
65        // copy + remove if the user's home is on a different mount.
66        if let Err(e) = std::fs::rename(&src, &dst) {
67            // EXDEV (cross-device link) → fall back to copy + remove.
68            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    // Try to remove `<workspace>/.recursive/` if it's now empty
89    // (skills/ and mcp.json being there will keep us out).
90    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
104/// macOS / Linux EXDEV constant. Avoids pulling in `libc` for one number.
105fn 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            // Old paths gone
162            assert!(!dotrec.join("sessions").exists());
163            assert!(!dotrec.join("shadow-git").exists());
164            assert!(!dotrec.join("scratchpad.json").exists());
165
166            // New paths in place
167            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            // skills/ and mcp.json untouched
186            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            // Pre-create the destination so migrate sees a collision.
199            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            // Source untouched.
206            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            // Source still there
221            assert!(dotrec.join("sessions").exists());
222            // Dest not created
223            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}