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, MigrateHomeArgs, MigrationCommands};
29
30/// Top-level dispatcher. Returns the exit code.
31pub async fn handle_migrate(cmd: MigrationCommands) -> i32 {
32    match cmd {
33        MigrationCommands::Brain(args) => handle_migrate_brain(args).await,
34        MigrationCommands::Home(args) => handle_migrate_home(args),
35    }
36}
37
38/// `oxicode migrate home` — legacy `~/.oxicode` → unified Oxi home.
39///
40/// Journaled, resumable, copy-only (see [`crate::home_migrate`]). Safe to
41/// re-run at any point; `--dry-run` prints the plan and mutates nothing.
42fn handle_migrate_home(args: MigrateHomeArgs) -> i32 {
43    use crate::home_migrate::{MigrationState, RunOutcome};
44    use oxicode_catalog::oxi_home;
45
46    println!("Oxi home layout migration");
47    println!("-------------------------");
48    println!("dry-run: {}", args.dry_run);
49
50    let Some(source) = oxi_home::legacy_home_dir() else {
51        println!("source:              <no legacy home>");
52        if std::env::var_os("OXICODE_HOME").is_some() {
53            println!(
54                "required action:     nothing to do (explicit OXICODE_HOME never merges legacy)"
55            );
56        } else {
57            println!("required action:     nothing to do");
58        }
59        return 0;
60    };
61    let Some(destination) = oxi_home::oxicode_home() else {
62        eprintln!("error: cannot resolve the canonical oxicode home");
63        return 1;
64    };
65    let Some(journal_path) = oxi_home::migration_journal_path() else {
66        eprintln!("error: cannot resolve the oxi home for the migration journal");
67        return 1;
68    };
69
70    // Preflight summary — cheap, read-only, always printed.
71    let plan = match crate::home_migrate::preflight(&source, &destination) {
72        Ok(plan) => plan,
73        Err(e) => {
74            eprintln!("error: preflight failed: {e}");
75            return 1;
76        }
77    };
78    println!("source:              {} (legacy)", source.display());
79    println!("destination:         {}", destination.display());
80    println!("journal:             {}", journal_path.display());
81    println!("files:               {}", plan.file_count);
82    println!("bytes:               {}", plan.total_bytes);
83
84    let outcome = match crate::home_migrate::run(&source, &destination, &journal_path, args.dry_run)
85    {
86        Ok(outcome) => outcome,
87        Err(e) => {
88            eprintln!("error: {e}");
89            return 1;
90        }
91    };
92
93    match outcome {
94        RunOutcome::DryRun(plan) => match plan.state {
95            MigrationState::NothingToDo => {
96                println!("state:               nothing to do");
97                println!("required action:     none");
98            }
99            MigrationState::Ready => {
100                println!(
101                    "state:               ready ({} file(s) to copy)",
102                    plan.pending.len()
103                );
104                println!("required action:     rerun without --dry-run to migrate");
105            }
106            MigrationState::AlreadyMigrated => {
107                println!("state:               already migrated (destination identical)");
108                println!("required action:     none");
109            }
110            MigrationState::Conflict { conflicts } => {
111                println!("state:               conflict");
112                for (src, dst) in &conflicts {
113                    println!("  differs:");
114                    println!("    source:      {}", src.display());
115                    println!("    destination: {}", dst.display());
116                }
117                println!(
118                    "required action:     resolve the differing files manually (nothing was touched)"
119                );
120            }
121        },
122        RunOutcome::NothingToDo => {
123            println!("state:               nothing to do");
124            println!("required action:     none");
125        }
126        RunOutcome::Conflict { conflicts } => {
127            println!("state:               conflict (nothing was touched)");
128            for (src, dst) in &conflicts {
129                println!("  differs:");
130                println!("    source:      {}", src.display());
131                println!("    destination: {}", dst.display());
132            }
133            println!("required action:     resolve the differing files manually");
134            return 1;
135        }
136        RunOutcome::AlreadyMigrated { completed_journal } => {
137            println!("state:               already migrated (destination identical)");
138            if completed_journal {
139                println!("journal:             stale in_progress entry marked complete");
140            }
141            println!("required action:     none");
142        }
143        RunOutcome::Copied { copied, skipped } => {
144            println!("copied:              {copied} file(s)");
145            println!("skipped (identical): {skipped} file(s)");
146            println!("required action:     none — legacy home left intact");
147        }
148    }
149
150    0
151}
152
153async fn handle_migrate_brain(args: MigrateBrainArgs) -> i32 {
154    let socket = args
155        .socket
156        .unwrap_or_else(crate::foundation::brain::default_socket_path);
157
158    println!("Foundation v1 brain migration");
159    println!("-----------------------------");
160    println!("socket:              {}", socket.display());
161    println!("dry-run:             {}", args.dry_run);
162    println!("archive-legacy:      {}", args.archive_legacy);
163    println!("checkpoint:          {}", args.checkpoint.display());
164    println!("batch size:          {}", args.batch_size);
165
166    if args.dry_run {
167        println!("\nDRY RUN: no memory will be written, no checkpoint advanced.");
168        println!("The legacy store will be enumerated but not read for content.");
169    }
170
171    let backend = crate::foundation::brain::BrainMemoryBackend::new(socket.clone());
172    // Probe the daemon so the printed health is a live measurement —
173    // construction alone leaves the cached state at `Unavailable`.
174    let probe = backend.ping().await;
175    println!(
176        "\nbackend health:      {}",
177        match &probe {
178            Ok(()) => "ok: oxibrain daemon connected".to_string(),
179            Err(e) => format!("degraded ({e})"),
180        }
181    );
182
183    if args.dry_run {
184        println!("\nDry run complete. Re-run without --dry-run to perform the migration.");
185        return 0;
186    }
187
188    let checkpoint = crate::foundation::migrate::Checkpoint::load(&args.checkpoint);
189    if let Some(last) = checkpoint.last_id() {
190        println!("resuming after id:   {last}");
191    } else {
192        println!("starting fresh (no checkpoint found)");
193    }
194
195    let legacy = crate::foundation::migrate::LegacyMemoryReader::for_default_home();
196    let mut migrated = 0usize;
197    let mut failed = 0usize;
198    let mut tx = crate::foundation::migrate::Migration::new(&backend, &args.checkpoint);
199
200    for batch in legacy.batches(args.batch_size) {
201        for item in batch {
202            match tx.migrate_one(item) {
203                Ok(crate::foundation::migrate::MigrationOutcome::Inserted(id)) => {
204                    println!("  + {id}");
205                    migrated += 1;
206                }
207                Ok(crate::foundation::migrate::MigrationOutcome::Skipped(id)) => {
208                    println!("  ~ {id} (already in brain)");
209                }
210                Err(e) => {
211                    println!("  ! {e}");
212                    failed += 1;
213                }
214            }
215        }
216    }
217
218    println!("\nMigration summary");
219    println!("  inserted: {migrated}");
220    println!("  failed:   {failed}");
221
222    if args.archive_legacy {
223        match crate::foundation::migrate::archive_legacy_default() {
224            Ok(archive_path) => {
225                println!("\nLegacy store archived to: {}", archive_path.display());
226            }
227            Err(e) => {
228                println!("\nArchive failed: {e}");
229                return 2;
230            }
231        }
232    }
233
234    if failed > 0 { 1 } else { 0 }
235}
236
237impl Default for MigrateBrainArgs {
238    fn default() -> Self {
239        Self {
240            socket: None,
241            dry_run: false,
242            archive_legacy: false,
243            checkpoint: PathBuf::from("~/.oxicode/migration/brain.json"),
244            batch_size: 64,
245        }
246    }
247}