use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
pub const JOURNAL_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MigrationState {
NothingToDo,
Ready,
AlreadyMigrated,
Conflict {
conflicts: Vec<(PathBuf, PathBuf)>,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct MigrationPlan {
pub source: PathBuf,
pub destination: PathBuf,
pub file_count: usize,
pub total_bytes: u64,
pub state: MigrationState,
pub pending: Vec<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MigrationJournal {
pub version: u32,
pub source: PathBuf,
pub destination: PathBuf,
pub status: String,
pub started_at: u64,
}
impl MigrationJournal {
pub fn new_in_progress(source: &Path, destination: &Path) -> Self {
Self {
version: JOURNAL_VERSION,
source: source.to_path_buf(),
destination: destination.to_path_buf(),
status: "in_progress".to_string(),
started_at: unix_now(),
}
}
pub fn load(path: &Path) -> Option<Self> {
let bytes = std::fs::read(path).ok()?;
serde_json::from_slice(&bytes).ok()
}
pub fn save(&self, path: &Path) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("json.part");
let bytes =
serde_json::to_vec_pretty(self).expect("migration journal is JSON-serializable");
{
let mut f = std::fs::File::create(&tmp)?;
f.write_all(&bytes)?;
f.sync_all()?;
}
std::fs::rename(&tmp, path)?;
Ok(())
}
pub fn is_in_progress(&self) -> bool {
self.status == "in_progress"
}
}
fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[derive(Debug, thiserror::Error)]
pub enum HomeMigrationError {
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("journal error: {0}")]
Journal(String),
#[error("verification failed (rerun `oxicode migrate home` to repair): {0}")]
Verify(String),
}
pub fn walk_files(root: &Path) -> std::io::Result<Vec<PathBuf>> {
let mut out = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(_) if dir != root => continue,
Err(e) if dir == root => return Err(e),
Err(_) => continue,
};
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if path.is_file() {
out.push(path.strip_prefix(root).unwrap_or(&path).to_path_buf());
}
}
}
out.sort();
Ok(out)
}
fn sha256_file(path: &Path) -> std::io::Result<[u8; 32]> {
let mut file = std::fs::File::open(path)?;
let mut hasher = Sha256::new();
let mut buf = [0u8; 64 * 1024];
loop {
let n = file.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(hasher.finalize().into())
}
fn files_identical(source: &Path, candidate: &Path) -> bool {
let Ok(src_meta) = std::fs::metadata(source) else {
return false;
};
let Ok(dst_meta) = std::fs::metadata(candidate) else {
return false;
};
if src_meta.len() != dst_meta.len() {
return false;
}
match (sha256_file(source), sha256_file(candidate)) {
(Ok(a), Ok(b)) => a == b,
_ => false,
}
}
pub fn preflight(source: &Path, destination: &Path) -> Result<MigrationPlan, HomeMigrationError> {
let rel_files = match walk_files(source) {
Ok(files) => files,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(MigrationPlan {
source: source.to_path_buf(),
destination: destination.to_path_buf(),
file_count: 0,
total_bytes: 0,
state: MigrationState::NothingToDo,
pending: Vec::new(),
});
}
Err(e) => return Err(e.into()),
};
if rel_files.is_empty() {
return Ok(MigrationPlan {
source: source.to_path_buf(),
destination: destination.to_path_buf(),
file_count: 0,
total_bytes: 0,
state: MigrationState::NothingToDo,
pending: Vec::new(),
});
}
let mut total_bytes = 0u64;
let mut pending = Vec::new();
let mut conflicts = Vec::new();
let mut all_present = true;
for rel in &rel_files {
let src = source.join(rel);
let dst = destination.join(rel);
total_bytes += std::fs::metadata(&src).map(|m| m.len()).unwrap_or(0);
if !dst.exists() {
all_present = false;
pending.push(rel.clone());
continue;
}
if files_identical(&src, &dst) {
continue;
}
conflicts.push((src, dst));
}
let state = if !conflicts.is_empty() {
MigrationState::Conflict { conflicts }
} else if all_present {
MigrationState::AlreadyMigrated
} else {
MigrationState::Ready
};
Ok(MigrationPlan {
source: source.to_path_buf(),
destination: destination.to_path_buf(),
file_count: rel_files.len(),
total_bytes,
state,
pending,
})
}
pub fn copy_file_idempotent(source: &Path, destination: &Path) -> std::io::Result<bool> {
if destination.exists() && files_identical(source, destination) {
return Ok(false); }
if let Some(parent) = destination.parent() {
std::fs::create_dir_all(parent)?;
}
let part = destination.with_file_name(format!(
"{}.part-{}",
destination
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default(),
std::process::id()
));
{
let mut src = std::fs::File::open(source)?;
let mut dst = std::fs::File::create(&part)?;
std::io::copy(&mut src, &mut dst)?;
dst.sync_all()?;
}
std::fs::rename(&part, destination)?;
#[cfg(unix)]
if let Some(parent) = destination.parent()
&& let Ok(dir) = std::fs::File::open(parent)
{
let _ = dir.sync_all();
}
Ok(true) }
pub fn verify(source: &Path, destination: &Path) -> Result<(), HomeMigrationError> {
for rel in walk_files(source)? {
let src = source.join(&rel);
let dst = destination.join(&rel);
if !dst.exists() {
return Err(HomeMigrationError::Verify(format!(
"missing in destination: {}",
dst.display()
)));
}
if !files_identical(&src, &dst) {
return Err(HomeMigrationError::Verify(format!(
"content mismatch: {} vs {}",
src.display(),
dst.display()
)));
}
}
Ok(())
}
#[derive(Debug, Clone, PartialEq)]
pub enum RunOutcome {
NothingToDo,
Conflict { conflicts: Vec<(PathBuf, PathBuf)> },
AlreadyMigrated { completed_journal: bool },
DryRun(Box<MigrationPlan>),
Copied { copied: usize, skipped: usize },
}
pub fn run(
source: &Path,
destination: &Path,
journal_path: &Path,
dry_run: bool,
) -> Result<RunOutcome, HomeMigrationError> {
let plan = preflight(source, destination)?;
if dry_run {
return Ok(RunOutcome::DryRun(Box::new(plan)));
}
match plan.state {
MigrationState::NothingToDo => Ok(RunOutcome::NothingToDo),
MigrationState::Conflict { conflicts } => Ok(RunOutcome::Conflict { conflicts }),
MigrationState::AlreadyMigrated => {
let mut completed_journal = false;
if let Some(journal) = MigrationJournal::load(journal_path)
&& journal.is_in_progress()
{
let mut done = journal;
done.status = "complete".to_string();
done.save(journal_path)?;
completed_journal = true;
}
Ok(RunOutcome::AlreadyMigrated { completed_journal })
}
MigrationState::Ready => {
let journal = MigrationJournal::new_in_progress(source, destination);
journal.save(journal_path)?;
let mut copied = 0usize;
let mut skipped = 0usize;
for rel in &plan.pending {
let src = source.join(rel);
let dst = destination.join(rel);
if copy_file_idempotent(&src, &dst)? {
copied += 1;
} else {
skipped += 1;
}
}
verify(source, destination)?;
let mut done = MigrationJournal::new_in_progress(source, destination);
done.status = "complete".to_string();
done.save(journal_path)?;
Ok(RunOutcome::Copied { copied, skipped })
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn setup_source() -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
fs::create_dir_all(tmp.path().join("skills/my-skill")).unwrap();
fs::write(tmp.path().join("auth.json"), r#"{"p":"k"}"#).unwrap();
fs::write(tmp.path().join("skills/my-skill/SKILL.md"), "# skill").unwrap();
fs::write(tmp.path().join("WATCHDOG.md"), "watch").unwrap();
tmp
}
#[test]
fn walk_files_lists_recursive_relative_paths() {
let tmp = setup_source();
let files = walk_files(tmp.path()).unwrap();
assert_eq!(
files,
vec![
PathBuf::from("WATCHDOG.md"),
PathBuf::from("auth.json"),
PathBuf::from("skills/my-skill/SKILL.md"),
]
);
}
#[test]
fn preflight_ready_when_destination_missing() {
let src = setup_source();
let dst = tempfile::tempdir().unwrap();
let plan = preflight(src.path(), dst.path()).unwrap();
assert_eq!(plan.state, MigrationState::Ready);
assert_eq!(plan.file_count, 3);
assert_eq!(plan.total_bytes, 9 + 7 + 5);
assert_eq!(plan.pending.len(), 3);
}
#[test]
fn preflight_nothing_to_do_when_source_missing_or_empty() {
let plan = preflight(Path::new("/nonexistent/legacy-home"), Path::new("/tmp/x")).unwrap();
assert_eq!(plan.state, MigrationState::NothingToDo);
let empty = tempfile::tempdir().unwrap();
let dst = tempfile::tempdir().unwrap();
let plan = preflight(empty.path(), dst.path()).unwrap();
assert_eq!(plan.state, MigrationState::NothingToDo);
}
#[test]
fn preflight_already_migrated_when_identical() {
let src = setup_source();
let dst = tempfile::tempdir().unwrap();
run(src.path(), dst.path(), &dst.path().join("j.json"), false).unwrap();
let plan = preflight(src.path(), dst.path()).unwrap();
assert_eq!(plan.state, MigrationState::AlreadyMigrated);
assert!(plan.pending.is_empty());
}
#[test]
fn preflight_conflict_on_differing_file() {
let src = setup_source();
let dst = tempfile::tempdir().unwrap();
fs::create_dir_all(dst.path().join("skills")).unwrap();
fs::write(dst.path().join("auth.json"), r#"{"different":true}"#).unwrap();
let plan = preflight(src.path(), dst.path()).unwrap();
match plan.state {
MigrationState::Conflict { conflicts } => {
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0].0, src.path().join("auth.json"));
assert_eq!(conflicts[0].1, dst.path().join("auth.json"));
}
other => panic!("expected Conflict, got {other:?}"),
}
}
#[test]
fn run_copies_and_completes_journal() {
let src = setup_source();
let dst = tempfile::tempdir().unwrap();
let journal_dir = tempfile::tempdir().unwrap();
let journal = journal_dir.path().join("journal.json");
match run(src.path(), dst.path(), &journal, false).unwrap() {
RunOutcome::Copied { copied, skipped } => {
assert_eq!(copied, 3);
assert_eq!(skipped, 0);
}
other => panic!("expected Copied, got {other:?}"),
}
assert_eq!(
fs::read_to_string(dst.path().join("auth.json")).unwrap(),
r#"{"p":"k"}"#
);
assert!(dst.path().join("skills/my-skill/SKILL.md").is_file());
let j = MigrationJournal::load(&journal).unwrap();
assert_eq!(j.status, "complete");
assert_eq!(j.version, JOURNAL_VERSION);
assert_eq!(j.source, src.path());
assert_eq!(j.destination, dst.path());
assert!(src.path().join("auth.json").is_file());
}
#[test]
fn run_is_idempotent_and_resumes_partial_copy() {
let src = setup_source();
let dst = tempfile::tempdir().unwrap();
let journal_dir = tempfile::tempdir().unwrap();
let journal = journal_dir.path().join("journal.json");
run(src.path(), dst.path(), &journal, false).unwrap();
match run(src.path(), dst.path(), &journal, false).unwrap() {
RunOutcome::AlreadyMigrated { completed_journal } => {
assert!(!completed_journal);
}
other => panic!("expected AlreadyMigrated, got {other:?}"),
}
}
#[test]
fn run_resume_after_partial_copy_completes() {
let src = setup_source();
let dst = tempfile::tempdir().unwrap();
let journal_dir = tempfile::tempdir().unwrap();
let journal = journal_dir.path().join("journal.json");
let journal_entry = MigrationJournal::new_in_progress(src.path(), dst.path());
journal_entry.save(&journal).unwrap();
fs::create_dir_all(dst.path().join("skills/my-skill")).unwrap();
fs::write(dst.path().join("auth.json"), r#"{"p":"k"}"#).unwrap();
match run(src.path(), dst.path(), &journal, false).unwrap() {
RunOutcome::Copied { copied, skipped } => {
assert_eq!(copied, 2);
assert_eq!(skipped, 0);
}
other => panic!("expected Copied, got {other:?}"),
}
let j = MigrationJournal::load(&journal).unwrap();
assert_eq!(j.status, "complete");
}
#[test]
fn verify_fails_on_post_migration_divergence() {
let src = setup_source();
let dst = tempfile::tempdir().unwrap();
let journal_dir = tempfile::tempdir().unwrap();
let journal = journal_dir.path().join("journal.json");
run(src.path(), dst.path(), &journal, false).unwrap();
fs::write(dst.path().join("WATCHDOG.md"), "tampered").unwrap();
let err = verify(src.path(), dst.path()).unwrap_err();
assert!(err.to_string().contains("content mismatch"));
let plan = preflight(src.path(), dst.path()).unwrap();
assert!(matches!(plan.state, MigrationState::Conflict { .. }));
}
#[test]
fn dry_run_mutates_nothing() {
let src = setup_source();
let dst = tempfile::tempdir().unwrap();
let journal_dir = tempfile::tempdir().unwrap();
let journal = journal_dir.path().join("journal.json");
let before = walk_files(dst.path()).unwrap();
match run(src.path(), dst.path(), &journal, true).unwrap() {
RunOutcome::DryRun(plan) => {
assert_eq!(plan.state, MigrationState::Ready);
assert_eq!(plan.file_count, 3);
}
other => panic!("expected DryRun, got {other:?}"),
}
assert_eq!(walk_files(dst.path()).unwrap(), before);
assert!(!journal.exists());
}
#[test]
fn stale_in_progress_journal_is_completed_on_already_migrated() {
let src = setup_source();
let dst = tempfile::tempdir().unwrap();
let journal_dir = tempfile::tempdir().unwrap();
let journal = journal_dir.path().join("journal.json");
let opts = copy_tree(src.path(), dst.path());
assert_eq!(opts, 3);
let entry = MigrationJournal::new_in_progress(src.path(), dst.path());
entry.save(&journal).unwrap();
match run(src.path(), dst.path(), &journal, false).unwrap() {
RunOutcome::AlreadyMigrated { completed_journal } => {
assert!(completed_journal);
}
other => panic!("expected AlreadyMigrated, got {other:?}"),
}
assert_eq!(MigrationJournal::load(&journal).unwrap().status, "complete");
}
fn copy_tree(source: &Path, destination: &Path) -> usize {
let mut n = 0;
for rel in walk_files(source).unwrap() {
let dst = destination.join(&rel);
fs::create_dir_all(dst.parent().unwrap()).unwrap();
fs::copy(source.join(&rel), &dst).unwrap();
n += 1;
}
n
}
}