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)]
50pub enum MigrateTarget {
51 KuzuMemory,
53}
54
55#[derive(Debug, PartialEq, Eq)]
62pub enum ConfigMigrateStatus {
63 Migrated,
65 AlreadyMigrated,
67 Skipped,
69 Failed(String),
71}
72
73#[derive(Debug)]
80pub struct ConfigMigrateResult {
81 pub path: PathBuf,
82 pub status: ConfigMigrateStatus,
83}
84
85pub fn handle_migrate(target: MigrateTarget, dry_run: bool, _config_only: bool) -> Result<()> {
95 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
110fn 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
170pub 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 None => {
202 return ConfigMigrateResult {
203 path: path.to_path_buf(),
204 status: ConfigMigrateStatus::Skipped,
205 }
206 }
207 };
208
209 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 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
251fn 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 #[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 assert!(
332 path.with_file_name("settings.local.json.bak").exists(),
333 "backup file missing"
334 );
335 }
336
337 #[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 #[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 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 #[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 #[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 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}