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!("{} Dry run — no files will be modified.\n", "·".dimmed());
102 }
103
104 run_config_phase(dry_run)
105}
106
107fn run_config_phase(dry_run: bool) -> Result<()> {
114 let home =
115 dirs::home_dir().ok_or_else(|| anyhow::anyhow!("could not determine home directory"))?;
116 println!(
117 "🔍 Scanning for Claude MCP settings under {}…",
118 home.display()
119 );
120
121 let files = discover_claude_settings(&home, default_settings_max_depth());
122 if files.is_empty() {
123 println!("{} No Claude settings files found.", "·".dimmed());
124 return Ok(());
125 }
126 println!("{} Found {} settings file(s).\n", "·".dimmed(), files.len());
127
128 let mut migrated = 0usize;
129 let mut already = 0usize;
130 let mut skipped = 0usize;
131 let mut failed = 0usize;
132
133 for (i, path) in files.iter().enumerate() {
134 let result = migrate_config_file(path, dry_run);
135 print_config_line(i + 1, files.len(), &result);
136 match result.status {
137 ConfigMigrateStatus::Migrated => migrated += 1,
138 ConfigMigrateStatus::AlreadyMigrated => already += 1,
139 ConfigMigrateStatus::Skipped => skipped += 1,
140 ConfigMigrateStatus::Failed(_) => failed += 1,
141 }
142 }
143
144 println!();
145 if dry_run {
146 println!(
147 "{} MCP config dry run: {} would migrate, {} already migrated, {} skipped, {} failed",
148 "·".dimmed(),
149 migrated,
150 already,
151 skipped,
152 failed
153 );
154 } else {
155 println!(
156 "{} MCP config: {} migrated, {} already migrated, {} skipped, {} failed",
157 "✓".green(),
158 migrated,
159 already,
160 skipped,
161 failed
162 );
163 }
164 Ok(())
165}
166
167pub fn migrate_config_file(path: &Path, dry_run: bool) -> ConfigMigrateResult {
181 let fail = |msg: String| ConfigMigrateResult {
182 path: path.to_path_buf(),
183 status: ConfigMigrateStatus::Failed(msg),
184 };
185
186 let content = match std::fs::read_to_string(path) {
187 Ok(c) => c,
188 Err(e) => return fail(format!("read: {e}")),
189 };
190 let mut root: Value = match serde_json::from_str(&content) {
191 Ok(v) => v,
192 Err(e) => return fail(format!("parse: {e}")),
193 };
194
195 let servers = match root.get_mut("mcpServers").and_then(Value::as_object_mut) {
196 Some(s) => s,
197 None => {
199 return ConfigMigrateResult {
200 path: path.to_path_buf(),
201 status: ConfigMigrateStatus::Skipped,
202 }
203 }
204 };
205
206 if servers.contains_key(TRUSTY_KEY) {
209 return ConfigMigrateResult {
210 path: path.to_path_buf(),
211 status: ConfigMigrateStatus::AlreadyMigrated,
212 };
213 }
214
215 let legacy_present = LEGACY_MCP_KEYS.iter().any(|k| servers.contains_key(*k));
216 if !legacy_present {
217 return ConfigMigrateResult {
218 path: path.to_path_buf(),
219 status: ConfigMigrateStatus::Skipped,
220 };
221 }
222
223 for k in LEGACY_MCP_KEYS {
225 servers.remove(*k);
226 }
227 servers.insert(
228 TRUSTY_KEY.to_string(),
229 mcp_server_entry(TRUSTY_KEY, &["serve"]),
230 );
231
232 if dry_run {
233 return ConfigMigrateResult {
234 path: path.to_path_buf(),
235 status: ConfigMigrateStatus::Migrated,
236 };
237 }
238
239 match write_json_atomic(path, &root) {
240 Ok(()) => ConfigMigrateResult {
241 path: path.to_path_buf(),
242 status: ConfigMigrateStatus::Migrated,
243 },
244 Err(e) => fail(format!("write: {e}")),
245 }
246}
247
248fn print_config_line(idx: usize, total: usize, r: &ConfigMigrateResult) {
254 let prefix = format!("[{idx}/{total}]");
255 let path = r.path.display().to_string();
256 match &r.status {
257 ConfigMigrateStatus::Migrated => println!(" {} {} {}", prefix.dimmed(), "✓".green(), path),
258 ConfigMigrateStatus::AlreadyMigrated => println!(
259 " {} {} {} {}",
260 prefix.dimmed(),
261 "↻".cyan(),
262 path.dimmed(),
263 "(already migrated)".dimmed()
264 ),
265 ConfigMigrateStatus::Skipped => println!(
266 " {} {} {} {}",
267 prefix.dimmed(),
268 "·".dimmed(),
269 path.dimmed(),
270 "(no kuzu-memory entry)".dimmed()
271 ),
272 ConfigMigrateStatus::Failed(msg) => println!(
273 " {} {} {} {}",
274 prefix.dimmed(),
275 "✗".red(),
276 path.dimmed(),
277 format!("({msg})").red()
278 ),
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285
286 #[test]
289 fn test_migrate_config_replaces_dashed_key() {
290 let tmp = tempfile::tempdir().expect("tempdir");
291 let path = tmp.path().join("settings.local.json");
292 let input = serde_json::json!({
293 "theme": "dark",
294 "mcpServers": {
295 "kuzu-memory": {
296 "command": "kuzu-memory",
297 "args": ["serve"]
298 },
299 "other-server": { "command": "other" }
300 }
301 });
302 std::fs::write(&path, serde_json::to_string_pretty(&input).unwrap()).expect("write input");
303
304 let result = migrate_config_file(&path, false);
305 assert_eq!(result.status, ConfigMigrateStatus::Migrated);
306
307 let rewritten: Value =
308 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
309 let servers = rewritten["mcpServers"].as_object().unwrap();
310 assert!(
311 !servers.contains_key("kuzu-memory"),
312 "legacy key should be gone"
313 );
314 assert!(servers.contains_key("trusty-memory"), "trusty key missing");
315 assert!(
316 servers.contains_key("other-server"),
317 "unrelated server dropped"
318 );
319 assert_eq!(
320 rewritten["theme"], "dark",
321 "unrelated top-level key dropped"
322 );
323 assert_eq!(servers["trusty-memory"]["command"], "trusty-memory");
324 assert_eq!(servers["trusty-memory"]["args"][0], "serve");
325
326 assert!(
328 path.with_file_name("settings.local.json.bak").exists(),
329 "backup file missing"
330 );
331 }
332
333 #[test]
335 fn test_migrate_config_replaces_underscored_key() {
336 let tmp = tempfile::tempdir().expect("tempdir");
337 let path = tmp.path().join("settings.json");
338 let input = serde_json::json!({
339 "mcpServers": {
340 "kuzu_memory": { "command": "kuzu-memory", "args": ["serve"] }
341 }
342 });
343 std::fs::write(&path, serde_json::to_string_pretty(&input).unwrap()).expect("write input");
344
345 let result = migrate_config_file(&path, false);
346 assert_eq!(result.status, ConfigMigrateStatus::Migrated);
347
348 let rewritten: Value =
349 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
350 let servers = rewritten["mcpServers"].as_object().unwrap();
351 assert!(!servers.contains_key("kuzu_memory"));
352 assert!(servers.contains_key("trusty-memory"));
353 }
354
355 #[test]
358 fn test_migrate_config_idempotent() {
359 let tmp = tempfile::tempdir().expect("tempdir");
360 let path = tmp.path().join("settings.json");
361 let input = serde_json::json!({
362 "mcpServers": {
363 "trusty-memory": {
364 "command": "trusty-memory",
365 "args": ["serve"]
366 }
367 }
368 });
369 let serialized = serde_json::to_string_pretty(&input).unwrap();
370 std::fs::write(&path, &serialized).expect("write input");
371
372 let result = migrate_config_file(&path, false);
373 assert_eq!(result.status, ConfigMigrateStatus::AlreadyMigrated);
374
375 assert_eq!(
377 std::fs::read_to_string(&path).unwrap(),
378 serialized,
379 "file should be untouched"
380 );
381 assert!(
382 !path.with_file_name("settings.json.bak").exists(),
383 "no backup should be written for a skipped file"
384 );
385 }
386
387 #[test]
390 fn test_migrate_config_skips_when_no_legacy_key() {
391 let tmp = tempfile::tempdir().expect("tempdir");
392 let path = tmp.path().join("settings.json");
393 let input = serde_json::json!({
394 "mcpServers": {
395 "some-other-server": { "command": "x" }
396 }
397 });
398 let serialized = serde_json::to_string_pretty(&input).unwrap();
399 std::fs::write(&path, &serialized).expect("write input");
400
401 let result = migrate_config_file(&path, false);
402 assert_eq!(result.status, ConfigMigrateStatus::Skipped);
403 assert_eq!(
404 std::fs::read_to_string(&path).unwrap(),
405 serialized,
406 "file must be untouched"
407 );
408 }
409
410 #[test]
413 fn test_migrate_config_dry_run_does_not_write() {
414 let tmp = tempfile::tempdir().expect("tempdir");
415 let path = tmp.path().join("settings.json");
416 let input = serde_json::json!({
417 "mcpServers": {
418 "kuzu-memory": { "command": "kuzu-memory", "args": ["serve"] }
419 }
420 });
421 let serialized = serde_json::to_string_pretty(&input).unwrap();
422 std::fs::write(&path, &serialized).expect("write input");
423
424 let result = migrate_config_file(&path, true);
425 assert_eq!(result.status, ConfigMigrateStatus::Migrated);
426
427 assert_eq!(
429 std::fs::read_to_string(&path).unwrap(),
430 serialized,
431 "dry run must not write the file"
432 );
433 assert!(
434 !path.with_file_name("settings.json.bak").exists(),
435 "dry run must not produce a backup"
436 );
437 }
438}