1use 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
29const LEGACY_MCP_KEYS: &[&str] = &["kuzu-memory", "kuzu_memory"];
37
38const TRUSTY_KEY: &str = "trusty-memory";
40
41#[derive(Debug, Clone, ValueEnum)]
51pub enum MigrateTarget {
52 KuzuMemory,
54 #[value(name = "kuzu-data")]
57 KuzuData,
58}
59
60#[derive(Debug, PartialEq, Eq)]
67pub enum ConfigMigrateStatus {
68 Migrated,
70 AlreadyMigrated,
72 Skipped,
74 Failed(String),
76}
77
78#[derive(Debug)]
85pub struct ConfigMigrateResult {
86 pub path: PathBuf,
87 pub status: ConfigMigrateStatus,
88}
89
90pub fn handle_migrate(
102 target: MigrateTarget,
103 dry_run: bool,
104 _config_only: bool,
105 kuzu_from: Option<std::path::PathBuf>,
106 kuzu_palace: Option<String>,
107 kuzu_limit: Option<usize>,
108) -> Result<()> {
109 match target {
110 MigrateTarget::KuzuMemory => {
111 if dry_run {
112 println!("{} Dry run — no files will be modified.\n", "·".dimmed());
113 }
114 run_config_phase(dry_run)
115 }
116 MigrateTarget::KuzuData => {
117 let from = kuzu_from
118 .ok_or_else(|| anyhow::anyhow!("migrate kuzu-data requires --from <store.redb>"))?;
119 let palace = kuzu_palace
120 .ok_or_else(|| anyhow::anyhow!("migrate kuzu-data requires --palace <name>"))?;
121 crate::commands::kuzu_migrate::handle_kuzu_data_migrate(
122 &from, &palace, dry_run, kuzu_limit,
123 )
124 }
125 }
126}
127
128fn run_config_phase(dry_run: bool) -> Result<()> {
135 let home =
136 dirs::home_dir().ok_or_else(|| anyhow::anyhow!("could not determine home directory"))?;
137 println!(
138 "🔍 Scanning for Claude MCP settings under {}…",
139 home.display()
140 );
141
142 let files = discover_claude_settings(&home, default_settings_max_depth());
143 if files.is_empty() {
144 println!("{} No Claude settings files found.", "·".dimmed());
145 return Ok(());
146 }
147 println!("{} Found {} settings file(s).\n", "·".dimmed(), files.len());
148
149 let mut migrated = 0usize;
150 let mut already = 0usize;
151 let mut skipped = 0usize;
152 let mut failed = 0usize;
153
154 for (i, path) in files.iter().enumerate() {
155 let result = migrate_config_file(path, dry_run);
156 print_config_line(i + 1, files.len(), &result);
157 match result.status {
158 ConfigMigrateStatus::Migrated => migrated += 1,
159 ConfigMigrateStatus::AlreadyMigrated => already += 1,
160 ConfigMigrateStatus::Skipped => skipped += 1,
161 ConfigMigrateStatus::Failed(_) => failed += 1,
162 }
163 }
164
165 println!();
166 if dry_run {
167 println!(
168 "{} MCP config dry run: {} would migrate, {} already migrated, {} skipped, {} failed",
169 "·".dimmed(),
170 migrated,
171 already,
172 skipped,
173 failed
174 );
175 } else {
176 println!(
177 "{} MCP config: {} migrated, {} already migrated, {} skipped, {} failed",
178 "✓".green(),
179 migrated,
180 already,
181 skipped,
182 failed
183 );
184 }
185 Ok(())
186}
187
188pub fn migrate_config_file(path: &Path, dry_run: bool) -> ConfigMigrateResult {
202 let fail = |msg: String| ConfigMigrateResult {
203 path: path.to_path_buf(),
204 status: ConfigMigrateStatus::Failed(msg),
205 };
206
207 let content = match std::fs::read_to_string(path) {
208 Ok(c) => c,
209 Err(e) => return fail(format!("read: {e}")),
210 };
211 let mut root: Value = match serde_json::from_str(&content) {
212 Ok(v) => v,
213 Err(e) => return fail(format!("parse: {e}")),
214 };
215
216 let servers = match root.get_mut("mcpServers").and_then(Value::as_object_mut) {
217 Some(s) => s,
218 None => {
220 return ConfigMigrateResult {
221 path: path.to_path_buf(),
222 status: ConfigMigrateStatus::Skipped,
223 }
224 }
225 };
226
227 if servers.contains_key(TRUSTY_KEY) {
230 return ConfigMigrateResult {
231 path: path.to_path_buf(),
232 status: ConfigMigrateStatus::AlreadyMigrated,
233 };
234 }
235
236 let legacy_present = LEGACY_MCP_KEYS.iter().any(|k| servers.contains_key(*k));
237 if !legacy_present {
238 return ConfigMigrateResult {
239 path: path.to_path_buf(),
240 status: ConfigMigrateStatus::Skipped,
241 };
242 }
243
244 for k in LEGACY_MCP_KEYS {
246 servers.remove(*k);
247 }
248 servers.insert(
249 TRUSTY_KEY.to_string(),
250 mcp_server_entry(TRUSTY_KEY, &["serve", "--stdio"]),
251 );
252
253 if dry_run {
254 return ConfigMigrateResult {
255 path: path.to_path_buf(),
256 status: ConfigMigrateStatus::Migrated,
257 };
258 }
259
260 match write_json_atomic(path, &root) {
261 Ok(()) => ConfigMigrateResult {
262 path: path.to_path_buf(),
263 status: ConfigMigrateStatus::Migrated,
264 },
265 Err(e) => fail(format!("write: {e}")),
266 }
267}
268
269fn print_config_line(idx: usize, total: usize, r: &ConfigMigrateResult) {
275 let prefix = format!("[{idx}/{total}]");
276 let path = r.path.display().to_string();
277 match &r.status {
278 ConfigMigrateStatus::Migrated => println!(" {} {} {}", prefix.dimmed(), "✓".green(), path),
279 ConfigMigrateStatus::AlreadyMigrated => println!(
280 " {} {} {} {}",
281 prefix.dimmed(),
282 "↻".cyan(),
283 path.dimmed(),
284 "(already migrated)".dimmed()
285 ),
286 ConfigMigrateStatus::Skipped => println!(
287 " {} {} {} {}",
288 prefix.dimmed(),
289 "·".dimmed(),
290 path.dimmed(),
291 "(no kuzu-memory entry)".dimmed()
292 ),
293 ConfigMigrateStatus::Failed(msg) => println!(
294 " {} {} {} {}",
295 prefix.dimmed(),
296 "✗".red(),
297 path.dimmed(),
298 format!("({msg})").red()
299 ),
300 }
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 #[test]
310 fn test_migrate_config_replaces_dashed_key() {
311 let tmp = tempfile::tempdir().expect("tempdir");
312 let path = tmp.path().join("settings.local.json");
313 let input = serde_json::json!({
314 "theme": "dark",
315 "mcpServers": {
316 "kuzu-memory": {
317 "command": "kuzu-memory",
318 "args": ["serve"]
319 },
320 "other-server": { "command": "other" }
321 }
322 });
323 std::fs::write(&path, serde_json::to_string_pretty(&input).unwrap()).expect("write input");
324
325 let result = migrate_config_file(&path, false);
326 assert_eq!(result.status, ConfigMigrateStatus::Migrated);
327
328 let rewritten: Value =
329 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
330 let servers = rewritten["mcpServers"].as_object().unwrap();
331 assert!(
332 !servers.contains_key("kuzu-memory"),
333 "legacy key should be gone"
334 );
335 assert!(servers.contains_key("trusty-memory"), "trusty key missing");
336 assert!(
337 servers.contains_key("other-server"),
338 "unrelated server dropped"
339 );
340 assert_eq!(
341 rewritten["theme"], "dark",
342 "unrelated top-level key dropped"
343 );
344 assert_eq!(servers["trusty-memory"]["command"], "trusty-memory");
345 assert_eq!(servers["trusty-memory"]["args"][0], "serve");
346 assert_eq!(servers["trusty-memory"]["args"][1], "--stdio");
347
348 assert!(
350 path.with_file_name("settings.local.json.bak").exists(),
351 "backup file missing"
352 );
353 }
354
355 #[test]
357 fn test_migrate_config_replaces_underscored_key() {
358 let tmp = tempfile::tempdir().expect("tempdir");
359 let path = tmp.path().join("settings.json");
360 let input = serde_json::json!({
361 "mcpServers": {
362 "kuzu_memory": { "command": "kuzu-memory", "args": ["serve"] }
363 }
364 });
365 std::fs::write(&path, serde_json::to_string_pretty(&input).unwrap()).expect("write input");
366
367 let result = migrate_config_file(&path, false);
368 assert_eq!(result.status, ConfigMigrateStatus::Migrated);
369
370 let rewritten: Value =
371 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
372 let servers = rewritten["mcpServers"].as_object().unwrap();
373 assert!(!servers.contains_key("kuzu_memory"));
374 assert!(servers.contains_key("trusty-memory"));
375 }
376
377 #[test]
380 fn test_migrate_config_idempotent() {
381 let tmp = tempfile::tempdir().expect("tempdir");
382 let path = tmp.path().join("settings.json");
383 let input = serde_json::json!({
384 "mcpServers": {
385 "trusty-memory": {
386 "command": "trusty-memory",
387 "args": ["serve", "--stdio"]
388 }
389 }
390 });
391 let serialized = serde_json::to_string_pretty(&input).unwrap();
392 std::fs::write(&path, &serialized).expect("write input");
393
394 let result = migrate_config_file(&path, false);
395 assert_eq!(result.status, ConfigMigrateStatus::AlreadyMigrated);
396
397 assert_eq!(
399 std::fs::read_to_string(&path).unwrap(),
400 serialized,
401 "file should be untouched"
402 );
403 assert!(
404 !path.with_file_name("settings.json.bak").exists(),
405 "no backup should be written for a skipped file"
406 );
407 }
408
409 #[test]
412 fn test_migrate_config_skips_when_no_legacy_key() {
413 let tmp = tempfile::tempdir().expect("tempdir");
414 let path = tmp.path().join("settings.json");
415 let input = serde_json::json!({
416 "mcpServers": {
417 "some-other-server": { "command": "x" }
418 }
419 });
420 let serialized = serde_json::to_string_pretty(&input).unwrap();
421 std::fs::write(&path, &serialized).expect("write input");
422
423 let result = migrate_config_file(&path, false);
424 assert_eq!(result.status, ConfigMigrateStatus::Skipped);
425 assert_eq!(
426 std::fs::read_to_string(&path).unwrap(),
427 serialized,
428 "file must be untouched"
429 );
430 }
431
432 #[test]
435 fn test_migrate_config_dry_run_does_not_write() {
436 let tmp = tempfile::tempdir().expect("tempdir");
437 let path = tmp.path().join("settings.json");
438 let input = serde_json::json!({
439 "mcpServers": {
440 "kuzu-memory": { "command": "kuzu-memory", "args": ["serve"] }
441 }
442 });
443 let serialized = serde_json::to_string_pretty(&input).unwrap();
444 std::fs::write(&path, &serialized).expect("write input");
445
446 let result = migrate_config_file(&path, true);
447 assert_eq!(result.status, ConfigMigrateStatus::Migrated);
448
449 assert_eq!(
451 std::fs::read_to_string(&path).unwrap(),
452 serialized,
453 "dry run must not write the file"
454 );
455 assert!(
456 !path.with_file_name("settings.json.bak").exists(),
457 "dry run must not produce a backup"
458 );
459 }
460}