1use crate::models::field_names;
34use std::path::{Path, PathBuf};
35
36use anyhow::Result;
37use clap::{Args, Subcommand};
38
39use crate::cli::CliOutput;
40use crate::config::config_keys;
41
42#[derive(Args, Debug, Clone)]
44pub struct ConfigCliArgs {
45 #[command(subcommand)]
46 pub action: ConfigAction,
47}
48
49#[derive(Subcommand, Debug, Clone)]
50pub enum ConfigAction {
51 Migrate {
59 #[arg(long)]
62 dry_run: bool,
63
64 #[arg(long)]
70 also_clean_claude_json: bool,
71 },
72}
73
74pub fn run(_db: &Path, args: ConfigCliArgs, out: &mut CliOutput) -> Result<i32> {
80 match args.action {
81 ConfigAction::Migrate {
82 dry_run,
83 also_clean_claude_json,
84 } => migrate(dry_run, also_clean_claude_json, out),
85 }
86}
87
88fn migrate(dry_run: bool, also_clean_claude_json: bool, out: &mut CliOutput) -> Result<i32> {
89 use crate::config::AppConfig;
90
91 let Some(path) = AppConfig::config_path() else {
92 let _ = writeln!(
93 out.stderr,
94 "ERROR: $HOME is not set; cannot resolve config path."
95 );
96 return Ok(2);
97 };
98
99 if !path.exists() {
100 let _ = writeln!(
101 out.stderr,
102 "ERROR: no config file at {} — nothing to migrate.",
103 path.display()
104 );
105 return Ok(2);
106 }
107
108 let contents = match std::fs::read_to_string(&path) {
109 Ok(c) => c,
110 Err(e) => {
111 let _ = writeln!(
112 out.stderr,
113 "ERROR: could not read {}: {}",
114 path.display(),
115 e
116 );
117 return Ok(4);
118 }
119 };
120
121 let original_value: toml::Value = match toml::from_str(&contents) {
122 Ok(v) => v,
123 Err(e) => {
124 let _ = writeln!(
125 out.stderr,
126 "ERROR: {} is not valid TOML: {}",
127 path.display(),
128 e
129 );
130 return Ok(3);
131 }
132 };
133
134 let original_table = match original_value.as_table() {
135 Some(t) => t.clone(),
136 None => {
137 let _ = writeln!(
138 out.stderr,
139 "ERROR: {} is valid TOML but not a top-level table.",
140 path.display()
141 );
142 return Ok(3);
143 }
144 };
145
146 let v2_already = original_table
149 .get(field_names::SCHEMA_VERSION)
150 .and_then(toml::Value::as_integer)
151 .is_some_and(|v| v >= 2);
152 let has_legacy = LEGACY_FIELDS
153 .iter()
154 .any(|k| original_table.contains_key(*k));
155
156 if v2_already && !has_legacy {
157 let _ = writeln!(
158 out.stderr,
159 "INFO: {} is already schema_version >= 2 with no legacy fields; no migration needed.",
160 path.display()
161 );
162 return Ok(0);
163 }
164
165 let migrated_table = build_migrated_table(&original_table);
166 let migrated_value = toml::Value::Table(migrated_table);
167 let migrated_text = toml::to_string_pretty(&migrated_value).unwrap_or_else(|_| String::new());
168
169 if dry_run {
170 let _ = writeln!(
171 out.stderr,
172 "--- DRY RUN — {} would be rewritten as: ---",
173 path.display()
174 );
175 let _ = writeln!(out.stderr, "{migrated_text}");
176 let _ = writeln!(out.stderr, "--- end dry run ---");
177 if also_clean_claude_json {
178 let _ = writeln!(
179 out.stderr,
180 "(--also-clean-claude-json also skipped in dry-run.)"
181 );
182 }
183 return Ok(1);
184 }
185
186 let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S").to_string();
188 let backup_path = path.with_extension(format!("toml.bak.{timestamp}"));
189 if let Err(e) = std::fs::write(&backup_path, &contents) {
190 let _ = writeln!(
191 out.stderr,
192 "ERROR: could not write backup {}: {}",
193 backup_path.display(),
194 e
195 );
196 return Ok(4);
197 }
198
199 if let Err(e) = std::fs::write(&path, &migrated_text) {
201 let _ = writeln!(
202 out.stderr,
203 "ERROR: could not write {}: {}",
204 path.display(),
205 e
206 );
207 return Ok(4);
208 }
209
210 let _ = writeln!(
211 out.stderr,
212 "OK: migrated {} (backup: {})",
213 path.display(),
214 backup_path.display()
215 );
216
217 if also_clean_claude_json {
218 match clean_claude_json(×tamp) {
219 Ok(Some(claude_path)) => {
220 let _ = writeln!(
221 out.stderr,
222 "OK: cleaned ~/.claude.json (backup: {claude_path})"
223 );
224 }
225 Ok(None) => {
226 let _ = writeln!(
227 out.stderr,
228 "INFO: ~/.claude.json had no mcpServers env block referencing ai-memory; no changes."
229 );
230 }
231 Err(e) => {
232 let _ = writeln!(out.stderr, "WARN: ~/.claude.json clean failed: {e}");
233 }
234 }
235 } else {
236 let _ = writeln!(
237 out.stderr,
238 "INFO: your ~/.claude.json may still carry an mcpServers env block. \
239 Re-run with `--also-clean-claude-json` to remove it after verifying \
240 the new config.toml works."
241 );
242 }
243
244 Ok(0)
245}
246
247const LEGACY_FIELDS: &[&str] = &[
249 "llm_model",
250 config_keys::OLLAMA_URL,
251 "embed_url",
252 config_keys::EMBEDDING_MODEL,
253 config_keys::CROSS_ENCODER,
254 config_keys::DEFAULT_NAMESPACE,
255 config_keys::ARCHIVE_ON_GC,
256 config_keys::ARCHIVE_MAX_DAYS,
257 config_keys::MAX_MEMORY_MB,
258 config_keys::AUTO_TAG_MODEL,
259];
260
261fn build_migrated_table(
264 original: &toml::map::Map<String, toml::Value>,
265) -> toml::map::Map<String, toml::Value> {
266 let mut migrated = original.clone();
267
268 let mut llm_model: Option<toml::Value> = None;
270 let mut ollama_url: Option<toml::Value> = None;
271 let mut embed_url: Option<toml::Value> = None;
272 let mut embedding_model: Option<toml::Value> = None;
273 let mut cross_encoder: Option<toml::Value> = None;
274 let mut default_namespace: Option<toml::Value> = None;
275 let mut archive_on_gc: Option<toml::Value> = None;
276 let mut archive_max_days: Option<toml::Value> = None;
277 let mut max_memory_mb: Option<toml::Value> = None;
278 let mut auto_tag_model: Option<toml::Value> = None;
279
280 macro_rules! take {
281 ($name:expr, $target:ident) => {
282 if let Some(v) = migrated.remove($name) {
283 $target = Some(v);
284 }
285 };
286 }
287
288 take!("llm_model", llm_model);
289 take!(config_keys::OLLAMA_URL, ollama_url);
290 take!("embed_url", embed_url);
291 take!(config_keys::EMBEDDING_MODEL, embedding_model);
292 take!(config_keys::CROSS_ENCODER, cross_encoder);
293 take!(config_keys::DEFAULT_NAMESPACE, default_namespace);
294 take!(config_keys::ARCHIVE_ON_GC, archive_on_gc);
295 take!(config_keys::ARCHIVE_MAX_DAYS, archive_max_days);
296 take!(config_keys::MAX_MEMORY_MB, max_memory_mb);
297 take!(config_keys::AUTO_TAG_MODEL, auto_tag_model);
298
299 migrated.insert(
301 field_names::SCHEMA_VERSION.to_string(),
302 toml::Value::Integer(2),
303 );
304
305 if !migrated.contains_key("llm") && llm_model.is_some() {
310 let mut llm = toml::map::Map::new();
311 llm.insert(
317 "backend".to_string(),
318 toml::Value::String(crate::llm::BACKEND_OLLAMA.to_string()),
319 );
320 if let Some(v) = llm_model {
321 llm.insert("model".to_string(), v);
322 }
323 if let Some(v) = ollama_url {
324 llm.insert("base_url".to_string(), v);
325 }
326 if let Some(v) = auto_tag_model {
328 let mut sub = toml::map::Map::new();
329 sub.insert("model".to_string(), v);
330 llm.insert("auto_tag".to_string(), toml::Value::Table(sub));
331 }
332 migrated.insert("llm".to_string(), toml::Value::Table(llm));
333 }
334
335 if !migrated.contains_key(config_keys::SECTION_EMBEDDINGS)
337 && (embed_url.is_some() || embedding_model.is_some())
338 {
339 let mut emb = toml::map::Map::new();
340 emb.insert(
343 "backend".to_string(),
344 toml::Value::String(crate::llm::BACKEND_OLLAMA.to_string()),
345 );
346 if let Some(v) = embed_url {
347 emb.insert("url".to_string(), v);
348 }
349 if let Some(v) = embedding_model {
350 emb.insert("model".to_string(), v);
351 }
352 migrated.insert(
353 config_keys::SECTION_EMBEDDINGS.to_string(),
354 toml::Value::Table(emb),
355 );
356 }
357
358 if !migrated.contains_key("reranker") && cross_encoder.is_some() {
360 let mut rerank = toml::map::Map::new();
361 if let Some(v) = cross_encoder.clone() {
362 rerank.insert("enabled".to_string(), v);
363 }
364 rerank.insert(
365 "model".to_string(),
366 toml::Value::String(crate::reranker::DEFAULT_RERANKER_MODEL.to_string()),
367 );
368 migrated.insert("reranker".to_string(), toml::Value::Table(rerank));
369 }
370
371 if !migrated.contains_key("storage")
373 && (default_namespace.is_some()
374 || archive_on_gc.is_some()
375 || archive_max_days.is_some()
376 || max_memory_mb.is_some())
377 {
378 let mut storage = toml::map::Map::new();
379 if let Some(v) = default_namespace {
380 storage.insert(config_keys::DEFAULT_NAMESPACE.to_string(), v);
381 }
382 if let Some(v) = archive_on_gc {
383 storage.insert(config_keys::ARCHIVE_ON_GC.to_string(), v);
384 }
385 if let Some(v) = archive_max_days {
386 storage.insert(config_keys::ARCHIVE_MAX_DAYS.to_string(), v);
387 }
388 if let Some(v) = max_memory_mb {
389 storage.insert(config_keys::MAX_MEMORY_MB.to_string(), v);
390 }
391 migrated.insert("storage".to_string(), toml::Value::Table(storage));
392 }
393
394 migrated
395}
396
397fn clean_claude_json(timestamp: &str) -> Result<Option<String>> {
402 let home = std::env::var("HOME").map_err(|_| anyhow::anyhow!("$HOME not set"))?;
403 let path = PathBuf::from(&home).join(".claude.json");
404 if !path.exists() {
405 return Ok(None);
406 }
407
408 let contents = std::fs::read_to_string(&path)?;
409 let mut value: serde_json::Value = serde_json::from_str(&contents)?;
410
411 let mut changed = false;
412 if let Some(servers) = value
413 .get_mut(crate::cli::install::KEY_MCP_SERVERS)
414 .and_then(serde_json::Value::as_object_mut)
415 {
416 for (_name, entry) in servers.iter_mut() {
417 let is_ai_memory = entry
418 .get("command")
419 .and_then(serde_json::Value::as_str)
420 .map(|c| c.ends_with("/ai-memory") || c == "ai-memory")
421 .unwrap_or(false);
422 if !is_ai_memory {
423 continue;
424 }
425 if let Some(obj) = entry.as_object_mut() {
426 if obj.remove("env").is_some() {
427 changed = true;
428 }
429 }
430 }
431 }
432
433 if !changed {
434 return Ok(None);
435 }
436
437 let backup_path = format!("{}.bak.{}", path.display(), timestamp);
438 std::fs::write(&backup_path, &contents)?;
439 std::fs::write(&path, serde_json::to_string_pretty(&value)?)?;
440
441 Ok(Some(backup_path))
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447 use crate::cli::CliOutput;
448
449 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
453 static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
454 LOCK.get_or_init(|| std::sync::Mutex::new(()))
455 .lock()
456 .unwrap_or_else(std::sync::PoisonError::into_inner)
457 }
458
459 fn run_migrate_with_home(
463 config_body: Option<&str>,
464 dry_run: bool,
465 also_clean: bool,
466 ) -> (i32, String) {
467 let _g = env_lock();
468 let home = tempfile::tempdir().expect("tempdir");
469 if let Some(body) = config_body {
470 let dir = home.path().join(".config/ai-memory");
471 std::fs::create_dir_all(&dir).unwrap();
472 std::fs::write(dir.join("config.toml"), body).unwrap();
473 }
474 let prev_home = std::env::var("HOME").ok();
475 unsafe {
478 std::env::set_var("HOME", home.path());
479 }
480 let mut stdout: Vec<u8> = Vec::new();
481 let mut stderr: Vec<u8> = Vec::new();
482 let code = {
483 let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
484 let args = ConfigCliArgs {
485 action: ConfigAction::Migrate {
486 dry_run,
487 also_clean_claude_json: also_clean,
488 },
489 };
490 run(std::path::Path::new("unused.db"), args, &mut out).expect("run ok")
491 };
492 unsafe {
493 match prev_home {
494 Some(h) => std::env::set_var("HOME", h),
495 None => std::env::remove_var("HOME"),
496 }
497 }
498 (code, String::from_utf8(stderr).unwrap())
499 }
500
501 #[test]
502 fn run_migrate_missing_file_returns_two() {
503 let (code, stderr) = run_migrate_with_home(None, false, false);
504 assert_eq!(code, 2);
505 assert!(stderr.contains("no config file"), "got: {stderr}");
506 }
507
508 #[test]
509 fn run_migrate_invalid_toml_returns_three() {
510 let (code, stderr) = run_migrate_with_home(Some("this is { not valid toml"), false, false);
511 assert_eq!(code, 3);
512 assert!(stderr.contains("not valid TOML"), "got: {stderr}");
513 }
514
515 #[test]
516 fn run_migrate_already_v2_is_noop() {
517 let body = "schema_version = 2\ntier = \"autonomous\"\n\n[llm]\nbackend = \"xai\"\n";
518 let (code, stderr) = run_migrate_with_home(Some(body), false, false);
519 assert_eq!(code, 0);
520 assert!(stderr.contains("no migration needed"), "got: {stderr}");
521 }
522
523 #[test]
524 fn run_migrate_dry_run_returns_one() {
525 let body = "llm_model = \"gemma\"\nollama_url = \"http://localhost:11434\"\n";
526 let (code, stderr) = run_migrate_with_home(Some(body), true, true);
527 assert_eq!(code, 1);
528 assert!(stderr.contains("DRY RUN"), "got: {stderr}");
529 assert!(
530 stderr.contains("also-clean-claude-json also skipped"),
531 "got: {stderr}"
532 );
533 }
534
535 #[test]
536 fn run_migrate_apply_writes_backup_and_succeeds() {
537 let body = "llm_model = \"gemma\"\nollama_url = \"http://localhost:11434\"\n";
538 let (code, stderr) = run_migrate_with_home(Some(body), false, false);
539 assert_eq!(code, 0);
540 assert!(stderr.contains("OK: migrated"), "got: {stderr}");
541 assert!(stderr.contains("backup:"), "got: {stderr}");
542 assert!(stderr.contains("--also-clean-claude-json"), "got: {stderr}");
544 }
545
546 #[test]
547 fn run_migrate_apply_with_clean_no_claude_json() {
548 let body = "embedding_model = \"nomic_embed_v15\"\n";
549 let (code, stderr) = run_migrate_with_home(Some(body), false, true);
550 assert_eq!(code, 0);
551 assert!(stderr.contains("no mcpServers env block"), "got: {stderr}");
553 }
554
555 #[test]
556 fn migrate_v1_legacy_fields_to_sections() {
557 let toml_text = r#"
558tier = "autonomous"
559db = "/tmp/test.db"
560llm_model = "gemma4:e4b"
561ollama_url = "http://localhost:11434"
562embed_url = "http://localhost:11434"
563embedding_model = "nomic_embed_v15"
564cross_encoder = true
565default_namespace = "alphaone"
566archive_on_gc = true
567"#;
568 let value: toml::Value = toml::from_str(toml_text).unwrap();
569 let original = value.as_table().unwrap().clone();
570
571 let migrated = build_migrated_table(&original);
572
573 assert_eq!(
574 migrated
575 .get("schema_version")
576 .and_then(toml::Value::as_integer),
577 Some(2),
578 "schema_version must land at 2"
579 );
580
581 for k in LEGACY_FIELDS {
583 assert!(
584 !migrated.contains_key(*k),
585 "legacy field {k} should have been removed"
586 );
587 }
588
589 let llm = migrated.get("llm").and_then(toml::Value::as_table).unwrap();
591 assert_eq!(
592 llm.get("backend").and_then(toml::Value::as_str),
593 Some("ollama")
594 );
595 assert_eq!(
596 llm.get("model").and_then(toml::Value::as_str),
597 Some("gemma4:e4b")
598 );
599 assert_eq!(
600 llm.get("base_url").and_then(toml::Value::as_str),
601 Some("http://localhost:11434")
602 );
603
604 let emb = migrated
606 .get("embeddings")
607 .and_then(toml::Value::as_table)
608 .unwrap();
609 assert_eq!(
610 emb.get("model").and_then(toml::Value::as_str),
611 Some("nomic_embed_v15")
612 );
613
614 let rerank = migrated
616 .get("reranker")
617 .and_then(toml::Value::as_table)
618 .unwrap();
619 assert_eq!(
620 rerank.get("enabled").and_then(toml::Value::as_bool),
621 Some(true)
622 );
623 assert_eq!(
624 rerank.get("model").and_then(toml::Value::as_str),
625 Some("ms-marco-MiniLM-L-6-v2")
626 );
627
628 let storage = migrated
630 .get("storage")
631 .and_then(toml::Value::as_table)
632 .unwrap();
633 assert_eq!(
634 storage
635 .get("default_namespace")
636 .and_then(toml::Value::as_str),
637 Some("alphaone")
638 );
639 assert_eq!(
640 storage.get("archive_on_gc").and_then(toml::Value::as_bool),
641 Some(true)
642 );
643
644 assert_eq!(
646 migrated.get("tier").and_then(toml::Value::as_str),
647 Some("autonomous")
648 );
649 assert_eq!(
650 migrated.get("db").and_then(toml::Value::as_str),
651 Some("/tmp/test.db")
652 );
653 }
654
655 #[test]
656 fn migrate_idempotent_on_already_v2() {
657 let toml_text = r#"
658schema_version = 2
659tier = "autonomous"
660
661[llm]
662backend = "xai"
663model = "grok-4.3"
664api_key_env = "XAI_API_KEY"
665
666[storage]
667default_namespace = "alphaone"
668"#;
669 let value: toml::Value = toml::from_str(toml_text).unwrap();
670 let original = value.as_table().unwrap().clone();
671
672 let migrated = build_migrated_table(&original);
673
674 assert_eq!(
676 migrated
677 .get("schema_version")
678 .and_then(toml::Value::as_integer),
679 Some(2)
680 );
681
682 let llm = migrated.get("llm").and_then(toml::Value::as_table).unwrap();
684 assert_eq!(
685 llm.get("backend").and_then(toml::Value::as_str),
686 Some("xai")
687 );
688 assert_eq!(
689 llm.get("model").and_then(toml::Value::as_str),
690 Some("grok-4.3")
691 );
692 }
693
694 #[test]
695 fn migrate_does_not_overwrite_existing_sections() {
696 let toml_text = r#"
700llm_model = "legacy-model"
701ollama_url = "http://stale:9999"
702
703[llm]
704backend = "xai"
705model = "grok-4.3"
706"#;
707 let value: toml::Value = toml::from_str(toml_text).unwrap();
708 let original = value.as_table().unwrap().clone();
709
710 let migrated = build_migrated_table(&original);
711
712 assert!(!migrated.contains_key("llm_model"));
714 assert!(!migrated.contains_key("ollama_url"));
715
716 let llm = migrated.get("llm").and_then(toml::Value::as_table).unwrap();
718 assert_eq!(
719 llm.get("backend").and_then(toml::Value::as_str),
720 Some("xai")
721 );
722 assert_eq!(
723 llm.get("model").and_then(toml::Value::as_str),
724 Some("grok-4.3")
725 );
726 }
727}