Skip to main content

oxicode/
home_migrate.rs

1//! Journaled, resumable migration of the legacy oxicode home (`~/.oxicode`)
2//! into the unified Oxi home layout (`<oxicode_home>`).
3//!
4//! Contract:
5//!
6//! - **Preflight** produces a plan: source, destination, file count, total
7//!   bytes, and a state ([`MigrationState::NothingToDo`],
8//!   [`MigrationState::Ready`], [`MigrationState::AlreadyMigrated`],
9//!   [`MigrationState::Conflict`]).
10//! - **Conflict** = the destination already contains a file that differs
11//!   (size or SHA-256) from its source counterpart. The migration aborts,
12//!   reports both paths, and touches nothing. A destination identical to the
13//!   source means [`MigrationState::AlreadyMigrated`] (no-op).
14//! - The **journal** (`<oxi_home>/oxicode.migration-journal.json`) is written
15//!   atomically (temp + rename) BEFORE the first filesystem mutation, with
16//!   `status: "in_progress"`; on success it is rewritten with
17//!   `status: "complete"`.
18//! - The **copy step is idempotent per file**: a destination file with the
19//!   same size + SHA-256 is skipped; otherwise the file is copied to
20//!   `<dest>.part-<pid>`, fsynced, and renamed into place (destination
21//!   directory fsynced best-effort). Because of this, a run that dies
22//!   mid-copy can simply be re-run: the journal stays `in_progress` and the
23//!   rerun repairs/resumes.
24//! - The **verify step** walks both trees and requires every source file to
25//!   exist in the destination with a matching hash. Any mismatch is an error
26//!   and the journal stays `in_progress` for the next run to repair.
27//! - The **source is never deleted or modified** — migration is copy-only.
28//!   (Rename optimization is deferred to a later cutover release.)
29
30use serde::{Deserialize, Serialize};
31use sha2::{Digest, Sha256};
32use std::io::{Read, Write};
33use std::path::{Path, PathBuf};
34
35/// Current journal schema version.
36pub const JOURNAL_VERSION: u32 = 1;
37
38// ── Plan ───────────────────────────────────────────────────────────────────
39
40/// Outcome of the preflight analysis. See module docs for the semantics.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum MigrationState {
43    /// No legacy home, or it contains no files.
44    NothingToDo,
45    /// Files can be copied without overwriting anything that differs.
46    Ready,
47    /// Destination already matches the source byte-for-byte.
48    AlreadyMigrated,
49    /// At least one file exists in both trees with different content.
50    /// Carries `(source, destination)` pairs of the differing files.
51    Conflict {
52        /// Differing file pairs (absolute paths).
53        conflicts: Vec<(PathBuf, PathBuf)>,
54    },
55}
56
57/// Result of a preflight run.
58#[derive(Debug, Clone, PartialEq)]
59pub struct MigrationPlan {
60    /// Legacy source home.
61    pub source: PathBuf,
62    /// Canonical destination home.
63    pub destination: PathBuf,
64    /// Number of files under the source.
65    pub file_count: usize,
66    /// Total size of the source files, in bytes.
67    pub total_bytes: u64,
68    /// Preflight state.
69    pub state: MigrationState,
70    /// Source-relative paths still needing a copy (empty unless `Ready`).
71    pub pending: Vec<PathBuf>,
72}
73
74// ── Journal ────────────────────────────────────────────────────────────────
75
76/// On-disk migration journal. Written atomically before the first mutation;
77/// a missing or unreadable journal is treated as "no journal".
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
79pub struct MigrationJournal {
80    /// Journal schema version.
81    pub version: u32,
82    /// Legacy source home.
83    pub source: PathBuf,
84    /// Canonical destination home.
85    pub destination: PathBuf,
86    /// `"in_progress"` until the copy + verify completes.
87    pub status: String,
88    /// Migration start time (Unix seconds).
89    pub started_at: u64,
90}
91
92impl MigrationJournal {
93    /// A fresh `in_progress` journal stamped with the current Unix time.
94    pub fn new_in_progress(source: &Path, destination: &Path) -> Self {
95        Self {
96            version: JOURNAL_VERSION,
97            source: source.to_path_buf(),
98            destination: destination.to_path_buf(),
99            status: "in_progress".to_string(),
100            started_at: unix_now(),
101        }
102    }
103
104    /// Load the journal from disk. Missing or unreadable file is treated as
105    /// "no journal" (`None`).
106    pub fn load(path: &Path) -> Option<Self> {
107        let bytes = std::fs::read(path).ok()?;
108        serde_json::from_slice(&bytes).ok()
109    }
110
111    /// Atomically write the journal (temp + rename). Parent directories are
112    /// created on demand.
113    pub fn save(&self, path: &Path) -> std::io::Result<()> {
114        if let Some(parent) = path.parent() {
115            std::fs::create_dir_all(parent)?;
116        }
117        let tmp = path.with_extension("json.part");
118        let bytes =
119            serde_json::to_vec_pretty(self).expect("migration journal is JSON-serializable");
120        {
121            let mut f = std::fs::File::create(&tmp)?;
122            f.write_all(&bytes)?;
123            f.sync_all()?;
124        }
125        std::fs::rename(&tmp, path)?;
126        Ok(())
127    }
128
129    /// Whether this journal records an in-progress migration.
130    pub fn is_in_progress(&self) -> bool {
131        self.status == "in_progress"
132    }
133}
134
135fn unix_now() -> u64 {
136    std::time::SystemTime::now()
137        .duration_since(std::time::UNIX_EPOCH)
138        .map(|d| d.as_secs())
139        .unwrap_or(0)
140}
141
142// ── Errors ─────────────────────────────────────────────────────────────────
143
144/// Errors the migration engine can surface.
145#[derive(Debug, thiserror::Error)]
146pub enum HomeMigrationError {
147    /// Filesystem error.
148    #[error(transparent)]
149    Io(#[from] std::io::Error),
150    /// Journal (de)serialization failure.
151    #[error("journal error: {0}")]
152    Journal(String),
153    /// Post-copy verification failed; the journal stays `in_progress`.
154    #[error("verification failed (rerun `oxicode migrate home` to repair): {0}")]
155    Verify(String),
156}
157
158// ── Preflight ──────────────────────────────────────────────────────────────
159
160/// Recursively list files under `root` as `root`-relative paths, sorted for
161/// deterministic plans.
162pub fn walk_files(root: &Path) -> std::io::Result<Vec<PathBuf>> {
163    let mut out = Vec::new();
164    let mut stack = vec![root.to_path_buf()];
165    while let Some(dir) = stack.pop() {
166        let entries = match std::fs::read_dir(&dir) {
167            Ok(entries) => entries,
168            // Not a directory (or unreadable): nothing to walk.
169            Err(_) if dir != root => continue,
170            Err(e) if dir == root => return Err(e),
171            Err(_) => continue,
172        };
173        for entry in entries {
174            let entry = entry?;
175            let path = entry.path();
176            if path.is_dir() {
177                stack.push(path);
178            } else if path.is_file() {
179                out.push(path.strip_prefix(root).unwrap_or(&path).to_path_buf());
180            }
181        }
182    }
183    out.sort();
184    Ok(out)
185}
186
187fn sha256_file(path: &Path) -> std::io::Result<[u8; 32]> {
188    let mut file = std::fs::File::open(path)?;
189    let mut hasher = Sha256::new();
190    let mut buf = [0u8; 64 * 1024];
191    loop {
192        let n = file.read(&mut buf)?;
193        if n == 0 {
194            break;
195        }
196        hasher.update(&buf[..n]);
197    }
198    Ok(hasher.finalize().into())
199}
200
201/// Whether `candidate` exists with the same size and SHA-256 as `source`.
202fn files_identical(source: &Path, candidate: &Path) -> bool {
203    let Ok(src_meta) = std::fs::metadata(source) else {
204        return false;
205    };
206    let Ok(dst_meta) = std::fs::metadata(candidate) else {
207        return false;
208    };
209    if src_meta.len() != dst_meta.len() {
210        return false;
211    }
212    match (sha256_file(source), sha256_file(candidate)) {
213        (Ok(a), Ok(b)) => a == b,
214        _ => false,
215    }
216}
217
218/// Analyze a legacy → canonical migration without touching the filesystem.
219pub fn preflight(source: &Path, destination: &Path) -> Result<MigrationPlan, HomeMigrationError> {
220    let rel_files = match walk_files(source) {
221        Ok(files) => files,
222        // A missing (or vanished) source is simply nothing to migrate.
223        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
224            return Ok(MigrationPlan {
225                source: source.to_path_buf(),
226                destination: destination.to_path_buf(),
227                file_count: 0,
228                total_bytes: 0,
229                state: MigrationState::NothingToDo,
230                pending: Vec::new(),
231            });
232        }
233        Err(e) => return Err(e.into()),
234    };
235    if rel_files.is_empty() {
236        return Ok(MigrationPlan {
237            source: source.to_path_buf(),
238            destination: destination.to_path_buf(),
239            file_count: 0,
240            total_bytes: 0,
241            state: MigrationState::NothingToDo,
242            pending: Vec::new(),
243        });
244    }
245
246    let mut total_bytes = 0u64;
247    let mut pending = Vec::new();
248    let mut conflicts = Vec::new();
249    let mut all_present = true;
250
251    for rel in &rel_files {
252        let src = source.join(rel);
253        let dst = destination.join(rel);
254        total_bytes += std::fs::metadata(&src).map(|m| m.len()).unwrap_or(0);
255
256        if !dst.exists() {
257            all_present = false;
258            pending.push(rel.clone());
259            continue;
260        }
261        if files_identical(&src, &dst) {
262            continue;
263        }
264        // Same relative path, different content: a hard conflict.
265        conflicts.push((src, dst));
266    }
267
268    let state = if !conflicts.is_empty() {
269        MigrationState::Conflict { conflicts }
270    } else if all_present {
271        MigrationState::AlreadyMigrated
272    } else {
273        MigrationState::Ready
274    };
275
276    Ok(MigrationPlan {
277        source: source.to_path_buf(),
278        destination: destination.to_path_buf(),
279        file_count: rel_files.len(),
280        total_bytes,
281        state,
282        pending,
283    })
284}
285
286// ── Copy + verify ──────────────────────────────────────────────────────────
287
288/// Copy `source` → `destination` if the destination does not already match.
289///
290/// Idempotent: a destination file with the same size + SHA-256 is skipped.
291/// Otherwise the content is written to `<destination>.part-<pid>`, fsynced,
292/// and renamed into place; the destination directory is fsynced best-effort.
293pub fn copy_file_idempotent(source: &Path, destination: &Path) -> std::io::Result<bool> {
294    if destination.exists() && files_identical(source, destination) {
295        return Ok(false); // skipped: already identical
296    }
297
298    if let Some(parent) = destination.parent() {
299        std::fs::create_dir_all(parent)?;
300    }
301    let part = destination.with_file_name(format!(
302        "{}.part-{}",
303        destination
304            .file_name()
305            .map(|n| n.to_string_lossy().to_string())
306            .unwrap_or_default(),
307        std::process::id()
308    ));
309
310    {
311        let mut src = std::fs::File::open(source)?;
312        let mut dst = std::fs::File::create(&part)?;
313        std::io::copy(&mut src, &mut dst)?;
314        dst.sync_all()?;
315    }
316    std::fs::rename(&part, destination)?;
317
318    // Best-effort directory fsync so the rename is durable.
319    #[cfg(unix)]
320    if let Some(parent) = destination.parent()
321        && let Ok(dir) = std::fs::File::open(parent)
322    {
323        let _ = dir.sync_all();
324    }
325
326    Ok(true) // copied
327}
328
329/// Verify the migration: every source file must exist in the destination
330/// with a matching size + SHA-256. Extra destination files are not part of
331/// the migration set and are ignored.
332pub fn verify(source: &Path, destination: &Path) -> Result<(), HomeMigrationError> {
333    for rel in walk_files(source)? {
334        let src = source.join(&rel);
335        let dst = destination.join(&rel);
336        if !dst.exists() {
337            return Err(HomeMigrationError::Verify(format!(
338                "missing in destination: {}",
339                dst.display()
340            )));
341        }
342        if !files_identical(&src, &dst) {
343            return Err(HomeMigrationError::Verify(format!(
344                "content mismatch: {} vs {}",
345                src.display(),
346                dst.display()
347            )));
348        }
349    }
350    Ok(())
351}
352
353// ── Run ────────────────────────────────────────────────────────────────────
354
355/// What a [`run`] invocation did.
356#[derive(Debug, Clone, PartialEq)]
357pub enum RunOutcome {
358    /// Nothing to migrate (no legacy home, or it is empty).
359    NothingToDo,
360    /// Conflicting files exist; nothing was touched.
361    Conflict { conflicts: Vec<(PathBuf, PathBuf)> },
362    /// Destination already matched; optionally completed a stale
363    /// `in_progress` journal.
364    AlreadyMigrated { completed_journal: bool },
365    /// Dry run: preflight only, filesystem untouched.
366    DryRun(Box<MigrationPlan>),
367    /// Copy completed and verified.
368    Copied { copied: usize, skipped: usize },
369}
370
371/// Execute (or dry-run) the migration.
372///
373/// All paths are injected so the engine is testable without touching the
374/// real `$HOME`. Safe to re-run at any point: the copy step is idempotent
375/// per file and a failed verify leaves the journal `in_progress` for repair.
376pub fn run(
377    source: &Path,
378    destination: &Path,
379    journal_path: &Path,
380    dry_run: bool,
381) -> Result<RunOutcome, HomeMigrationError> {
382    let plan = preflight(source, destination)?;
383
384    if dry_run {
385        return Ok(RunOutcome::DryRun(Box::new(plan)));
386    }
387
388    match plan.state {
389        MigrationState::NothingToDo => Ok(RunOutcome::NothingToDo),
390        MigrationState::Conflict { conflicts } => Ok(RunOutcome::Conflict { conflicts }),
391        MigrationState::AlreadyMigrated => {
392            // Complete a stale in-progress journal, if any; otherwise this is
393            // a pure no-op.
394            let mut completed_journal = false;
395            if let Some(journal) = MigrationJournal::load(journal_path)
396                && journal.is_in_progress()
397            {
398                let mut done = journal;
399                done.status = "complete".to_string();
400                done.save(journal_path)?;
401                completed_journal = true;
402            }
403            Ok(RunOutcome::AlreadyMigrated { completed_journal })
404        }
405        MigrationState::Ready => {
406            // Journal BEFORE the first filesystem mutation.
407            let journal = MigrationJournal::new_in_progress(source, destination);
408            journal.save(journal_path)?;
409
410            let mut copied = 0usize;
411            let mut skipped = 0usize;
412            for rel in &plan.pending {
413                let src = source.join(rel);
414                let dst = destination.join(rel);
415                if copy_file_idempotent(&src, &dst)? {
416                    copied += 1;
417                } else {
418                    skipped += 1;
419                }
420            }
421
422            verify(source, destination)?;
423
424            let mut done = MigrationJournal::new_in_progress(source, destination);
425            done.status = "complete".to_string();
426            done.save(journal_path)?;
427
428            Ok(RunOutcome::Copied { copied, skipped })
429        }
430    }
431}
432
433// ── Tests ──────────────────────────────────────────────────────────────────
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use std::fs;
439
440    /// Legacy tree with three files; fresh destination.
441    fn setup_source() -> tempfile::TempDir {
442        let tmp = tempfile::tempdir().unwrap();
443        fs::create_dir_all(tmp.path().join("skills/my-skill")).unwrap();
444        fs::write(tmp.path().join("auth.json"), r#"{"p":"k"}"#).unwrap();
445        fs::write(tmp.path().join("skills/my-skill/SKILL.md"), "# skill").unwrap();
446        fs::write(tmp.path().join("WATCHDOG.md"), "watch").unwrap();
447        tmp
448    }
449
450    #[test]
451    fn walk_files_lists_recursive_relative_paths() {
452        let tmp = setup_source();
453        let files = walk_files(tmp.path()).unwrap();
454        assert_eq!(
455            files,
456            vec![
457                PathBuf::from("WATCHDOG.md"),
458                PathBuf::from("auth.json"),
459                PathBuf::from("skills/my-skill/SKILL.md"),
460            ]
461        );
462    }
463
464    #[test]
465    fn preflight_ready_when_destination_missing() {
466        let src = setup_source();
467        let dst = tempfile::tempdir().unwrap();
468        let plan = preflight(src.path(), dst.path()).unwrap();
469        assert_eq!(plan.state, MigrationState::Ready);
470        assert_eq!(plan.file_count, 3);
471        assert_eq!(plan.total_bytes, 9 + 7 + 5);
472        assert_eq!(plan.pending.len(), 3);
473    }
474
475    #[test]
476    fn preflight_nothing_to_do_when_source_missing_or_empty() {
477        let plan = preflight(Path::new("/nonexistent/legacy-home"), Path::new("/tmp/x")).unwrap();
478        assert_eq!(plan.state, MigrationState::NothingToDo);
479
480        let empty = tempfile::tempdir().unwrap();
481        let dst = tempfile::tempdir().unwrap();
482        let plan = preflight(empty.path(), dst.path()).unwrap();
483        assert_eq!(plan.state, MigrationState::NothingToDo);
484    }
485
486    #[test]
487    fn preflight_already_migrated_when_identical() {
488        let src = setup_source();
489        let dst = tempfile::tempdir().unwrap();
490        run(src.path(), dst.path(), &dst.path().join("j.json"), false).unwrap();
491        // Rerun: everything identical.
492        let plan = preflight(src.path(), dst.path()).unwrap();
493        assert_eq!(plan.state, MigrationState::AlreadyMigrated);
494        assert!(plan.pending.is_empty());
495    }
496
497    #[test]
498    fn preflight_conflict_on_differing_file() {
499        let src = setup_source();
500        let dst = tempfile::tempdir().unwrap();
501        fs::create_dir_all(dst.path().join("skills")).unwrap();
502        fs::write(dst.path().join("auth.json"), r#"{"different":true}"#).unwrap();
503
504        let plan = preflight(src.path(), dst.path()).unwrap();
505        match plan.state {
506            MigrationState::Conflict { conflicts } => {
507                assert_eq!(conflicts.len(), 1);
508                assert_eq!(conflicts[0].0, src.path().join("auth.json"));
509                assert_eq!(conflicts[0].1, dst.path().join("auth.json"));
510            }
511            other => panic!("expected Conflict, got {other:?}"),
512        }
513    }
514
515    #[test]
516    fn run_copies_and_completes_journal() {
517        let src = setup_source();
518        let dst = tempfile::tempdir().unwrap();
519        let journal_dir = tempfile::tempdir().unwrap();
520        let journal = journal_dir.path().join("journal.json");
521
522        match run(src.path(), dst.path(), &journal, false).unwrap() {
523            RunOutcome::Copied { copied, skipped } => {
524                assert_eq!(copied, 3);
525                assert_eq!(skipped, 0);
526            }
527            other => panic!("expected Copied, got {other:?}"),
528        }
529
530        // Content landed.
531        assert_eq!(
532            fs::read_to_string(dst.path().join("auth.json")).unwrap(),
533            r#"{"p":"k"}"#
534        );
535        assert!(dst.path().join("skills/my-skill/SKILL.md").is_file());
536
537        // Journal marked complete.
538        let j = MigrationJournal::load(&journal).unwrap();
539        assert_eq!(j.status, "complete");
540        assert_eq!(j.version, JOURNAL_VERSION);
541        assert_eq!(j.source, src.path());
542        assert_eq!(j.destination, dst.path());
543
544        // Source untouched.
545        assert!(src.path().join("auth.json").is_file());
546    }
547
548    #[test]
549    fn run_is_idempotent_and_resumes_partial_copy() {
550        let src = setup_source();
551        let dst = tempfile::tempdir().unwrap();
552        let journal_dir = tempfile::tempdir().unwrap();
553        let journal = journal_dir.path().join("journal.json");
554
555        run(src.path(), dst.path(), &journal, false).unwrap();
556
557        // Simulate a restart after everything is already copied: rerun.
558        match run(src.path(), dst.path(), &journal, false).unwrap() {
559            RunOutcome::AlreadyMigrated { completed_journal } => {
560                // First run completed the journal, so the rerun does not
561                // need to complete anything.
562                assert!(!completed_journal);
563            }
564            other => panic!("expected AlreadyMigrated, got {other:?}"),
565        }
566    }
567
568    #[test]
569    fn run_resume_after_partial_copy_completes() {
570        let src = setup_source();
571        let dst = tempfile::tempdir().unwrap();
572        let journal_dir = tempfile::tempdir().unwrap();
573        let journal = journal_dir.path().join("journal.json");
574
575        // Simulate a crashed first run: journal in_progress, one file copied.
576        let journal_entry = MigrationJournal::new_in_progress(src.path(), dst.path());
577        journal_entry.save(&journal).unwrap();
578        fs::create_dir_all(dst.path().join("skills/my-skill")).unwrap();
579        fs::write(dst.path().join("auth.json"), r#"{"p":"k"}"#).unwrap();
580
581        match run(src.path(), dst.path(), &journal, false).unwrap() {
582            RunOutcome::Copied { copied, skipped } => {
583                // The pre-copied auth.json was already excluded from the
584                // pending set by preflight, so the copy pass has no skips.
585                assert_eq!(copied, 2);
586                assert_eq!(skipped, 0);
587            }
588            other => panic!("expected Copied, got {other:?}"),
589        }
590
591        let j = MigrationJournal::load(&journal).unwrap();
592        assert_eq!(j.status, "complete");
593    }
594
595    #[test]
596    fn verify_fails_on_post_migration_divergence() {
597        let src = setup_source();
598        let dst = tempfile::tempdir().unwrap();
599        let journal_dir = tempfile::tempdir().unwrap();
600        let journal = journal_dir.path().join("journal.json");
601
602        run(src.path(), dst.path(), &journal, false).unwrap();
603        // Post-migration divergence in the destination.
604        fs::write(dst.path().join("WATCHDOG.md"), "tampered").unwrap();
605
606        // Verify surfaces the mismatch as an error...
607        let err = verify(src.path(), dst.path()).unwrap_err();
608        assert!(err.to_string().contains("content mismatch"));
609
610        // ...and preflight classifies the divergence as a hard conflict, so
611        // a blind rerun cannot overwrite the changed destination file.
612        let plan = preflight(src.path(), dst.path()).unwrap();
613        assert!(matches!(plan.state, MigrationState::Conflict { .. }));
614    }
615
616    #[test]
617    fn dry_run_mutates_nothing() {
618        let src = setup_source();
619        let dst = tempfile::tempdir().unwrap();
620        let journal_dir = tempfile::tempdir().unwrap();
621        let journal = journal_dir.path().join("journal.json");
622        let before = walk_files(dst.path()).unwrap();
623
624        match run(src.path(), dst.path(), &journal, true).unwrap() {
625            RunOutcome::DryRun(plan) => {
626                assert_eq!(plan.state, MigrationState::Ready);
627                assert_eq!(plan.file_count, 3);
628            }
629            other => panic!("expected DryRun, got {other:?}"),
630        }
631
632        assert_eq!(walk_files(dst.path()).unwrap(), before);
633        assert!(!journal.exists());
634    }
635
636    #[test]
637    fn stale_in_progress_journal_is_completed_on_already_migrated() {
638        let src = setup_source();
639        let dst = tempfile::tempdir().unwrap();
640        let journal_dir = tempfile::tempdir().unwrap();
641        let journal = journal_dir.path().join("journal.json");
642
643        // Full copy done by hand, journal left in_progress (crashed run).
644        let opts = copy_tree(src.path(), dst.path());
645        assert_eq!(opts, 3);
646        let entry = MigrationJournal::new_in_progress(src.path(), dst.path());
647        entry.save(&journal).unwrap();
648
649        match run(src.path(), dst.path(), &journal, false).unwrap() {
650            RunOutcome::AlreadyMigrated { completed_journal } => {
651                assert!(completed_journal);
652            }
653            other => panic!("expected AlreadyMigrated, got {other:?}"),
654        }
655        assert_eq!(MigrationJournal::load(&journal).unwrap().status, "complete");
656    }
657
658    fn copy_tree(source: &Path, destination: &Path) -> usize {
659        let mut n = 0;
660        for rel in walk_files(source).unwrap() {
661            let dst = destination.join(&rel);
662            fs::create_dir_all(dst.parent().unwrap()).unwrap();
663            fs::copy(source.join(&rel), &dst).unwrap();
664            n += 1;
665        }
666        n
667    }
668}