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