use std::fs;
use std::path::PathBuf;
#[derive(Debug, PartialEq, Eq)]
pub enum Migration {
Moved { from: PathBuf, to: PathBuf },
Merged {
from: PathBuf,
to: PathBuf,
entries: usize,
legacy_removed: bool,
},
}
impl Migration {
pub fn describe(&self) -> String {
match self {
Migration::Moved { from, to } => {
format!("moved {} → {}", from.display(), to.display())
}
Migration::Merged {
from,
to,
entries,
legacy_removed,
} => {
let noun = if *entries == 1 { "entry" } else { "entries" };
let mut msg = format!(
"carried {entries} {noun} from {} into {}",
from.display(),
to.display()
);
if !legacy_removed {
msg.push_str(" (kept the rest, already present at the new path)");
}
msg
}
}
}
}
pub fn migrate_dir(legacy: PathBuf, current: PathBuf) -> Option<Migration> {
if !legacy.is_dir() {
return None;
}
if !current.exists() {
fs::rename(&legacy, ¤t).ok()?;
return Some(Migration::Moved {
from: legacy,
to: current,
});
}
let mut entries = 0;
for entry in fs::read_dir(&legacy).ok()?.flatten() {
let target = current.join(entry.file_name());
if target.exists() {
continue;
}
if fs::rename(entry.path(), &target).is_ok() {
entries += 1;
}
}
let legacy_removed = fs::remove_dir(&legacy).is_ok();
if entries == 0 {
return None;
}
Some(Migration::Merged {
from: legacy,
to: current,
entries,
legacy_removed,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn write(path: &PathBuf, body: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, body).unwrap();
}
#[test]
fn renames_whole_directory_when_target_is_absent() {
let base = tempfile::tempdir().unwrap();
let legacy = base.path().join("old");
let current = base.path().join("new");
write(&legacy.join("auth.json"), "creds");
let result = migrate_dir(legacy.clone(), current.clone()).expect("should migrate");
assert_eq!(
result,
Migration::Moved {
from: legacy.clone(),
to: current.clone()
}
);
assert!(!legacy.exists());
assert_eq!(
fs::read_to_string(current.join("auth.json")).unwrap(),
"creds"
);
}
#[test]
fn carries_credentials_across_when_a_stray_file_already_exists() {
let base = tempfile::tempdir().unwrap();
let legacy = base.path().join("old");
let current = base.path().join("new");
write(&legacy.join("auth.json"), "creds");
write(¤t.join("grok-client-version"), "0.2.93\n");
let result = migrate_dir(legacy.clone(), current.clone()).expect("should migrate");
assert_eq!(
result,
Migration::Merged {
from: legacy.clone(),
to: current.clone(),
entries: 1,
legacy_removed: true
}
);
assert_eq!(
fs::read_to_string(current.join("auth.json")).unwrap(),
"creds"
);
assert!(!legacy.exists());
}
#[test]
fn never_overwrites_an_entry_present_at_the_new_path() {
let base = tempfile::tempdir().unwrap();
let legacy = base.path().join("old");
let current = base.path().join("new");
write(&legacy.join("auth.json"), "stale");
write(&legacy.join("rubric.md"), "hand-written");
write(¤t.join("auth.json"), "live");
let result = migrate_dir(legacy.clone(), current.clone()).expect("should migrate");
assert_eq!(
result,
Migration::Merged {
from: legacy.clone(),
to: current.clone(),
entries: 1,
legacy_removed: false
}
);
assert_eq!(
fs::read_to_string(current.join("auth.json")).unwrap(),
"live"
);
assert_eq!(
fs::read_to_string(current.join("rubric.md")).unwrap(),
"hand-written"
);
assert_eq!(
fs::read_to_string(legacy.join("auth.json")).unwrap(),
"stale"
);
}
#[test]
fn moves_subdirectories_whole() {
let base = tempfile::tempdir().unwrap();
let legacy = base.path().join("old");
let current = base.path().join("new");
write(&legacy.join("sessions").join("a.json"), "session");
write(¤t.join("grok-client-version"), "0.2.93\n");
migrate_dir(legacy, current.clone()).expect("should migrate");
assert_eq!(
fs::read_to_string(current.join("sessions").join("a.json")).unwrap(),
"session"
);
}
#[test]
fn reports_nothing_for_a_fresh_install() {
let base = tempfile::tempdir().unwrap();
assert!(migrate_dir(base.path().join("old"), base.path().join("new")).is_none());
}
#[test]
fn reports_nothing_when_the_legacy_directory_is_empty() {
let base = tempfile::tempdir().unwrap();
let legacy = base.path().join("old");
let current = base.path().join("new");
fs::create_dir_all(&legacy).unwrap();
fs::create_dir_all(¤t).unwrap();
assert!(migrate_dir(legacy.clone(), current).is_none());
assert!(!legacy.exists());
}
#[test]
fn describe_names_both_paths() {
let moved = Migration::Moved {
from: PathBuf::from("/a/small-harness"),
to: PathBuf::from("/a/albatross"),
};
assert_eq!(moved.describe(), "moved /a/small-harness → /a/albatross");
let merged = Migration::Merged {
from: PathBuf::from("/a/small-harness"),
to: PathBuf::from("/a/albatross"),
entries: 2,
legacy_removed: false,
};
assert_eq!(
merged.describe(),
"carried 2 entries from /a/small-harness into /a/albatross \
(kept the rest, already present at the new path)"
);
}
}