use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
use crate::paths;
#[derive(Debug, Default, Clone)]
pub struct MigrateReport {
pub moved: Vec<(PathBuf, PathBuf)>,
pub skipped: Vec<(PathBuf, PathBuf)>,
pub already_clean: bool,
pub removed_empty_dotrecursive: bool,
}
pub fn migrate_workspace(workspace: &Path, dry_run: bool) -> Result<MigrateReport> {
let mut report = MigrateReport::default();
let legacy = paths::legacy_paths_in_workspace(workspace);
if legacy.is_empty() {
report.already_clean = true;
return Ok(report);
}
let target_dir = paths::user_workspace_dir(workspace)?;
for src in legacy {
let name = src
.file_name()
.ok_or_else(|| Error::Tool {
name: "migrate".into(),
message: format!("legacy path has no file name: {}", src.display()),
})?
.to_owned();
let dst = target_dir.join(&name);
if dst.exists() {
report.skipped.push((src, dst));
continue;
}
if dry_run {
report.moved.push((src, dst));
continue;
}
if let Err(e) = std::fs::rename(&src, &dst) {
if e.raw_os_error() == Some(libc_exdev()) {
copy_recursively(&src, &dst).map_err(|e| Error::Tool {
name: "migrate".into(),
message: format!("copy across mounts failed for {}: {e}", src.display()),
})?;
if src.is_dir() {
std::fs::remove_dir_all(&src).map_err(Error::Io)?;
} else {
std::fs::remove_file(&src).map_err(Error::Io)?;
}
} else {
return Err(Error::Tool {
name: "migrate".into(),
message: format!("rename {} -> {} failed: {e}", src.display(), dst.display()),
});
}
}
report.moved.push((src, dst));
}
if !dry_run {
let dotrec = workspace.join(".recursive");
if dotrec.is_dir() {
if let Ok(mut iter) = std::fs::read_dir(&dotrec) {
if iter.next().is_none() && std::fs::remove_dir(&dotrec).is_ok() {
report.removed_empty_dotrecursive = true;
}
}
}
}
Ok(report)
}
fn libc_exdev() -> i32 {
18
}
fn copy_recursively(src: &Path, dst: &Path) -> std::io::Result<()> {
if src.is_dir() {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
copy_recursively(&entry.path(), &dst.join(entry.file_name()))?;
}
Ok(())
} else {
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(src, dst).map(|_| ())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::PinnedRecursiveHome;
fn with_home<F: FnOnce()>(f: F) {
let home = tempfile::tempdir().unwrap();
let _g = PinnedRecursiveHome::new(home.path());
f();
}
#[test]
fn migrate_clean_workspace_is_noop() {
with_home(|| {
let ws = tempfile::tempdir().unwrap();
let r = migrate_workspace(ws.path(), false).unwrap();
assert!(r.already_clean);
assert!(r.moved.is_empty());
});
}
#[test]
fn migrate_moves_sessions_and_shadow_git() {
with_home(|| {
let ws = tempfile::tempdir().unwrap();
let dotrec = ws.path().join(".recursive");
std::fs::create_dir_all(dotrec.join("sessions/foo")).unwrap();
std::fs::write(dotrec.join("sessions/foo/.meta.json"), "{}").unwrap();
std::fs::create_dir_all(dotrec.join("shadow-git")).unwrap();
std::fs::write(dotrec.join("shadow-git/HEAD"), "ref: refs/heads/master\n").unwrap();
std::fs::write(dotrec.join("scratchpad.json"), r#"{"entries":[]}"#).unwrap();
let r = migrate_workspace(ws.path(), false).unwrap();
assert!(!r.already_clean);
assert_eq!(r.moved.len(), 3);
assert!(!dotrec.join("sessions").exists());
assert!(!dotrec.join("shadow-git").exists());
assert!(!dotrec.join("scratchpad.json").exists());
let target = paths::user_workspace_dir(ws.path()).unwrap();
assert!(target.join("sessions").is_dir());
assert!(target.join("shadow-git").is_dir());
assert!(target.join("scratchpad.json").is_file());
});
}
#[test]
fn migrate_skips_skills_and_mcp_json() {
with_home(|| {
let ws = tempfile::tempdir().unwrap();
let dotrec = ws.path().join(".recursive");
std::fs::create_dir_all(dotrec.join("sessions")).unwrap();
std::fs::create_dir_all(dotrec.join("skills")).unwrap();
std::fs::write(dotrec.join("mcp.json"), "{}").unwrap();
let r = migrate_workspace(ws.path(), false).unwrap();
assert_eq!(r.moved.len(), 1);
assert!(dotrec.join("skills").is_dir());
assert!(dotrec.join("mcp.json").is_file());
});
}
#[test]
fn migrate_aborts_on_destination_collision() {
with_home(|| {
let ws = tempfile::tempdir().unwrap();
let dotrec = ws.path().join(".recursive");
std::fs::create_dir_all(dotrec.join("sessions")).unwrap();
let target = paths::user_workspace_dir(ws.path()).unwrap();
std::fs::create_dir_all(target.join("sessions")).unwrap();
let r = migrate_workspace(ws.path(), false).unwrap();
assert_eq!(r.moved.len(), 0);
assert_eq!(r.skipped.len(), 1);
assert!(dotrec.join("sessions").exists());
});
}
#[test]
fn migrate_dry_run_does_not_mutate() {
with_home(|| {
let ws = tempfile::tempdir().unwrap();
let dotrec = ws.path().join(".recursive");
std::fs::create_dir_all(dotrec.join("sessions")).unwrap();
let r = migrate_workspace(ws.path(), true).unwrap();
assert_eq!(r.moved.len(), 1);
assert!(dotrec.join("sessions").exists());
let target = paths::user_workspace_dir(ws.path()).unwrap();
assert!(!target.join("sessions").exists());
});
}
#[test]
fn migrate_removes_empty_dotrecursive_when_only_byproducts_existed() {
with_home(|| {
let ws = tempfile::tempdir().unwrap();
let dotrec = ws.path().join(".recursive");
std::fs::create_dir_all(dotrec.join("sessions")).unwrap();
let r = migrate_workspace(ws.path(), false).unwrap();
assert!(r.removed_empty_dotrecursive);
assert!(!dotrec.exists());
});
}
}