use crate::cli::convert::{derive_palace_name, read_kuzu_memories, RawMemory};
use crate::cli::memory::open_or_create_handle;
use crate::cli::output::OutputConfig;
use anyhow::{Context, Result};
use clap::{Args, Subcommand};
use serde_json::{json, Value};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
const MAX_SCAN_DEPTH: usize = 5;
const KUZU_SERVER_KEY: &str = "kuzu-memory";
const TRUSTY_SERVER_KEY: &str = "trusty-memory";
#[derive(Subcommand, Debug, Clone)]
pub enum MigrateSubcommand {
#[command(
name = "kuzu-memory",
after_help = "Examples:\n trusty-memory migrate kuzu-memory\n trusty-memory migrate kuzu-memory --dry-run\n trusty-memory migrate kuzu-memory --palace my-app"
)]
KuzuMemory(KuzuMemoryArgs),
}
#[derive(Args, Debug, Clone)]
pub struct KuzuMemoryArgs {
#[arg(long)]
pub dry_run: bool,
#[arg(long)]
pub palace: Option<String>,
}
pub async fn handle(command: MigrateSubcommand, out: &OutputConfig) -> Result<()> {
match command {
MigrateSubcommand::KuzuMemory(opts) => migrate_kuzu_memory(opts, out).await,
}
}
async fn migrate_kuzu_memory(opts: KuzuMemoryArgs, out: &OutputConfig) -> Result<()> {
let prefix = if opts.dry_run { "[dry-run] " } else { "" };
out.print_header("migrate", "kuzu-memory");
let settings_files = discover_claude_settings()?;
let mut config_changed = 0usize;
for path in &settings_files {
match migrate_settings_file(path, opts.dry_run) {
Ok(true) => {
config_changed += 1;
println!(
"{prefix}rewrote kuzu-memory → trusty-memory in {}",
path.display()
);
}
Ok(false) => {}
Err(e) => eprintln!("warning: could not migrate {}: {e:#}", path.display()),
}
}
if config_changed == 0 {
println!("{prefix}no Claude settings files reference kuzu-memory");
}
let palace_name = match &opts.palace {
Some(p) => p.clone(),
None => {
let cwd = std::env::current_dir().context("get current directory")?;
derive_palace_name(&cwd)
}
};
let memories = collect_kuzu_memories();
let report = if memories.is_empty() {
println!("{prefix}no kuzu-memory data found to import");
MigrationReport::default()
} else if opts.dry_run {
let unique = dedup_count(&memories);
println!(
"{prefix}would import {} memories ({} unique) → palace '{palace_name}'",
memories.len(),
unique,
);
MigrationReport {
migrated: unique,
skipped: memories.len() - unique,
failed: 0,
}
} else {
import_memories(&palace_name, &memories).await?
};
println!("{prefix}{}", format_report(&report, config_changed));
if !opts.dry_run {
out.print_success("kuzu-memory migration complete");
}
Ok(())
}
pub fn discover_claude_settings() -> Result<Vec<PathBuf>> {
let home = dirs::home_dir().context("resolve home directory")?;
let mut found: Vec<PathBuf> = Vec::new();
let mut seen: HashSet<PathBuf> = HashSet::new();
for name in ["settings.json", "settings.local.json"] {
let p = home.join(".claude").join(name);
if p.is_file() && seen.insert(p.clone()) {
found.push(p);
}
}
scan_for_claude_settings(&home, 0, &mut found, &mut seen);
Ok(found)
}
fn scan_for_claude_settings(
dir: &Path,
depth: usize,
found: &mut Vec<PathBuf>,
seen: &mut HashSet<PathBuf>,
) {
if depth > MAX_SCAN_DEPTH {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = entry.file_name();
let name = name.to_string_lossy();
if name == ".claude" {
for file in ["settings.json", "settings.local.json"] {
let p = path.join(file);
if p.is_file() && seen.insert(p.clone()) {
found.push(p);
}
}
continue;
}
if name.starts_with('.') || is_skipped_dir(&name) {
continue;
}
scan_for_claude_settings(&path, depth + 1, found, seen);
}
}
fn is_skipped_dir(name: &str) -> bool {
matches!(
name,
"node_modules"
| "target"
| "vendor"
| "dist"
| "build"
| "Library"
| ".git"
| "__pycache__"
)
}
pub fn migrate_settings_file(path: &Path, dry_run: bool) -> Result<bool> {
let raw = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
if raw.trim().is_empty() {
return Ok(false);
}
let value: Value =
serde_json::from_str(&raw).with_context(|| format!("parse JSON in {}", path.display()))?;
let Some(rewritten) = rewrite_mcp_servers(&value) else {
return Ok(false);
};
if dry_run {
return Ok(true);
}
let backup = backup_path(path);
std::fs::copy(path, &backup)
.with_context(|| format!("back up {} to {}", path.display(), backup.display()))?;
let pretty = serde_json::to_string_pretty(&rewritten).context("serialize migrated settings")?;
std::fs::write(path, pretty).with_context(|| format!("write {}", path.display()))?;
Ok(true)
}
pub fn backup_path(path: &Path) -> PathBuf {
let mut name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "settings.json".to_string());
name.push_str(".bak");
path.with_file_name(name)
}
pub fn rewrite_mcp_servers(value: &Value) -> Option<Value> {
let obj = value.as_object()?;
let key = ["mcpServers", "mcp_servers"]
.into_iter()
.find(|k| obj.get(*k).map(Value::is_object).unwrap_or(false))?;
let servers = obj.get(key)?.as_object()?;
if !servers.contains_key(KUZU_SERVER_KEY) {
return None;
}
let mut new_value = value.clone();
let new_obj = new_value.as_object_mut()?;
let new_servers = new_obj.get_mut(key)?.as_object_mut()?;
new_servers.remove(KUZU_SERVER_KEY);
new_servers.insert(TRUSTY_SERVER_KEY.to_string(), trusty_mcp_entry());
Some(new_value)
}
pub fn trusty_mcp_entry() -> Value {
json!({
"command": "trusty-memory",
"args": ["serve"]
})
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct MigrationReport {
pub migrated: usize,
pub skipped: usize,
pub failed: usize,
}
fn collect_kuzu_memories() -> Vec<RawMemory> {
let mut out: Vec<RawMemory> = Vec::new();
for db in kuzu_database_paths() {
match read_kuzu_memories(&db) {
Ok(mut mems) => out.append(&mut mems),
Err(e) => eprintln!("warning: kuzu read failed for {}: {e:#}", db.display()),
}
}
out
}
fn kuzu_database_paths() -> Vec<PathBuf> {
let mut paths: Vec<PathBuf> = Vec::new();
if let Ok(cwd) = std::env::current_dir() {
let local = cwd.join(".kuzu-memory").join("memories.db");
if local.exists() {
paths.push(local);
}
}
if let Some(home) = dirs::home_dir() {
let data_root = home.join(".local").join("share").join("kuzu-memory");
if let Ok(entries) = std::fs::read_dir(&data_root) {
for entry in entries.flatten() {
let p = entry.path();
if p.is_file()
&& p.extension().map(|e| e == "db").unwrap_or(false)
&& !paths.contains(&p)
{
paths.push(p);
}
}
}
let direct = data_root.join("memories.db");
if direct.exists() && !paths.contains(&direct) {
paths.push(direct);
}
}
paths
}
fn dedup_count(memories: &[RawMemory]) -> usize {
let mut seen: HashSet<&str> = HashSet::new();
for m in memories {
seen.insert(m.content.as_str());
}
seen.len()
}
async fn import_memories(palace: &str, memories: &[RawMemory]) -> Result<MigrationReport> {
let handle = open_or_create_handle(palace).await?;
let mut report = MigrationReport::default();
let mut seen: HashSet<String> = HashSet::new();
for m in memories {
if !seen.insert(m.content.clone()) {
report.skipped += 1;
continue;
}
let tags = vec![format!("source:{}", m.source)];
match handle
.remember(m.content.clone(), m.room.clone(), tags, m.importance)
.await
{
Ok(_) => report.migrated += 1,
Err(e) => {
eprintln!("warning: failed to import memory: {e:#}");
report.failed += 1;
}
}
}
Ok(report)
}
pub fn format_report(report: &MigrationReport, config_files_changed: usize) -> String {
format!(
"{} memories migrated, {} skipped (duplicates), {} failed; {} config file(s) updated",
report.migrated, report.skipped, report.failed, config_files_changed,
)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::fs;
use tempfile::tempdir;
use trusty_memory_core::RoomType;
fn raw(content: &str) -> RawMemory {
RawMemory {
content: content.to_string(),
importance: 0.5,
room: RoomType::General,
source: "kuzu".to_string(),
}
}
#[test]
fn trusty_mcp_entry_shape() {
let v = trusty_mcp_entry();
assert_eq!(
v.get("command").and_then(Value::as_str),
Some("trusty-memory")
);
let args = v.get("args").and_then(Value::as_array).expect("args array");
assert_eq!(args.len(), 1);
assert_eq!(args[0].as_str(), Some("serve"));
}
#[test]
fn backup_path_appends_suffix() {
let p = Path::new("/home/u/.claude/settings.json");
assert_eq!(
backup_path(p),
PathBuf::from("/home/u/.claude/settings.json.bak")
);
let local = Path::new("/p/.claude/settings.local.json");
assert_eq!(
backup_path(local),
PathBuf::from("/p/.claude/settings.local.json.bak")
);
}
#[test]
fn rewrite_mcp_servers_replaces_kuzu_entry() {
let settings = json!({
"model": "sonnet",
"mcpServers": {
"kuzu-memory": {"command": "kuzu-memory", "args": ["serve"]},
"other": {"command": "other-mcp"}
}
});
let out = rewrite_mcp_servers(&settings).expect("should rewrite");
let servers = out
.get("mcpServers")
.and_then(Value::as_object)
.expect("mcpServers object");
assert!(!servers.contains_key("kuzu-memory"), "kuzu entry removed");
assert!(servers.contains_key("trusty-memory"), "trusty entry added");
assert!(servers.contains_key("other"), "unrelated entry preserved");
assert_eq!(out.get("model").and_then(Value::as_str), Some("sonnet"));
assert_eq!(
servers["trusty-memory"]
.get("command")
.and_then(Value::as_str),
Some("trusty-memory")
);
}
#[test]
fn rewrite_mcp_servers_returns_none_without_kuzu() {
let settings = json!({
"mcpServers": {"other": {"command": "other-mcp"}}
});
assert!(rewrite_mcp_servers(&settings).is_none());
}
#[test]
fn rewrite_mcp_servers_returns_none_without_mcp_servers() {
let settings = json!({"model": "sonnet"});
assert!(rewrite_mcp_servers(&settings).is_none());
}
#[test]
fn rewrite_mcp_servers_handles_snake_case_key() {
let settings = json!({
"mcp_servers": {"kuzu-memory": {"command": "kuzu-memory"}}
});
let out = rewrite_mcp_servers(&settings).expect("should rewrite");
let servers = out
.get("mcp_servers")
.and_then(Value::as_object)
.expect("mcp_servers object");
assert!(!servers.contains_key("kuzu-memory"));
assert!(servers.contains_key("trusty-memory"));
}
#[test]
fn migrate_settings_file_rewrites_and_backs_up() {
let dir = tempdir().expect("tempdir");
let path = dir.path().join("settings.json");
let original = json!({
"mcpServers": {"kuzu-memory": {"command": "kuzu-memory"}}
});
fs::write(&path, serde_json::to_string_pretty(&original).unwrap()).unwrap();
let changed = migrate_settings_file(&path, false).expect("migrate");
assert!(changed);
let backup = backup_path(&path);
assert!(backup.is_file(), "backup file created");
let backup_json: Value =
serde_json::from_str(&fs::read_to_string(&backup).unwrap()).unwrap();
assert!(backup_json["mcpServers"].get("kuzu-memory").is_some());
let live: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert!(live["mcpServers"].get("kuzu-memory").is_none());
assert!(live["mcpServers"].get("trusty-memory").is_some());
}
#[test]
fn migrate_settings_file_dry_run_does_not_write() {
let dir = tempdir().expect("tempdir");
let path = dir.path().join("settings.json");
let original = json!({
"mcpServers": {"kuzu-memory": {"command": "kuzu-memory"}}
});
let raw = serde_json::to_string_pretty(&original).unwrap();
fs::write(&path, &raw).unwrap();
let changed = migrate_settings_file(&path, true).expect("migrate");
assert!(changed, "dry-run still reports a change is needed");
assert!(!backup_path(&path).exists(), "no backup in dry-run");
assert_eq!(fs::read_to_string(&path).unwrap(), raw, "file untouched");
}
#[test]
fn migrate_settings_file_no_kuzu_is_noop() {
let dir = tempdir().expect("tempdir");
let path = dir.path().join("settings.json");
fs::write(&path, r#"{"mcpServers":{"other":{"command":"x"}}}"#).unwrap();
assert!(!migrate_settings_file(&path, false).expect("migrate"));
assert!(!backup_path(&path).exists());
}
#[test]
fn migrate_settings_file_empty_is_noop() {
let dir = tempdir().expect("tempdir");
let path = dir.path().join("settings.json");
fs::write(&path, " ").unwrap();
assert!(!migrate_settings_file(&path, false).expect("migrate"));
}
#[test]
fn scan_for_claude_settings_finds_nested() {
let dir = tempdir().expect("tempdir");
let root = dir.path();
let claude = root.join("project").join(".claude");
fs::create_dir_all(&claude).unwrap();
let settings = claude.join("settings.json");
fs::write(&settings, "{}").unwrap();
let nm = root.join("node_modules").join(".claude");
fs::create_dir_all(&nm).unwrap();
fs::write(nm.join("settings.json"), "{}").unwrap();
let mut found = Vec::new();
let mut seen = HashSet::new();
scan_for_claude_settings(root, 0, &mut found, &mut seen);
assert!(found.contains(&settings), "nested .claude settings found");
assert!(
!found
.iter()
.any(|p| p.starts_with(root.join("node_modules"))),
"node_modules skipped"
);
}
#[test]
fn scan_for_claude_settings_respects_depth_limit() {
let dir = tempdir().expect("tempdir");
let root = dir.path();
let mut deep = root.to_path_buf();
for i in 0..(MAX_SCAN_DEPTH + 3) {
deep = deep.join(format!("d{i}"));
}
let claude = deep.join(".claude");
fs::create_dir_all(&claude).unwrap();
fs::write(claude.join("settings.json"), "{}").unwrap();
let mut found = Vec::new();
let mut seen = HashSet::new();
scan_for_claude_settings(root, 0, &mut found, &mut seen);
assert!(found.is_empty(), "settings beyond depth limit are skipped");
}
#[test]
fn is_skipped_dir_matches_known_heavy_dirs() {
assert!(is_skipped_dir("node_modules"));
assert!(is_skipped_dir("target"));
assert!(!is_skipped_dir("src"));
assert!(!is_skipped_dir("project"));
}
#[test]
fn dedup_count_collapses_duplicates() {
let mems = vec![raw("alpha"), raw("alpha"), raw("beta")];
assert_eq!(dedup_count(&mems), 2);
}
#[test]
fn dedup_count_empty_is_zero() {
assert_eq!(dedup_count(&[]), 0);
}
#[test]
fn format_report_shape() {
let report = MigrationReport {
migrated: 12,
skipped: 3,
failed: 1,
};
let s = format_report(&report, 2);
assert_eq!(
s,
"12 memories migrated, 3 skipped (duplicates), 1 failed; 2 config file(s) updated"
);
}
#[test]
fn migration_report_default_is_zeroed() {
let r = MigrationReport::default();
assert_eq!(
r,
MigrationReport {
migrated: 0,
skipped: 0,
failed: 0
}
);
}
}