use super::convert::{convert_one, find_all_mvs_configs, parse_mvs_config, ConvertStatus};
use super::daemon_utils::daemon_base_url;
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] = &["mcp-vector-search", "mcp_vector_search"];
const TRUSTY_KEY: &str = "trusty-search";
#[derive(Debug, Clone, ValueEnum)]
pub enum MigrateTarget {
McpVectorSearch,
}
#[derive(Debug, PartialEq, Eq)]
pub enum ConfigMigrateStatus {
Migrated,
AlreadyMigrated,
NoChange,
Failed(String),
}
#[derive(Debug)]
pub struct ConfigMigrateResult {
pub path: PathBuf,
pub status: ConfigMigrateStatus,
}
pub async fn handle_migrate(
target: MigrateTarget,
dry_run: bool,
mcp_only: bool,
indexes_only: bool,
) -> Result<()> {
match target {
MigrateTarget::McpVectorSearch => {}
}
if dry_run {
println!(
"{} Dry run — no files or indexes will be modified.\n",
"·".dimmed()
);
}
if !indexes_only {
run_mcp_phase(dry_run)?;
}
if !mcp_only {
if !indexes_only {
println!();
}
run_index_phase(dry_run).await?;
}
Ok(())
}
fn run_mcp_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 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 | ConfigMigrateStatus::NoChange => skipped += 1,
ConfigMigrateStatus::Failed(_) => failed += 1,
}
}
println!();
if dry_run {
println!(
"{} MCP config dry run: {} would migrate, {} skipped, {} failed",
"·".dimmed(),
migrated,
skipped,
failed
);
} else {
println!(
"{} MCP config: {} migrated, {} skipped, {} failed",
"✓".green(),
migrated,
skipped,
failed
);
}
Ok(())
}
async fn run_index_phase(dry_run: bool) -> Result<()> {
println!("🔍 Scanning for mcp-vector-search project indexes…");
let configs = find_all_mvs_configs();
if configs.is_empty() {
println!("{} No mcp-vector-search projects found.", "·".dimmed());
return Ok(());
}
println!("{} Found {} project(s).\n", "·".dimmed(), configs.len());
let base = if dry_run {
String::new()
} else {
let base = daemon_base_url();
crate::commands::daemon_guard::ensure_daemon_running_or_exit(&base).await?;
base
};
let total = configs.len();
let mut migrated = 0usize;
let mut already = 0usize;
let mut dry = 0usize;
let mut failed = 0usize;
for (i, config_path) in configs.into_iter().enumerate() {
let result = match parse_mvs_config(&config_path) {
Ok((root, name)) => convert_one(root, name, &base, dry_run).await,
Err(e) => {
println!(
" {} {} {} {}",
format!("[{}/{}]", i + 1, total).dimmed(),
"✗".red(),
config_path.display().to_string().dimmed(),
format!("(parse: {e})").red()
);
failed += 1;
continue;
}
};
print_index_line(i + 1, total, &result);
match result.status {
ConvertStatus::Queued => migrated += 1,
ConvertStatus::AlreadyRegistered => already += 1,
ConvertStatus::DryRun => dry += 1,
ConvertStatus::Failed(_) => failed += 1,
}
}
println!();
if dry_run {
println!("{} Index dry run: {} project(s)", "·".dimmed(), dry);
} else {
println!(
"{} Indexes: {} queued, {} already registered, {} failed",
"✓".green(),
migrated,
already,
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 result = |status| ConfigMigrateResult {
path: path.to_path_buf(),
status,
};
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 result(ConfigMigrateStatus::NoChange),
};
if servers.contains_key(TRUSTY_KEY) {
return result(ConfigMigrateStatus::AlreadyMigrated);
}
let legacy_present = LEGACY_MCP_KEYS.iter().any(|k| servers.contains_key(*k));
if !legacy_present {
return result(ConfigMigrateStatus::NoChange);
}
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 result(ConfigMigrateStatus::Migrated);
}
match write_json_atomic(path, &root) {
Ok(()) => result(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::NoChange => println!(
" {} {} {} {}",
prefix.dimmed(),
"·".dimmed(),
path.dimmed(),
"(no mcp-vector-search entry)".dimmed()
),
ConfigMigrateStatus::Failed(msg) => println!(
" {} {} {} {}",
prefix.dimmed(),
"✗".red(),
path.dimmed(),
format!("({msg})").red()
),
}
}
fn print_index_line(idx: usize, total: usize, r: &super::convert::ConvertResult) {
let prefix = format!("[{idx}/{total}]");
let path = r.path.display().to_string();
match &r.status {
ConvertStatus::Queued => println!(
" {} {} {:<24} → {}",
prefix.dimmed(),
"✓".green(),
r.name,
path.dimmed()
),
ConvertStatus::AlreadyRegistered => println!(
" {} {} {:<24} → {} {}",
prefix.dimmed(),
"↻".cyan(),
r.name,
path.dimmed(),
"(already registered, reindexing)".dimmed()
),
ConvertStatus::DryRun => println!(" {} {:<24} {}", prefix.dimmed(), r.name, path.dimmed()),
ConvertStatus::Failed(msg) => println!(
" {} {} {:<24} → {} {}",
prefix.dimmed(),
"✗".red(),
r.name,
path.dimmed(),
format!("({msg})").red()
),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_scan_finds_settings_files() {
let tmp = tempfile::tempdir().expect("tempdir");
let home = tmp.path();
let global = home.join(".claude");
std::fs::create_dir_all(&global).expect("mkdir global");
std::fs::write(global.join("settings.json"), "{}").expect("write global");
let proj = home.join("code").join("my-proj").join(".claude");
std::fs::create_dir_all(&proj).expect("mkdir proj");
std::fs::write(proj.join("settings.local.json"), "{}").expect("write proj");
let noise = home.join("node_modules").join(".claude");
std::fs::create_dir_all(&noise).expect("mkdir noise");
std::fs::write(noise.join("settings.json"), "{}").expect("write noise");
let found = discover_claude_settings(home, default_settings_max_depth());
assert!(
found.contains(&global.join("settings.json")),
"global settings missing: {found:?}"
);
assert!(
found.contains(&proj.join("settings.local.json")),
"project settings missing: {found:?}"
);
assert!(
!found
.iter()
.any(|p| p.starts_with(home.join("node_modules"))),
"node_modules should be skipped: {found:?}"
);
}
#[test]
fn test_migrate_config_replaces_key() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("settings.local.json");
let input = serde_json::json!({
"theme": "dark",
"mcpServers": {
"mcp-vector-search": {
"command": "mcp-vector-search",
"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("mcp-vector-search"),
"legacy key should be gone"
);
assert!(servers.contains_key("trusty-search"), "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-search"]["command"], "trusty-search");
assert_eq!(servers["trusty-search"]["args"][0], "serve");
assert!(
path.with_file_name("settings.local.json.bak").exists(),
"backup file missing"
);
}
#[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-search": {
"command": "trusty-search",
"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"
);
}
}