Skip to main content

oxicode/cli/commands/
migrate.rs

1//! `oxicode migrate` subcommand family.
2//!
3//! Routes every migrate-style subcommand to the matching handler. The
4//! only handler currently shipping is `migrate_brain`, which migrates
5//! legacy durable memory (the SQLite / Mnemopi / summary backends) to
6//! the Foundation v1 host's only durable-memory authority: the
7//! oxibrain daemon.
8//!
9//! The migration is **resumable**: a checkpoint under
10//! `~/.oxicode/migration/brain.json` records the last successfully
11//! migrated memory ID. Restarting the migration resumes from the
12//! checkpoint; clearing the file restarts from scratch.
13//!
14//! The migration is **non-destructive**: the legacy store is left
15//! intact until the user explicitly archives it with
16//! `oxicode migrate brain --archive-legacy`. Archival moves the
17//! legacy store out of the active path to
18//! `~/.oxicode/archive/memory/<timestamp>/`. The legacy store cannot
19//! be re-enabled silently.
20//!
21//! ## Reference
22//!
23//! `docs/superpowers/specs/2026-08-17-oxi-foundation-contract.md` §
24//! "Migration".
25
26use std::path::PathBuf;
27
28use crate::cli::{MigrateBrainArgs, MigrationCommands};
29
30/// Top-level dispatcher. Returns the exit code.
31pub fn handle_migrate(cmd: MigrationCommands) -> i32 {
32    match cmd {
33        MigrationCommands::Brain(args) => handle_migrate_brain(args),
34    }
35}
36
37fn handle_migrate_brain(args: MigrateBrainArgs) -> i32 {
38    let socket = args
39        .socket
40        .unwrap_or_else(crate::foundation::brain::default_socket_path);
41
42    println!("Foundation v1 brain migration");
43    println!("-----------------------------");
44    println!("socket:              {}", socket.display());
45    println!("dry-run:             {}", args.dry_run);
46    println!("archive-legacy:      {}", args.archive_legacy);
47    println!("checkpoint:          {}", args.checkpoint.display());
48    println!("batch size:          {}", args.batch_size);
49
50    if args.dry_run {
51        println!("\nDRY RUN: no memory will be written, no checkpoint advanced.");
52        println!("The legacy store will be enumerated but not read for content.");
53    }
54
55    let backend = crate::foundation::brain::BrainMemoryBackend::new(socket.clone());
56    println!("\nbackend health:      {}", backend.health().info());
57
58    if args.dry_run {
59        println!("\nDry run complete. Re-run without --dry-run to perform the migration.");
60        return 0;
61    }
62
63    let checkpoint = crate::foundation::migrate::Checkpoint::load(&args.checkpoint);
64    if let Some(last) = checkpoint.last_id() {
65        println!("resuming after id:   {last}");
66    } else {
67        println!("starting fresh (no checkpoint found)");
68    }
69
70    let legacy = crate::foundation::migrate::LegacyMemoryReader::for_default_home();
71    let mut migrated = 0usize;
72    let mut failed = 0usize;
73    let mut tx = crate::foundation::migrate::Migration::new(&backend, &args.checkpoint);
74
75    for batch in legacy.batches(args.batch_size) {
76        for item in batch {
77            match tx.migrate_one(item) {
78                Ok(crate::foundation::migrate::MigrationOutcome::Inserted(id)) => {
79                    println!("  + {id}");
80                    migrated += 1;
81                }
82                Ok(crate::foundation::migrate::MigrationOutcome::Skipped(id)) => {
83                    println!("  ~ {id} (already in brain)");
84                }
85                Err(e) => {
86                    println!("  ! {e}");
87                    failed += 1;
88                }
89            }
90        }
91    }
92
93    println!("\nMigration summary");
94    println!("  inserted: {migrated}");
95    println!("  failed:   {failed}");
96
97    if args.archive_legacy {
98        match crate::foundation::migrate::archive_legacy_default() {
99            Ok(archive_path) => {
100                println!("\nLegacy store archived to: {}", archive_path.display());
101            }
102            Err(e) => {
103                println!("\nArchive failed: {e}");
104                return 2;
105            }
106        }
107    }
108
109    if failed > 0 { 1 } else { 0 }
110}
111
112impl Default for MigrateBrainArgs {
113    fn default() -> Self {
114        Self {
115            socket: None,
116            dry_run: false,
117            archive_legacy: false,
118            checkpoint: PathBuf::from("~/.oxicode/migration/brain.json"),
119            batch_size: 64,
120        }
121    }
122}