use anyhow::Result;
use clap::ValueEnum;
use colored::Colorize;
use serde_json::Value;
use std::path::{Path, PathBuf};
use trusty_common::claude_config::{
default_settings_max_depth, discover_claude_settings, mcp_server_entry, write_json_atomic,
};
const LEGACY_MCP_KEYS: &[&str] = &["kuzu-memory", "kuzu_memory"];
const TRUSTY_KEY: &str = "trusty-memory";
#[derive(Debug, Clone, ValueEnum)]
pub enum MigrateTarget {
KuzuMemory,
}
#[derive(Debug, PartialEq, Eq)]
pub enum ConfigMigrateStatus {
Migrated,
AlreadyMigrated,
Skipped,
Failed(String),
}
#[derive(Debug)]
pub struct ConfigMigrateResult {
pub path: PathBuf,
pub status: ConfigMigrateStatus,
}
pub fn handle_migrate(target: MigrateTarget, dry_run: bool, _config_only: bool) -> Result<()> {
match target {
MigrateTarget::KuzuMemory => {}
}
if dry_run {
println!("{} Dry run — no files will be modified.\n", "·".dimmed());
}
run_config_phase(dry_run)
}
fn run_config_phase(dry_run: bool) -> Result<()> {
let home =
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("could not determine home directory"))?;
println!(
"🔍 Scanning for Claude MCP settings under {}…",
home.display()
);
let files = discover_claude_settings(&home, default_settings_max_depth());
if files.is_empty() {
println!("{} No Claude settings files found.", "·".dimmed());
return Ok(());
}
println!("{} Found {} settings file(s).\n", "·".dimmed(), files.len());
let mut migrated = 0usize;
let mut already = 0usize;
let mut skipped = 0usize;
let mut failed = 0usize;
for (i, path) in files.iter().enumerate() {
let result = migrate_config_file(path, dry_run);
print_config_line(i + 1, files.len(), &result);
match result.status {
ConfigMigrateStatus::Migrated => migrated += 1,
ConfigMigrateStatus::AlreadyMigrated => already += 1,
ConfigMigrateStatus::Skipped => skipped += 1,
ConfigMigrateStatus::Failed(_) => failed += 1,
}
}
println!();
if dry_run {
println!(
"{} MCP config dry run: {} would migrate, {} already migrated, {} skipped, {} failed",
"·".dimmed(),
migrated,
already,
skipped,
failed
);
} else {
println!(
"{} MCP config: {} migrated, {} already migrated, {} skipped, {} failed",
"✓".green(),
migrated,
already,
skipped,
failed
);
}
Ok(())
}
pub fn migrate_config_file(path: &Path, dry_run: bool) -> ConfigMigrateResult {
let fail = |msg: String| ConfigMigrateResult {
path: path.to_path_buf(),
status: ConfigMigrateStatus::Failed(msg),
};
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => return fail(format!("read: {e}")),
};
let mut root: Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => return fail(format!("parse: {e}")),
};
let servers = match root.get_mut("mcpServers").and_then(Value::as_object_mut) {
Some(s) => s,
None => {
return ConfigMigrateResult {
path: path.to_path_buf(),
status: ConfigMigrateStatus::Skipped,
}
}
};
if servers.contains_key(TRUSTY_KEY) {
return ConfigMigrateResult {
path: path.to_path_buf(),
status: ConfigMigrateStatus::AlreadyMigrated,
};
}
let legacy_present = LEGACY_MCP_KEYS.iter().any(|k| servers.contains_key(*k));
if !legacy_present {
return ConfigMigrateResult {
path: path.to_path_buf(),
status: ConfigMigrateStatus::Skipped,
};
}
for k in LEGACY_MCP_KEYS {
servers.remove(*k);
}
servers.insert(
TRUSTY_KEY.to_string(),
mcp_server_entry(TRUSTY_KEY, &["serve"]),
);
if dry_run {
return ConfigMigrateResult {
path: path.to_path_buf(),
status: ConfigMigrateStatus::Migrated,
};
}
match write_json_atomic(path, &root) {
Ok(()) => ConfigMigrateResult {
path: path.to_path_buf(),
status: ConfigMigrateStatus::Migrated,
},
Err(e) => fail(format!("write: {e}")),
}
}
fn print_config_line(idx: usize, total: usize, r: &ConfigMigrateResult) {
let prefix = format!("[{idx}/{total}]");
let path = r.path.display().to_string();
match &r.status {
ConfigMigrateStatus::Migrated => println!(" {} {} {}", prefix.dimmed(), "✓".green(), path),
ConfigMigrateStatus::AlreadyMigrated => println!(
" {} {} {} {}",
prefix.dimmed(),
"↻".cyan(),
path.dimmed(),
"(already migrated)".dimmed()
),
ConfigMigrateStatus::Skipped => println!(
" {} {} {} {}",
prefix.dimmed(),
"·".dimmed(),
path.dimmed(),
"(no kuzu-memory entry)".dimmed()
),
ConfigMigrateStatus::Failed(msg) => println!(
" {} {} {} {}",
prefix.dimmed(),
"✗".red(),
path.dimmed(),
format!("({msg})").red()
),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_migrate_config_replaces_dashed_key() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("settings.local.json");
let input = serde_json::json!({
"theme": "dark",
"mcpServers": {
"kuzu-memory": {
"command": "kuzu-memory",
"args": ["serve"]
},
"other-server": { "command": "other" }
}
});
std::fs::write(&path, serde_json::to_string_pretty(&input).unwrap()).expect("write input");
let result = migrate_config_file(&path, false);
assert_eq!(result.status, ConfigMigrateStatus::Migrated);
let rewritten: Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
let servers = rewritten["mcpServers"].as_object().unwrap();
assert!(
!servers.contains_key("kuzu-memory"),
"legacy key should be gone"
);
assert!(servers.contains_key("trusty-memory"), "trusty key missing");
assert!(
servers.contains_key("other-server"),
"unrelated server dropped"
);
assert_eq!(
rewritten["theme"], "dark",
"unrelated top-level key dropped"
);
assert_eq!(servers["trusty-memory"]["command"], "trusty-memory");
assert_eq!(servers["trusty-memory"]["args"][0], "serve");
assert!(
path.with_file_name("settings.local.json.bak").exists(),
"backup file missing"
);
}
#[test]
fn test_migrate_config_replaces_underscored_key() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("settings.json");
let input = serde_json::json!({
"mcpServers": {
"kuzu_memory": { "command": "kuzu-memory", "args": ["serve"] }
}
});
std::fs::write(&path, serde_json::to_string_pretty(&input).unwrap()).expect("write input");
let result = migrate_config_file(&path, false);
assert_eq!(result.status, ConfigMigrateStatus::Migrated);
let rewritten: Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
let servers = rewritten["mcpServers"].as_object().unwrap();
assert!(!servers.contains_key("kuzu_memory"));
assert!(servers.contains_key("trusty-memory"));
}
#[test]
fn test_migrate_config_idempotent() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("settings.json");
let input = serde_json::json!({
"mcpServers": {
"trusty-memory": {
"command": "trusty-memory",
"args": ["serve"]
}
}
});
let serialized = serde_json::to_string_pretty(&input).unwrap();
std::fs::write(&path, &serialized).expect("write input");
let result = migrate_config_file(&path, false);
assert_eq!(result.status, ConfigMigrateStatus::AlreadyMigrated);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
serialized,
"file should be untouched"
);
assert!(
!path.with_file_name("settings.json.bak").exists(),
"no backup should be written for a skipped file"
);
}
#[test]
fn test_migrate_config_skips_when_no_legacy_key() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("settings.json");
let input = serde_json::json!({
"mcpServers": {
"some-other-server": { "command": "x" }
}
});
let serialized = serde_json::to_string_pretty(&input).unwrap();
std::fs::write(&path, &serialized).expect("write input");
let result = migrate_config_file(&path, false);
assert_eq!(result.status, ConfigMigrateStatus::Skipped);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
serialized,
"file must be untouched"
);
}
#[test]
fn test_migrate_config_dry_run_does_not_write() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("settings.json");
let input = serde_json::json!({
"mcpServers": {
"kuzu-memory": { "command": "kuzu-memory", "args": ["serve"] }
}
});
let serialized = serde_json::to_string_pretty(&input).unwrap();
std::fs::write(&path, &serialized).expect("write input");
let result = migrate_config_file(&path, true);
assert_eq!(result.status, ConfigMigrateStatus::Migrated);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
serialized,
"dry run must not write the file"
);
assert!(
!path.with_file_name("settings.json.bak").exists(),
"dry run must not produce a backup"
);
}
}