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 async fn handle_migrate(cmd: MigrationCommands) -> i32 {
32    match cmd {
33        MigrationCommands::Brain(args) => handle_migrate_brain(args).await,
34    }
35}
36
37async fn 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    // Probe the daemon so the printed health is a live measurement —
57    // construction alone leaves the cached state at `Unavailable`.
58    let probe = backend.ping().await;
59    println!(
60        "\nbackend health:      {}",
61        match &probe {
62            Ok(()) => "ok: oxibrain daemon connected".to_string(),
63            Err(e) => format!("degraded ({e})"),
64        }
65    );
66
67    if args.dry_run {
68        println!("\nDry run complete. Re-run without --dry-run to perform the migration.");
69        return 0;
70    }
71
72    let checkpoint = crate::foundation::migrate::Checkpoint::load(&args.checkpoint);
73    if let Some(last) = checkpoint.last_id() {
74        println!("resuming after id:   {last}");
75    } else {
76        println!("starting fresh (no checkpoint found)");
77    }
78
79    let legacy = crate::foundation::migrate::LegacyMemoryReader::for_default_home();
80    let mut migrated = 0usize;
81    let mut failed = 0usize;
82    let mut tx = crate::foundation::migrate::Migration::new(&backend, &args.checkpoint);
83
84    for batch in legacy.batches(args.batch_size) {
85        for item in batch {
86            match tx.migrate_one(item) {
87                Ok(crate::foundation::migrate::MigrationOutcome::Inserted(id)) => {
88                    println!("  + {id}");
89                    migrated += 1;
90                }
91                Ok(crate::foundation::migrate::MigrationOutcome::Skipped(id)) => {
92                    println!("  ~ {id} (already in brain)");
93                }
94                Err(e) => {
95                    println!("  ! {e}");
96                    failed += 1;
97                }
98            }
99        }
100    }
101
102    println!("\nMigration summary");
103    println!("  inserted: {migrated}");
104    println!("  failed:   {failed}");
105
106    if args.archive_legacy {
107        match crate::foundation::migrate::archive_legacy_default() {
108            Ok(archive_path) => {
109                println!("\nLegacy store archived to: {}", archive_path.display());
110            }
111            Err(e) => {
112                println!("\nArchive failed: {e}");
113                return 2;
114            }
115        }
116    }
117
118    if failed > 0 { 1 } else { 0 }
119}
120
121impl Default for MigrateBrainArgs {
122    fn default() -> Self {
123        Self {
124            socket: None,
125            dry_run: false,
126            archive_legacy: false,
127            checkpoint: PathBuf::from("~/.oxicode/migration/brain.json"),
128            batch_size: 64,
129        }
130    }
131}