Skip to main content

trusty_memory/commands/
migrate.rs

1//! Handler for `trusty-memory migrate kuzu-memory`.
2//!
3//! Why: users currently running the kuzu-memory MCP server need a one-command
4//! switch to trusty-memory that rewrites every Claude settings file referring
5//! to the legacy server, with the same idempotency / atomic-write semantics as
6//! `trusty-search migrate mcp-vector-search`. Doing this by hand across global
7//! and per-project settings files is error-prone, so the binary owns it.
8//! What: `handle_migrate` walks every discovered Claude settings file via
9//! `trusty_common::claude_config`, swaps any `kuzu-memory` / `kuzu_memory`
10//! `mcpServers` entry for a canonical `trusty-memory` entry, and prints a
11//! summary table. `--dry-run` prints the plan without writing. `--config-only`
12//! is accepted for parity with `trusty-search migrate` (today the migration
13//! has no other phase so it is effectively a no-op flag).
14//! Test: unit tests cover (a) a vanilla rewrite preserving unrelated keys,
15//! (b) idempotency when a `trusty-memory` entry is already present, and
16//! (c) the no-op case when no legacy key is present.
17//!
18//! Run manually with: `cargo run -p trusty-memory -- migrate kuzu-memory --dry-run`.
19
20use anyhow::Result;
21use clap::ValueEnum;
22use colored::Colorize;
23use serde_json::Value;
24use std::path::{Path, PathBuf};
25use trusty_common::claude_config::{
26    default_settings_max_depth, discover_claude_settings, mcp_server_entry, write_json_atomic,
27};
28
29/// The MCP server keys (both spellings) we replace with `trusty-memory`.
30///
31/// Why: we have seen both `kuzu-memory` and `kuzu_memory` in the wild in
32/// `mcpServers` blocks; treat them as equivalent legacy aliases.
33/// What: a static slice scanned for membership inside each settings file.
34/// Test: `test_migrate_config_replaces_dashed_key` and
35/// `test_migrate_config_replaces_underscored_key` cover both forms.
36const LEGACY_MCP_KEYS: &[&str] = &["kuzu-memory", "kuzu_memory"];
37
38/// The canonical key written for the migrated trusty-memory MCP server.
39const TRUSTY_KEY: &str = "trusty-memory";
40
41/// What the user is migrating *from*.
42///
43/// Why: model the migration source as an enum (validated at parse time by
44/// clap) so additional sources can be added later without changing the
45/// CLI surface.
46/// What: two variants today — `kuzu-memory` (config migration) and
47/// `kuzu-data` (deprecated alias for `import kuzu`, #277).
48/// Test: `cargo run -p trusty-memory -- migrate bogus` → clap rejects with
49/// a usage hint.
50#[derive(Debug, Clone, ValueEnum)]
51pub enum MigrateTarget {
52    /// Migrate from kuzu-memory (rewrites Claude `mcpServers` entries).
53    KuzuMemory,
54    /// Deprecated alias for `import kuzu --from <path>` (#277).
55    #[value(name = "kuzu-data")]
56    KuzuData,
57}
58
59/// Outcome of attempting to migrate one Claude settings file.
60///
61/// Why: the summary table distinguishes a real rewrite from an
62/// already-migrated no-op, a "no relevant key" skip, and a hard failure.
63/// What: enumerates the four terminal states of `migrate_config_file`.
64/// Test: unit tests assert `Migrated`, `AlreadyMigrated`, and `Skipped`.
65#[derive(Debug, PartialEq, Eq)]
66pub enum ConfigMigrateStatus {
67    /// The file contained a legacy key and was rewritten.
68    Migrated,
69    /// The file already contained a `trusty-memory` key — left untouched.
70    AlreadyMigrated,
71    /// No legacy key (and no `trusty-memory` key) — nothing to do.
72    Skipped,
73    /// An I/O or parse error occurred.
74    Failed(String),
75}
76
77/// Result of migrating one Claude settings file (path + terminal status).
78///
79/// Why: pairs the file path with its outcome so the summary renderer can
80/// print one line per file.
81/// What: returned by `migrate_config_file`.
82/// Test: unit tests inspect `status` after rewriting fixture files.
83#[derive(Debug)]
84pub struct ConfigMigrateResult {
85    pub path: PathBuf,
86    pub status: ConfigMigrateStatus,
87}
88
89/// Entry point for `trusty-memory migrate`.
90///
91/// Why: a single command that switches a machine from kuzu-memory to
92/// trusty-memory. `kuzu-memory` rewrites every Claude MCP settings file.
93/// `kuzu-data` forwards to `import kuzu` (deprecated, #277).
94/// What: dispatches to the appropriate handler based on the `target` variant.
95/// The `_config_only` flag is accepted for CLI parity with
96/// `trusty-search migrate` but only applies to `kuzu-memory` (the config
97/// migration).
98/// Test: `migrate kuzu-memory --dry-run` enumerates without writing.
99pub fn handle_migrate(
100    target: MigrateTarget,
101    dry_run: bool,
102    _config_only: bool,
103    kuzu_from: Option<std::path::PathBuf>,
104    kuzu_palace: Option<String>,
105    kuzu_limit: Option<usize>,
106) -> Result<()> {
107    match target {
108        MigrateTarget::KuzuMemory => {
109            if dry_run {
110                println!("{} Dry run — no files will be modified.\n", "·".dimmed());
111            }
112            run_config_phase(dry_run)
113        }
114        MigrateTarget::KuzuData => {
115            let from = kuzu_from.ok_or_else(|| {
116                anyhow::anyhow!("migrate kuzu-data requires --from <.kuzu-memory>")
117            })?;
118            let palace = kuzu_palace
119                .ok_or_else(|| anyhow::anyhow!("migrate kuzu-data requires --palace <name>"))?;
120            crate::commands::kuzu_migrate::handle_kuzu_data_migrate(
121                &from, &palace, dry_run, kuzu_limit,
122            )
123        }
124    }
125}
126
127/// Scan + rewrite every Claude settings file.
128///
129/// Why: keeps the orchestration (scan → migrate → summarize) separate from
130/// the per-file surgery in `migrate_config_file`.
131/// What: locates settings files, migrates each, prints a summary table.
132/// Test: covered indirectly by `--dry-run` runs and the unit tests below.
133fn run_config_phase(dry_run: bool) -> Result<()> {
134    let home =
135        dirs::home_dir().ok_or_else(|| anyhow::anyhow!("could not determine home directory"))?;
136    println!(
137        "🔍 Scanning for Claude MCP settings under {}…",
138        home.display()
139    );
140
141    let files = discover_claude_settings(&home, default_settings_max_depth());
142    if files.is_empty() {
143        println!("{} No Claude settings files found.", "·".dimmed());
144        return Ok(());
145    }
146    println!("{} Found {} settings file(s).\n", "·".dimmed(), files.len());
147
148    let mut migrated = 0usize;
149    let mut already = 0usize;
150    let mut skipped = 0usize;
151    let mut failed = 0usize;
152
153    for (i, path) in files.iter().enumerate() {
154        let result = migrate_config_file(path, dry_run);
155        print_config_line(i + 1, files.len(), &result);
156        match result.status {
157            ConfigMigrateStatus::Migrated => migrated += 1,
158            ConfigMigrateStatus::AlreadyMigrated => already += 1,
159            ConfigMigrateStatus::Skipped => skipped += 1,
160            ConfigMigrateStatus::Failed(_) => failed += 1,
161        }
162    }
163
164    println!();
165    if dry_run {
166        println!(
167            "{} MCP config dry run: {} would migrate, {} already migrated, {} skipped, {} failed",
168            "·".dimmed(),
169            migrated,
170            already,
171            skipped,
172            failed
173        );
174    } else {
175        println!(
176            "{} MCP config: {} migrated, {} already migrated, {} skipped, {} failed",
177            "✓".green(),
178            migrated,
179            already,
180            skipped,
181            failed
182        );
183    }
184    Ok(())
185}
186
187/// Rewrite one Claude settings file, replacing any legacy kuzu-memory MCP
188/// server entry with a `trusty-memory` entry.
189///
190/// Why: this is the load-bearing surgery — it must preserve every unrelated
191/// JSON key, be idempotent across repeated runs, and never corrupt the file
192/// on failure (atomic write + `.bak` backup, courtesy of
193/// `trusty_common::claude_config::write_json_atomic`).
194/// What: parses the file as `serde_json::Value`, swaps the key inside
195/// `mcpServers`, then atomically rewrites the file.
196/// Test: `test_migrate_config_replaces_dashed_key`,
197/// `test_migrate_config_replaces_underscored_key`, and
198/// `test_migrate_config_idempotent` cover the rewrite, the alternate
199/// spelling, and the no-op-on-already-migrated paths.
200pub fn migrate_config_file(path: &Path, dry_run: bool) -> ConfigMigrateResult {
201    let fail = |msg: String| ConfigMigrateResult {
202        path: path.to_path_buf(),
203        status: ConfigMigrateStatus::Failed(msg),
204    };
205
206    let content = match std::fs::read_to_string(path) {
207        Ok(c) => c,
208        Err(e) => return fail(format!("read: {e}")),
209    };
210    let mut root: Value = match serde_json::from_str(&content) {
211        Ok(v) => v,
212        Err(e) => return fail(format!("parse: {e}")),
213    };
214
215    let servers = match root.get_mut("mcpServers").and_then(Value::as_object_mut) {
216        Some(s) => s,
217        // No mcpServers block at all — nothing to migrate.
218        None => {
219            return ConfigMigrateResult {
220                path: path.to_path_buf(),
221                status: ConfigMigrateStatus::Skipped,
222            }
223        }
224    };
225
226    // Idempotency: a trusty-memory entry already present means a previous run
227    // (or the user) already migrated this file — never double-migrate.
228    if servers.contains_key(TRUSTY_KEY) {
229        return ConfigMigrateResult {
230            path: path.to_path_buf(),
231            status: ConfigMigrateStatus::AlreadyMigrated,
232        };
233    }
234
235    let legacy_present = LEGACY_MCP_KEYS.iter().any(|k| servers.contains_key(*k));
236    if !legacy_present {
237        return ConfigMigrateResult {
238            path: path.to_path_buf(),
239            status: ConfigMigrateStatus::Skipped,
240        };
241    }
242
243    // Drop every legacy key and insert the canonical trusty-memory entry.
244    for k in LEGACY_MCP_KEYS {
245        servers.remove(*k);
246    }
247    servers.insert(
248        TRUSTY_KEY.to_string(),
249        mcp_server_entry(TRUSTY_KEY, &["serve", "--stdio"]),
250    );
251
252    if dry_run {
253        return ConfigMigrateResult {
254            path: path.to_path_buf(),
255            status: ConfigMigrateStatus::Migrated,
256        };
257    }
258
259    match write_json_atomic(path, &root) {
260        Ok(()) => ConfigMigrateResult {
261            path: path.to_path_buf(),
262            status: ConfigMigrateStatus::Migrated,
263        },
264        Err(e) => fail(format!("write: {e}")),
265    }
266}
267
268/// Render one config-migration result line for the summary table.
269///
270/// Why: keeps colorised, aligned output away from the orchestration logic.
271/// What: one line per file with a status glyph.
272/// Test: not unit-tested (pure formatting); covered by manual smoke runs.
273fn print_config_line(idx: usize, total: usize, r: &ConfigMigrateResult) {
274    let prefix = format!("[{idx}/{total}]");
275    let path = r.path.display().to_string();
276    match &r.status {
277        ConfigMigrateStatus::Migrated => println!("  {} {} {}", prefix.dimmed(), "✓".green(), path),
278        ConfigMigrateStatus::AlreadyMigrated => println!(
279            "  {} {} {} {}",
280            prefix.dimmed(),
281            "↻".cyan(),
282            path.dimmed(),
283            "(already migrated)".dimmed()
284        ),
285        ConfigMigrateStatus::Skipped => println!(
286            "  {} {} {} {}",
287            prefix.dimmed(),
288            "·".dimmed(),
289            path.dimmed(),
290            "(no kuzu-memory entry)".dimmed()
291        ),
292        ConfigMigrateStatus::Failed(msg) => println!(
293            "  {} {} {} {}",
294            prefix.dimmed(),
295            "✗".red(),
296            path.dimmed(),
297            format!("({msg})").red()
298        ),
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    /// Why: the core surgery — a `kuzu-memory` key must be removed and the
307    /// canonical trusty-memory key inserted, while unrelated keys survive.
308    #[test]
309    fn test_migrate_config_replaces_dashed_key() {
310        let tmp = tempfile::tempdir().expect("tempdir");
311        let path = tmp.path().join("settings.local.json");
312        let input = serde_json::json!({
313            "theme": "dark",
314            "mcpServers": {
315                "kuzu-memory": {
316                    "command": "kuzu-memory",
317                    "args": ["serve"]
318                },
319                "other-server": { "command": "other" }
320            }
321        });
322        std::fs::write(&path, serde_json::to_string_pretty(&input).unwrap()).expect("write input");
323
324        let result = migrate_config_file(&path, false);
325        assert_eq!(result.status, ConfigMigrateStatus::Migrated);
326
327        let rewritten: Value =
328            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
329        let servers = rewritten["mcpServers"].as_object().unwrap();
330        assert!(
331            !servers.contains_key("kuzu-memory"),
332            "legacy key should be gone"
333        );
334        assert!(servers.contains_key("trusty-memory"), "trusty key missing");
335        assert!(
336            servers.contains_key("other-server"),
337            "unrelated server dropped"
338        );
339        assert_eq!(
340            rewritten["theme"], "dark",
341            "unrelated top-level key dropped"
342        );
343        assert_eq!(servers["trusty-memory"]["command"], "trusty-memory");
344        assert_eq!(servers["trusty-memory"]["args"][0], "serve");
345        assert_eq!(servers["trusty-memory"]["args"][1], "--stdio");
346
347        // Backup preserves multi-dot filename: settings.local.json.bak
348        assert!(
349            path.with_file_name("settings.local.json.bak").exists(),
350            "backup file missing"
351        );
352    }
353
354    /// Why: the alternate `kuzu_memory` spelling must be treated identically.
355    #[test]
356    fn test_migrate_config_replaces_underscored_key() {
357        let tmp = tempfile::tempdir().expect("tempdir");
358        let path = tmp.path().join("settings.json");
359        let input = serde_json::json!({
360            "mcpServers": {
361                "kuzu_memory": { "command": "kuzu-memory", "args": ["serve"] }
362            }
363        });
364        std::fs::write(&path, serde_json::to_string_pretty(&input).unwrap()).expect("write input");
365
366        let result = migrate_config_file(&path, false);
367        assert_eq!(result.status, ConfigMigrateStatus::Migrated);
368
369        let rewritten: Value =
370            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
371        let servers = rewritten["mcpServers"].as_object().unwrap();
372        assert!(!servers.contains_key("kuzu_memory"));
373        assert!(servers.contains_key("trusty-memory"));
374    }
375
376    /// Why: a file already carrying a `trusty-memory` entry must be left
377    /// untouched so repeated `migrate` runs are safe.
378    #[test]
379    fn test_migrate_config_idempotent() {
380        let tmp = tempfile::tempdir().expect("tempdir");
381        let path = tmp.path().join("settings.json");
382        let input = serde_json::json!({
383            "mcpServers": {
384                "trusty-memory": {
385                    "command": "trusty-memory",
386                    "args": ["serve", "--stdio"]
387                }
388            }
389        });
390        let serialized = serde_json::to_string_pretty(&input).unwrap();
391        std::fs::write(&path, &serialized).expect("write input");
392
393        let result = migrate_config_file(&path, false);
394        assert_eq!(result.status, ConfigMigrateStatus::AlreadyMigrated);
395
396        // File must be byte-for-byte unchanged.
397        assert_eq!(
398            std::fs::read_to_string(&path).unwrap(),
399            serialized,
400            "file should be untouched"
401        );
402        assert!(
403            !path.with_file_name("settings.json.bak").exists(),
404            "no backup should be written for a skipped file"
405        );
406    }
407
408    /// Why: a settings file with no `kuzu-memory` entry (and no `trusty-memory`
409    /// entry) is reported as `Skipped`, not `Migrated`, and not modified.
410    #[test]
411    fn test_migrate_config_skips_when_no_legacy_key() {
412        let tmp = tempfile::tempdir().expect("tempdir");
413        let path = tmp.path().join("settings.json");
414        let input = serde_json::json!({
415            "mcpServers": {
416                "some-other-server": { "command": "x" }
417            }
418        });
419        let serialized = serde_json::to_string_pretty(&input).unwrap();
420        std::fs::write(&path, &serialized).expect("write input");
421
422        let result = migrate_config_file(&path, false);
423        assert_eq!(result.status, ConfigMigrateStatus::Skipped);
424        assert_eq!(
425            std::fs::read_to_string(&path).unwrap(),
426            serialized,
427            "file must be untouched"
428        );
429    }
430
431    /// Why: `--dry-run` must report what *would* change without writing the
432    /// file to disk or producing a backup.
433    #[test]
434    fn test_migrate_config_dry_run_does_not_write() {
435        let tmp = tempfile::tempdir().expect("tempdir");
436        let path = tmp.path().join("settings.json");
437        let input = serde_json::json!({
438            "mcpServers": {
439                "kuzu-memory": { "command": "kuzu-memory", "args": ["serve"] }
440            }
441        });
442        let serialized = serde_json::to_string_pretty(&input).unwrap();
443        std::fs::write(&path, &serialized).expect("write input");
444
445        let result = migrate_config_file(&path, true);
446        assert_eq!(result.status, ConfigMigrateStatus::Migrated);
447
448        // File on disk must be byte-for-byte unchanged.
449        assert_eq!(
450            std::fs::read_to_string(&path).unwrap(),
451            serialized,
452            "dry run must not write the file"
453        );
454        assert!(
455            !path.with_file_name("settings.json.bak").exists(),
456            "dry run must not produce a backup"
457        );
458    }
459}