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")]
56 KuzuData,
57}
58
59#[derive(Debug, PartialEq, Eq)]
66pub enum ConfigMigrateStatus {
67 Migrated,
69 AlreadyMigrated,
71 Skipped,
73 Failed(String),
75}
76
77#[derive(Debug)]
84pub struct ConfigMigrateResult {
85 pub path: PathBuf,
86 pub status: ConfigMigrateStatus,
87}
88
89pub 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
127fn 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
187pub 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 None => {
219 return ConfigMigrateResult {
220 path: path.to_path_buf(),
221 status: ConfigMigrateStatus::Skipped,
222 }
223 }
224 };
225
226 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 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
268fn 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 #[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 assert!(
349 path.with_file_name("settings.local.json.bak").exists(),
350 "backup file missing"
351 );
352 }
353
354 #[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 #[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 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 #[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 #[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 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}