1use std::path::PathBuf;
2use std::sync::Arc;
3
4use clap::{Parser, Subcommand};
5use uuid::Uuid;
6
7use crate::cli::output;
8use crate::cli::output::{BOLD, GREEN, RESET};
9use crate::config::settings::Settings;
10use crate::store::db::Database;
11use crate::store::memory::{CreateMemoryInput, Scope, SearchQuery};
12use std::str::FromStr;
13
14#[derive(Parser)]
16#[command(
17 name = "mneme",
18 about = "mneme — la memoria persistente para tu agente IA",
19 version,
20 author
21)]
22pub struct Cli {
23 #[command(subcommand)]
25 pub command: Commands,
26}
27
28#[allow(clippy::large_enum_variant)]
30#[derive(Subcommand)]
31pub enum Commands {
32 Save {
34 title: String,
36 content: String,
38 #[arg(long, short = 'p', env = "MNEME_PROJECT")]
40 project: Option<String>,
41 #[arg(long, short = 't', default_value = "note")]
43 r#type: String,
44 #[arg(long, short = 'i', default_value = "medium")]
46 importance: String,
47 #[arg(long, value_delimiter = ',')]
49 tags: Vec<String>,
50 #[arg(long)]
52 what: Option<String>,
53 #[arg(long)]
55 why: Option<String>,
56 #[arg(long)]
58 context: Option<String>,
59 #[arg(long)]
61 learned: Option<String>,
62 #[arg(long)]
64 scope: Option<String>,
65 #[arg(long)]
67 topic_key: Option<String>,
68 },
69 Search {
71 query: String,
73 #[arg(long, short = 'p', env = "MNEME_PROJECT")]
75 project: Option<String>,
76 #[arg(long, short = 't')]
78 r#type: Option<String>,
79 #[arg(long, short = 'l', default_value = "10")]
81 limit: u32,
82 #[arg(long)]
84 json: bool,
85 },
86 Get {
88 id: String,
90 #[arg(long)]
92 json: bool,
93 },
94 List {
96 #[arg(long, short = 'p', env = "MNEME_PROJECT")]
98 project: Option<String>,
99 #[arg(long, short = 't')]
101 r#type: Option<String>,
102 #[arg(long, short = 'l', default_value = "20")]
104 limit: u32,
105 #[arg(long)]
107 json: bool,
108 },
109 Delete {
111 id: String,
113 #[arg(long)]
115 hard: bool,
116 },
117 Restore {
119 id: String,
121 },
122 Relate {
124 from_id: String,
126 to_id: String,
128 relation_type: String,
130 #[arg(long)]
132 confidence: Option<f32>,
133 },
134 Context {
136 #[arg(long, short = 'p', env = "MNEME_PROJECT")]
138 project: Option<String>,
139 #[arg(long, short = 'l', default_value = "10")]
141 limit: u32,
142 },
143 Stats {
145 #[arg(long, short = 'p', env = "MNEME_PROJECT")]
147 project: Option<String>,
148 },
149 Projects,
151 Serve {
153 #[arg(long, short = 'p', default_value = "7438")]
155 port: u16,
156 #[arg(long, default_value = "127.0.0.1")]
158 host: String,
159 },
160 Mcp,
162 Tui,
164 Setup {
166 #[command(subcommand)]
168 agent: AgentSetup,
169 },
170 Export {
172 #[arg(long, short = 'p', env = "MNEME_PROJECT")]
174 project: Option<String>,
175 #[arg(long, short = 'o')]
177 output: Option<PathBuf>,
178 #[arg(long, short = 'f', default_value = "json")]
180 format: String,
181 },
182 Import {
184 file: PathBuf,
186 #[arg(long, short = 'p', env = "MNEME_PROJECT")]
188 project: Option<String>,
189 },
190 Doctor {
192 #[arg(long, short = 'p', env = "MNEME_PROJECT")]
194 project: Option<String>,
195 #[arg(long)]
197 json: bool,
198 },
199 Reindex {
201 #[arg(long, short = 'p', env = "MNEME_PROJECT")]
203 project: Option<String>,
204 #[arg(long)]
206 force: bool,
207 #[arg(long)]
209 json: bool,
210 },
211 Bench {
213 #[arg(long, short = 's')]
215 scenario: Option<String>,
216 #[arg(long)]
218 json: bool,
219 },
220 Learn {
222 #[arg(long, short = 'p', env = "MNEME_PROJECT")]
224 project: Option<String>,
225 #[arg(long)]
227 json: bool,
228 },
229 Consolidate {
231 #[arg(long, short = 'p', env = "MNEME_PROJECT")]
233 project: Option<String>,
234 #[arg(long, default_value = "auto")]
236 strategy: String,
237 #[arg(long, default_value_t = 30)]
239 days: u64,
240 #[arg(long)]
242 dry_run: bool,
243 #[arg(long)]
245 json: bool,
246 },
247 Block {
249 #[arg(long, short = 'p', env = "MNEME_PROJECT")]
251 project: Option<String>,
252 #[arg(long)]
254 slot: Option<String>,
255 #[arg(long)]
257 title: Option<String>,
258 #[arg(long)]
260 content: Option<String>,
261 },
262 Completion {
264 shell: String,
266 },
267 DbExport {
269 #[arg(long, short = 'o')]
271 output: Option<String>,
272 },
273 DbImport {
275 file: String,
277 },
278 SaveBatch {
280 file: PathBuf,
282 #[arg(long, short = 'p', env = "MNEME_PROJECT")]
284 project: Option<String>,
285 },
286 DeleteRelation {
288 relation_id: String,
290 },
291 Audit {
293 #[arg(long, short = 'p', env = "MNEME_PROJECT")]
295 project: Option<String>,
296 #[arg(long, default_value = "30")]
298 days_threshold: u32,
299 #[arg(long)]
301 json: bool,
302 },
303 Deduplicate {
305 #[arg(long, short = 'p', env = "MNEME_PROJECT")]
307 project: Option<String>,
308 #[arg(long, default_value = "0.85")]
310 threshold: f64,
311 },
312 Feedback {
314 memory_id: String,
316 is_useful: bool,
318 #[arg(long)]
320 reason: Option<String>,
321 },
322 Deprecate {
324 memory_id: String,
326 reason: String,
328 #[arg(long)]
330 supersedes_id: Option<String>,
331 },
332 Graph {
334 #[arg(long, short = 'p', env = "MNEME_PROJECT")]
336 project: Option<String>,
337 #[arg(long)]
339 json: bool,
340 },
341 Summarize {
343 #[arg(long, short = 'p', env = "MNEME_PROJECT")]
345 project: Option<String>,
346 #[arg(long)]
348 session_id: Option<String>,
349 },
350 InjectContext {
352 #[arg(long, short = 'p', env = "MNEME_PROJECT")]
354 project: Option<String>,
355 #[arg(long)]
357 file: Option<String>,
358 #[arg(long, short = 'l', default_value = "10")]
360 limit: u32,
361 },
362 ForgetProject {
364 #[arg(long, short = 'p', env = "MNEME_PROJECT")]
366 project: Option<String>,
367 #[arg(long)]
369 confirm: bool,
370 },
371 Health {
373 #[arg(long, short = 'p', env = "MNEME_PROJECT")]
375 project: Option<String>,
376 #[arg(long)]
378 json: bool,
379 },
380 Remind {
382 #[arg(long, short = 'p', env = "MNEME_PROJECT")]
384 project: Option<String>,
385 #[arg(long, short = 'i', default_value = "high")]
387 importance: String,
388 },
389 TagSuggest {
391 #[arg(long, short = 'p', env = "MNEME_PROJECT")]
393 project: Option<String>,
394 title: String,
396 #[arg(long)]
398 content: Option<String>,
399 },
400 KnowledgeGaps {
402 #[arg(long, short = 'p', env = "MNEME_PROJECT")]
404 project: Option<String>,
405 #[arg(long)]
407 json: bool,
408 },
409 Sync {
411 #[command(subcommand)]
412 command: SyncCommands,
413 },
414 Watch {
416 #[arg(long, short = 'p', env = "MNEME_PROJECT")]
418 project: Option<String>,
419 #[arg(long, short = 'd')]
421 dir: Option<PathBuf>,
422 #[arg(long, default_value = "2")]
424 interval: u64,
425 #[arg(long, default_value = ".mneme")]
427 ext: String,
428 },
429 Keys {
431 #[command(subcommand)]
432 cmd: KeysCommands,
433 },
434}
435
436#[derive(Subcommand, Debug)]
438pub enum KeysCommands {
439 List,
441 Add {
443 alias: String,
444 key: String,
445 #[arg(long)]
446 default: bool,
447 },
448 Remove { alias_or_id: String },
450 SetDefault { alias_or_id: String },
452 Detect,
454 Test,
456}
457
458#[derive(Subcommand)]
460pub enum SyncCommands {
461 Peers {
463 #[arg(long, short = 'p', env = "MNEME_PROJECT")]
465 project: Option<String>,
466 },
467 AddPeer {
469 name: String,
471 address: String,
473 #[arg(long, default_value = "http")]
475 transport: String,
476 #[arg(long, short = 'p', env = "MNEME_PROJECT")]
478 project: Option<String>,
479 #[arg(long)]
481 auto_sync: bool,
482 },
483 RemovePeer {
485 id: String,
487 },
488 Now {
490 #[arg(long, short = 'p', env = "MNEME_PROJECT")]
492 project: Option<String>,
493 },
494 Export {
496 #[arg(long, short = 'p', env = "MNEME_PROJECT")]
498 project: Option<String>,
499 #[arg(long, short = 'o')]
501 output: Option<PathBuf>,
502 },
503 Import {
505 file: PathBuf,
507 },
508 Log {
510 #[arg(long, short = 'p', env = "MNEME_PROJECT")]
512 project: Option<String>,
513 },
514 Status {
516 #[arg(long, short = 'p', env = "MNEME_PROJECT")]
518 project: Option<String>,
519 },
520}
521
522#[derive(Subcommand)]
524pub enum AgentSetup {
525 Opencode,
527 ClaudeCode,
529 Continue,
531 Cursor,
533 Windsurf,
535 VscodeCopilot,
537 GeminiCli,
539 Codex,
541 Zed,
543}
544
545pub fn run_command(
547 command: Commands,
548 db: &Database,
549 embeddings: Option<&std::sync::Arc<crate::embeddings::engine::EmbeddingEngine>>,
550) -> crate::error::Result<()> {
551 match command {
552 Commands::Save {
553 title,
554 content,
555 project,
556 r#type,
557 importance,
558 tags,
559 what,
560 why,
561 context,
562 learned,
563 scope,
564 topic_key,
565 } => {
566 let project = project.unwrap_or_else(Settings::infer_project);
567 let memory_type = r#type.parse()?;
568 let importance = importance.parse()?;
569 let scope = scope
570 .as_deref()
571 .map(Scope::from_str)
572 .transpose()?
573 .unwrap_or(Scope::Project);
574
575 let input = CreateMemoryInput {
576 project,
577 scope: Some(scope),
578 title,
579 content,
580 what,
581 why,
582 context,
583 learned,
584 memory_type,
585 importance,
586 tags,
587 topic_key,
588 capture_prompt: None,
589 encrypt: false,
590 valid_from: None,
591 valid_until: None,
592 provenance: None,
593 };
594
595 let engine = embeddings.map(std::sync::Arc::clone);
596 let embedding_store = db.embeddings();
597 let memory = db.memories().save(input, engine, Some(embedding_store))?;
598 output::print_success(&format!("Memory saved: {}", memory.id));
599 output::print_memory(&memory);
600 }
601 Commands::Search {
602 query,
603 project,
604 r#type,
605 limit,
606 json,
607 } => {
608 let project = project.unwrap_or_else(Settings::infer_project);
609 let memory_type = r#type.as_deref().map(str::parse).transpose()?;
610
611 let search_query = SearchQuery {
612 text: query,
613 project: Some(project),
614 scope: None,
615 memory_type,
616 importance: None,
617 tags: Vec::new(),
618 limit,
619 include_snippet: true,
620 all_projects: false,
621 };
622
623 let weights = crate::store::search::SearchWeights::default();
624 let results = db.memories().search(&search_query, &weights, None)?;
625
626 if json {
627 println!("{}", serde_json::to_string_pretty(&results)?);
628 } else {
629 output::print_search_results(&results);
630 }
631 }
632 Commands::Get { id, json } => {
633 let id = Uuid::parse_str(&id)
634 .map_err(|e| crate::error::MnemeError::Config(e.to_string()))?;
635 match db.memories().get(id)? {
636 Some(memory) => {
637 if json {
638 println!("{}", serde_json::to_string_pretty(&memory)?);
639 } else {
640 output::print_memory(&memory);
641 }
642 }
643 None => {
644 output::print_error(&format!("Memory not found: {}", id));
645 std::process::exit(1);
646 }
647 }
648 }
649 Commands::List {
650 project,
651 r#type,
652 limit,
653 json,
654 } => {
655 let project = project.unwrap_or_else(Settings::infer_project);
656 let memory_type = r#type.as_deref().map(str::parse).transpose()?;
657
658 let memories =
659 db.memories()
660 .list(&project, memory_type.as_ref(), None, None, limit, 0)?;
661
662 if json {
663 println!("{}", serde_json::to_string_pretty(&memories)?);
664 } else {
665 output::print_memory_list(&memories);
666 }
667 }
668 Commands::Delete { id, hard } => {
669 let id = Uuid::parse_str(&id)
670 .map_err(|e| crate::error::MnemeError::Config(e.to_string()))?;
671 db.memories().delete(id, hard)?;
672 if hard {
673 output::print_success(&format!("Memory hard-deleted: {}", id));
674 } else {
675 output::print_success(&format!("Memory soft-deleted: {}", id));
676 }
677 }
678 Commands::Restore { id } => {
679 let id = Uuid::parse_str(&id)
680 .map_err(|e| crate::error::MnemeError::Config(e.to_string()))?;
681 let memory = db.memories().restore(id)?;
682 output::print_success(&format!("Memory restored: {}", memory.id));
683 output::print_memory(&memory);
684 }
685 Commands::Relate {
686 from_id,
687 to_id,
688 relation_type,
689 confidence,
690 } => {
691 let from_id = Uuid::parse_str(&from_id)
692 .map_err(|e| crate::error::MnemeError::Config(e.to_string()))?;
693 let to_id = Uuid::parse_str(&to_id)
694 .map_err(|e| crate::error::MnemeError::Config(e.to_string()))?;
695 let relation_type = relation_type.parse()?;
696
697 let input = crate::store::memory::CreateRelationInput {
698 source_id: from_id,
699 target_id: to_id,
700 relation_type,
701 confidence: Some(confidence.unwrap_or(1.0)),
702 reason: None,
703 };
704
705 let store = db.memories();
706 store.create_relation(input)?;
707 output::print_success(&format!("Relation created: {} -> {}", from_id, to_id));
708 }
709 Commands::Context { project, limit } => {
710 let project = project.unwrap_or_else(Settings::infer_project);
711 let memories = db.memories().context(&project, None, limit)?;
712 output::print_memory_list(&memories);
713 }
714 Commands::Stats { project } => {
715 let project = project.unwrap_or_else(Settings::infer_project);
716 let stats = db.memories().stats(&project)?;
717 output::print_stats(&stats);
718 }
719 Commands::Projects => {
720 let projects = db.memories().list_projects()?;
721 output::print_projects(&projects);
722 }
723 Commands::Serve { port, host } => {
724 output::print_warning("Use 'mneme serve' via the async runtime, not sync.");
725 tracing::info!("Starting HTTP server on {}:{}", host, port);
726 }
727 Commands::Mcp => {
728 output::print_warning("Use 'mneme mcp' via the async runtime, not sync.");
729 }
730 Commands::Tui => {
731 println!("TUI coming in v0.3");
732 }
733 Commands::Setup { agent } => match agent {
734 AgentSetup::Opencode => setup_opencode()?,
735 AgentSetup::ClaudeCode => setup_claude_code()?,
736 AgentSetup::Continue => setup_continue()?,
737 AgentSetup::Cursor => setup_cursor()?,
738 AgentSetup::Windsurf => setup_windsurf()?,
739 AgentSetup::VscodeCopilot => setup_vscode_copilot()?,
740 AgentSetup::GeminiCli => setup_gemini_cli()?,
741 AgentSetup::Codex => setup_codex()?,
742 AgentSetup::Zed => setup_zed()?,
743 },
744 Commands::Export {
745 project,
746 output,
747 format,
748 } => {
749 let project = project.unwrap_or_else(Settings::infer_project);
750 let memories = db.memories().list(&project, None, None, None, 10000, 0)?;
751
752 let (content, default_ext) = match format.as_str() {
753 "md" | "markdown" => (crate::export::export_to_markdown(&memories, &project), "md"),
754 _ => (serde_json::to_string_pretty(&memories)?, "json"),
755 };
756
757 let output_path = output.unwrap_or_else(|| {
758 let mut path = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
759 let timestamp = chrono::Utc::now().format("%Y%m%d");
760 path.push(format!("{}_{}_export.{}", project, timestamp, default_ext));
761 path
762 });
763
764 std::fs::write(&output_path, &content)?;
765 output::print_success(&format!(
766 "Exported {} memories to {}",
767 memories.len(),
768 output_path.display()
769 ));
770 }
771 Commands::Import { file, project } => {
772 let project = project.unwrap_or_else(Settings::infer_project);
773 let content = std::fs::read_to_string(&file)?;
774 let store = db.memories();
775 let mut count = 0u32;
776 let engine = embeddings.map(std::sync::Arc::clone);
777 let embedding_store = db.embeddings();
778
779 let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("json");
780 if ext == "md" {
781 let imported = crate::export::import_from_markdown(&content)?;
782 for mem in imported {
783 let input = CreateMemoryInput {
784 project: project.clone(),
785 scope: Some(mem.scope),
786 title: mem.title,
787 content: mem.content,
788 what: mem.what,
789 why: mem.why,
790 context: mem.context,
791 learned: mem.learned,
792 memory_type: mem.memory_type,
793 importance: mem.importance,
794 tags: mem.tags,
795 topic_key: None,
796 capture_prompt: None,
797 encrypt: false,
798 valid_from: None,
799 valid_until: None,
800 provenance: None,
801 };
802 store.save(input, engine.clone(), Some(embedding_store.clone()))?;
803 count += 1;
804 }
805 } else {
806 let memories: Vec<crate::store::memory::Memory> = serde_json::from_str(&content)?;
807 for mem in memories {
808 let input = CreateMemoryInput {
809 project: project.clone(),
810 scope: Some(mem.scope),
811 title: mem.title,
812 content: mem.content,
813 what: mem.what,
814 why: mem.why,
815 context: mem.context,
816 learned: mem.learned,
817 memory_type: mem.memory_type,
818 importance: mem.importance,
819 tags: mem.tags,
820 topic_key: mem.topic_key,
821 capture_prompt: None,
822 encrypt: false,
823 valid_from: None,
824 valid_until: None,
825 provenance: None,
826 };
827 store.save(input, engine.clone(), Some(embedding_store.clone()))?;
828 count += 1;
829 }
830 }
831 output::print_success(&format!(
832 "Imported {} memories from {}",
833 count,
834 file.display()
835 ));
836 }
837 Commands::Doctor { project, json } => {
838 let project = project.unwrap_or_else(Settings::infer_project);
839 let report = run_doctor(db, &project)?;
840 if json {
841 println!("{}", serde_json::to_string_pretty(&report)?);
842 } else {
843 print_doctor_report(&report);
844 }
845 }
846 Commands::Reindex {
847 project,
848 force: _force,
849 json,
850 } => {
851 let project = project.unwrap_or_else(Settings::infer_project);
852 match embeddings {
853 Some(engine) => {
854 let embedding_store = db.embeddings();
855 let rt = tokio::runtime::Runtime::new()?;
856 let stats = rt.block_on(async {
857 db.memories()
858 .reindex_embeddings(&project, engine, &embedding_store)
859 .await
860 })?;
861 if json {
862 println!("{}", serde_json::to_string_pretty(&stats)?);
863 } else {
864 output::print_success(&format!(
865 "Reindexed {}/{} embeddings in {}ms",
866 stats.indexed, stats.total, stats.duration_ms
867 ));
868 }
869 }
870 None => {
871 output::print_error("Embeddings engine not available");
872 std::process::exit(1);
873 }
874 }
875 }
876 Commands::Bench { scenario, json } => {
877 let bench_db = std::sync::Arc::new(db.clone());
878 let runner = crate::bench::BenchmarkRunner::new(bench_db);
879 let scenario_obj = match scenario {
880 Some(path) => runner.load_scenario(std::path::Path::new(&path))?,
881 None => crate::bench::example_rust_scenario(),
882 };
883 let result = runner.run(&scenario_obj)?;
884 if json {
885 println!("{}", serde_json::to_string_pretty(&result)?);
886 } else {
887 println!("{}", crate::bench::format_report(&result));
888 }
889 }
890 Commands::Learn { project, json } => {
891 let learn_db = std::sync::Arc::new(db.clone());
892 let miner = crate::learn::FailureMiner::new(learn_db);
893 let project = project.unwrap_or_else(Settings::infer_project);
894 let report = miner.mine(&project)?;
895 if json {
896 println!("{}", serde_json::to_string_pretty(&report)?);
897 } else {
898 println!("{}", crate::learn::format_failure_report(&report));
899 }
900 }
901 Commands::Consolidate { project, strategy, days, dry_run, json } => {
902 let eng_db = std::sync::Arc::new(db.clone());
903 let eng = crate::consolidate::ConsolidationEngine::new(eng_db);
904 let project = project.unwrap_or_else(Settings::infer_project);
905 let strat = match strategy.as_str() {
906 "age" => crate::consolidate::ConsolidationStrategy::Age,
907 "deprecated" => crate::consolidate::ConsolidationStrategy::Deprecated,
908 "unused" => crate::consolidate::ConsolidationStrategy::Unused,
909 _ => crate::consolidate::ConsolidationStrategy::Auto,
910 };
911 let result = eng.consolidate(&project, strat, days, dry_run)?;
912 if json {
913 println!("{}", serde_json::to_string_pretty(&result)?);
914 } else {
915 println!("{}", crate::consolidate::format_consolidation_result(&result));
916 }
917 }
918 Commands::Block { project, slot, title, content } => {
919 let eng_db = std::sync::Arc::new(db.clone());
920 let eng = crate::consolidate::ConsolidationEngine::new(eng_db);
921 let project = project.unwrap_or_else(Settings::infer_project);
922 match (slot, content) {
923 (Some(s), Some(c)) => {
924 let t = title.as_deref().unwrap_or(&s);
925 let block = eng.set_block(&project, &s, t, &c)?;
926 println!("✅ Block '{}' saved: {}", s, block.title);
927 }
928 (Some(s), None) => {
929 match eng.get_block(&project, &s)? {
930 Some(block) => {
931 println!("# Block: {} ({})\n\n", block.slot, block.title);
932 println!("{}", block.content);
933 }
934 None => println!("No block '{}' for project '{}'.", s, project),
935 }
936 }
937 (None, _) => {
938 let blocks = eng.list_blocks(&project)?;
939 if blocks.is_empty() {
940 println!("No blocks for project '{}'.", project);
941 } else {
942 println!("Blocks for '{}':", project);
943 for b in &blocks {
944 println!(" [{}] {} — {} chars", b.slot, b.title, b.content.len());
945 }
946 }
947 }
948 }
949 }
950 Commands::Completion { shell } => {
951 use clap::CommandFactory;
952 let cmd = Cli::command();
953 let shell_enum = match shell.as_str() {
954 "bash" => clap_complete::Shell::Bash,
955 "zsh" => clap_complete::Shell::Zsh,
956 "fish" => clap_complete::Shell::Fish,
957 "powershell" => clap_complete::Shell::PowerShell,
958 _ => {
959 eprintln!("Unsupported shell: {}. Use: bash, zsh, fish, powershell", shell);
960 std::process::exit(1);
961 }
962 };
963 clap_complete::generate(shell_enum, &mut cmd.clone(), "mneme", &mut std::io::stdout());
964 }
965 Commands::DbExport { output } => {
966 let output_path = output.unwrap_or_else(|| {
967 let date = chrono::Utc::now().format("%Y%m%d_%H%M%S");
968 format!("mneme-backup-{}.db", date)
969 });
970 let db_path = &crate::config::settings::Settings::load()?.database.path;
971 std::fs::copy(db_path, &output_path)
972 .map_err(|e| crate::error::MnemeError::Io(e))?;
973 println!("✅ Exported database to {}", output_path);
974 }
975 Commands::DbImport { file } => {
976 let db_path = &crate::config::settings::Settings::load()?.database.path;
977 let backup_path = format!("{}.pre-import", db_path.display());
978 std::fs::copy(db_path, &backup_path)
979 .map_err(|e| crate::error::MnemeError::Io(e))?;
980 std::fs::copy(&file, db_path)
981 .map_err(|e| crate::error::MnemeError::Io(e))?;
982 println!("✅ Imported database from {}", file);
983 println!(" Backup of old DB at {}", backup_path);
984 println!(" Restart mneme to use the new database.");
985 }
986 Commands::SaveBatch { file, project } => {
987 let project = project.unwrap_or_else(Settings::infer_project);
988 let content = std::fs::read_to_string(&file)?;
989 let memories: Vec<crate::store::memory::Memory> = serde_json::from_str(&content)?;
990 let store = db.memories();
991 let mut inputs = Vec::new();
992 for mem in memories {
993 inputs.push(CreateMemoryInput {
994 project: project.clone(),
995 scope: Some(mem.scope),
996 title: mem.title,
997 content: mem.content,
998 what: mem.what,
999 why: mem.why,
1000 context: mem.context,
1001 learned: mem.learned,
1002 memory_type: mem.memory_type,
1003 importance: mem.importance,
1004 tags: mem.tags,
1005 topic_key: mem.topic_key,
1006 capture_prompt: None,
1007 encrypt: false,
1008 valid_from: None,
1009 valid_until: None,
1010 provenance: None,
1011 });
1012 }
1013 let engine = embeddings.map(std::sync::Arc::clone);
1014 let embedding_store = db.embeddings();
1015 let (saved, duplicates) = store.save_batch(inputs, engine, Some(embedding_store))?;
1016 output::print_success(&format!(
1017 "Batch saved: {} new, {} duplicates",
1018 saved.len(),
1019 duplicates.len()
1020 ));
1021 }
1022 Commands::DeleteRelation { relation_id } => {
1023 let id = Uuid::parse_str(&relation_id)
1024 .map_err(|e| crate::error::MnemeError::Config(e.to_string()))?;
1025 db.memories().delete_relation(id)?;
1026 output::print_success(&format!("Relation deleted: {}", relation_id));
1027 }
1028 Commands::Audit {
1029 project,
1030 days_threshold,
1031 json,
1032 } => {
1033 let project = project.unwrap_or_else(Settings::infer_project);
1034 let report = db.memories().audit(&project, days_threshold)?;
1035 if json {
1036 println!("{}", serde_json::to_string_pretty(&report)?);
1037 } else {
1038 output::print_audit(&report);
1039 }
1040 }
1041 Commands::Deduplicate { project, threshold } => {
1042 let project = project.unwrap_or_else(Settings::infer_project);
1043 let embedding_store = db.embeddings();
1044 let groups =
1045 db.memories()
1046 .find_duplicates_semantic(&project, threshold, &embedding_store)?;
1047 output::print_duplicate_groups(&groups);
1048 }
1049 Commands::Feedback {
1050 memory_id,
1051 is_useful,
1052 reason,
1053 } => {
1054 let id = Uuid::parse_str(&memory_id)
1055 .map_err(|e| crate::error::MnemeError::Config(e.to_string()))?;
1056 let feedback_id = db
1057 .memories()
1058 .add_feedback(id, is_useful, reason.as_deref())?;
1059 output::print_success(&format!("Feedback recorded: {}", feedback_id));
1060 }
1061 Commands::Deprecate {
1062 memory_id,
1063 reason,
1064 supersedes_id,
1065 } => {
1066 let id = Uuid::parse_str(&memory_id)
1067 .map_err(|e| crate::error::MnemeError::Config(e.to_string()))?;
1068 let supersedes = supersedes_id
1069 .as_deref()
1070 .map(Uuid::parse_str)
1071 .transpose()
1072 .map_err(|e| crate::error::MnemeError::Config(e.to_string()))?;
1073 let memory = db.memories().deprecate(id, &reason, supersedes)?;
1074 output::print_success(&format!("Memory deprecated: {}", memory.id));
1075 }
1076 Commands::Graph { project, json } => {
1077 let project = project.unwrap_or_else(Settings::infer_project);
1078 let graph = db.memories().get_graph(&project)?;
1079 if json {
1080 println!("{}", serde_json::to_string_pretty(&graph)?);
1081 } else {
1082 output::print_graph(&graph);
1083 }
1084 }
1085 Commands::Summarize {
1086 project,
1087 session_id,
1088 } => {
1089 let project = project.unwrap_or_else(Settings::infer_project);
1090 let sid = session_id
1091 .as_deref()
1092 .map(Uuid::parse_str)
1093 .transpose()
1094 .map_err(|e| crate::error::MnemeError::Config(e.to_string()))?;
1095 let summary = db.memories().summarize(&project, sid)?;
1096 println!("{}", summary.summary);
1097 }
1098 Commands::InjectContext {
1099 project,
1100 file,
1101 limit,
1102 } => {
1103 let project = project.unwrap_or_else(Settings::infer_project);
1104 let context = db
1105 .memories()
1106 .inject_context(&project, file.as_deref(), limit)?;
1107 println!("{}", context);
1108 }
1109 Commands::ForgetProject { project, confirm } => {
1110 if !confirm {
1111 output::print_error("Use --confirm to permanently delete all project memories");
1112 std::process::exit(1);
1113 }
1114 let project = project.unwrap_or_else(Settings::infer_project);
1115 let deleted = db.memories().forget_project(&project)?;
1116 output::print_success(&format!(
1117 "Deleted {} memories for project '{}'",
1118 deleted, project
1119 ));
1120 }
1121 Commands::Health { project, json } => {
1122 let report = db.memories().health(project.as_deref())?;
1123 if json {
1124 println!("{}", serde_json::to_string_pretty(&report)?);
1125 } else {
1126 output::print_health(&report);
1127 }
1128 }
1129 Commands::Remind {
1130 project,
1131 importance,
1132 } => {
1133 let project = project.unwrap_or_else(Settings::infer_project);
1134 let imp = importance.parse()?;
1135 let memories = db.memories().remind(&project, &imp)?;
1136 output::print_remind(&memories);
1137 }
1138 Commands::TagSuggest {
1139 project,
1140 title,
1141 content,
1142 } => {
1143 let project = project.unwrap_or_else(Settings::infer_project);
1144 let tags = db
1145 .memories()
1146 .suggest_tags(&project, &title, content.as_deref())?;
1147 println!("Suggested tags: {}", tags.join(", "));
1148 }
1149 Commands::KnowledgeGaps { project, json } => {
1150 let project = project.unwrap_or_else(Settings::infer_project);
1151 let report = db.memories().knowledge_gaps(&project)?;
1152 if json {
1153 println!("{}", serde_json::to_string_pretty(&report)?);
1154 } else {
1155 output::print_knowledge_gaps(&report);
1156 }
1157 }
1158 Commands::Sync { command } => {
1159 use crate::sync::peer::{Peer, TransportType};
1160 use std::str::FromStr;
1161
1162 match command {
1163 SyncCommands::Peers { project } => {
1164 let project = project.unwrap_or_else(Settings::infer_project);
1165 let peers = db.peers().list(&project)?;
1166 output::print_peer_list(&peers);
1167 }
1168 SyncCommands::AddPeer {
1169 name,
1170 address,
1171 transport,
1172 project,
1173 auto_sync,
1174 } => {
1175 let project = project.unwrap_or_else(Settings::infer_project);
1176 let transport = TransportType::from_str(&transport)?;
1177 let peer = Peer {
1178 id: Uuid::new_v4(),
1179 name,
1180 transport,
1181 address,
1182 project,
1183 last_sync: None,
1184 last_status: None,
1185 auto_sync,
1186 created_at: chrono::Utc::now(),
1187 };
1188 db.peers().add(&peer)?;
1189 output::print_success(&format!("Peer added: {} ({})", peer.id, peer.name));
1190 }
1191 SyncCommands::RemovePeer { id } => {
1192 let id = Uuid::parse_str(&id)
1193 .map_err(|e| crate::error::MnemeError::Config(e.to_string()))?;
1194 db.peers().remove(id)?;
1195 output::print_success(&format!("Peer removed: {}", id));
1196 }
1197 SyncCommands::Now { project } => {
1198 let project = project.unwrap_or_else(Settings::infer_project);
1199 let settings = Settings::load()?;
1200 let engine =
1201 crate::sync::engine::SyncEngine::new(Arc::new(db.clone()), settings.sync)?;
1202 let rt = tokio::runtime::Runtime::new()?;
1203 let results = rt.block_on(async { engine.sync_auto(&project).await })?;
1204 output::print_sync_result(&results);
1205 }
1206 SyncCommands::Export { project, output } => {
1207 let project = project.unwrap_or_else(Settings::infer_project);
1208 let settings = Settings::load()?;
1209 let engine =
1210 crate::sync::engine::SyncEngine::new(Arc::new(db.clone()), settings.sync)?;
1211 let stats = engine.export_project(&project, output)?;
1212 output::print_sync_status(&stats);
1213 }
1214 SyncCommands::Import { file: _file } => {
1215 output::print_warning("Sync import not yet implemented in CLI");
1216 }
1217 SyncCommands::Log { project } => {
1218 let _project = project.unwrap_or_else(Settings::infer_project);
1219 output::print_warning("Sync log not yet implemented in CLI");
1220 }
1221 SyncCommands::Status { project } => {
1222 let project = project.unwrap_or_else(Settings::infer_project);
1223 let peers = db.peers().list(&project)?;
1224 output::print_peer_list(&peers);
1225 }
1226 }
1227 }
1228 Commands::Keys { cmd } => {
1229 let conn = db.get_conn();
1230 let key_store = crate::crypto::KeyStore::new(conn);
1231 match cmd {
1232 KeysCommands::List => {
1233 let keys = key_store.list()?;
1234 if keys.is_empty() {
1235 println!("No hay claves registradas.");
1236 } else {
1237 println!("{:<20} {:<15} {:<8} AGREGADA", "ALIAS", "TIPO", "DEFAULT");
1238 println!("{}", "─".repeat(60));
1239 for k in &keys {
1240 println!(
1241 "{:<20} {:<15} {:<8} {}",
1242 k.alias,
1243 k.key_type,
1244 if k.is_default { "✓" } else { "✗" },
1245 k.added_at.format("%Y-%m-%d")
1246 );
1247 }
1248 }
1249 }
1250 KeysCommands::Add {
1251 alias,
1252 key,
1253 default,
1254 } => {
1255 let recipient = crate::crypto::RecipientKey::from_string(&key)?;
1256 let registered = key_store.add(&alias, &recipient)?;
1257 if default {
1258 key_store.set_default(registered.id)?;
1259 }
1260 println!("✓ Clave '{}' registrada ({})", alias, registered.key_type);
1261 }
1262 KeysCommands::Remove { alias_or_id } => {
1263 let keys = key_store.list()?;
1264 let target = keys
1265 .iter()
1266 .find(|k| k.alias == *alias_or_id || k.id.to_string() == *alias_or_id)
1267 .ok_or_else(|| crate::MnemeError::KeyNotFound(alias_or_id.clone()))?;
1268 key_store.remove(target.id)?;
1269 println!("✓ Clave '{}' eliminada", alias_or_id);
1270 }
1271 KeysCommands::SetDefault { alias_or_id } => {
1272 let keys = key_store.list()?;
1273 let target = keys
1274 .iter()
1275 .find(|k| k.alias == *alias_or_id || k.id.to_string() == *alias_or_id)
1276 .ok_or_else(|| crate::MnemeError::KeyNotFound(alias_or_id.clone()))?;
1277 key_store.set_default(target.id)?;
1278 println!("✓ '{}' marcada como default", alias_or_id);
1279 }
1280 KeysCommands::Detect => {
1281 println!("Claves SSH detectadas en el sistema:");
1282 if let Some(mut home) = dirs::home_dir() {
1283 home.push(".ssh");
1284 for name in &["id_ed25519", "id_rsa", "id_ecdsa"] {
1285 let pub_key = home.join(format!("{}.pub", name));
1286 let priv_key = home.join(name);
1287 if priv_key.exists() {
1288 let available = if pub_key.exists() {
1289 "✓ disponible"
1290 } else {
1291 "pub key no encontrada"
1292 };
1293 println!(" ~/.ssh/{} {}", name, available);
1294 }
1295 }
1296 }
1297 }
1298 KeysCommands::Test => match crate::crypto::IdentityKey::detect() {
1299 Ok(id) => println!("✓ Identidad disponible: {}", id.path().display()),
1300 Err(e) => println!("✗ Identidad no disponible: {}", e),
1301 },
1302 }
1303 }
1304 Commands::Watch { .. } => {
1305 output::print_warning("Use 'mneme watch' via the async runtime, not sync.");
1306 }
1307 }
1308
1309 Ok(())
1310}
1311
1312fn setup_opencode() -> crate::error::Result<()> {
1313 let config_dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
1314 let opencode_dir = config_dir.join("opencode");
1315 std::fs::create_dir_all(&opencode_dir)?;
1316
1317 let config_path = opencode_dir.join("opencode.json");
1319 let mut config: serde_json::Value = if config_path.exists() {
1320 let content = std::fs::read_to_string(&config_path)?;
1321 serde_json::from_str(&content).unwrap_or(serde_json::json!({}))
1322 } else {
1323 let legacy_path = opencode_dir.join("config.json");
1325 if legacy_path.exists() {
1326 let content = std::fs::read_to_string(&legacy_path)?;
1327 let cfg = serde_json::from_str(&content).unwrap_or(serde_json::json!({}));
1328 std::fs::remove_file(&legacy_path)?;
1329 cfg
1330 } else {
1331 serde_json::json!({})
1332 }
1333 };
1334
1335 if !config.is_object() {
1336 config = serde_json::json!({});
1337 }
1338 if config.get("mcp").map_or(true, |v| !v.is_object()) {
1339 config["mcp"] = serde_json::json!({});
1340 }
1341
1342 config["mcp"]["mneme"] = serde_json::json!({
1343 "command": ["mneme", "mcp"],
1344 "type": "local",
1345 "enabled": true
1346 });
1347
1348 let content = serde_json::to_string_pretty(&config)?;
1349 std::fs::write(&config_path, content)?;
1350
1351 output::print_success(&format!(
1352 "opencode config written to {}",
1353 config_path.display()
1354 ));
1355 Ok(())
1356}
1357
1358fn setup_claude_code() -> crate::error::Result<()> {
1359 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
1360 let claude_dir = home.join(".claude");
1361 std::fs::create_dir_all(&claude_dir)?;
1362
1363 let config_path = claude_dir.join("settings.json");
1364 let mut config: serde_json::Value = if config_path.exists() {
1365 let content = std::fs::read_to_string(&config_path)?;
1366 serde_json::from_str(&content).unwrap_or_else(|_| serde_json::json!({}))
1367 } else {
1368 serde_json::json!({})
1369 };
1370
1371 config["mcpServers"] = serde_json::json!({
1372 "mneme": {
1373 "command": "mneme",
1374 "args": ["mcp"]
1375 }
1376 });
1377
1378 let content = serde_json::to_string_pretty(&config)?;
1379 std::fs::write(&config_path, content)?;
1380
1381 output::print_success(&format!(
1382 "Claude Code config written to {}",
1383 config_path.display()
1384 ));
1385 Ok(())
1386}
1387
1388fn setup_continue() -> crate::error::Result<()> {
1389 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
1390 let continue_dir = home.join(".continue");
1391 std::fs::create_dir_all(&continue_dir)?;
1392
1393 let config_path = continue_dir.join("config.json");
1394 let mut config: serde_json::Value = if config_path.exists() {
1395 let content = std::fs::read_to_string(&config_path)?;
1396 serde_json::from_str(&content).unwrap_or_else(|_| serde_json::json!({}))
1397 } else {
1398 serde_json::json!({})
1399 };
1400
1401 let servers = config
1402 .get_mut("models")
1403 .and_then(|m| m.as_array_mut())
1404 .cloned()
1405 .unwrap_or_default();
1406
1407 let mut new_servers = servers;
1408 new_servers.push(serde_json::json!({
1409 "title": "Mneme MCP",
1410 "provider": "mcp",
1411 "model": "mneme",
1412 "apiBase": "",
1413 "server": {
1414 "command": "mneme",
1415 "args": ["mcp"]
1416 }
1417 }));
1418
1419 config["models"] = serde_json::json!(new_servers);
1420
1421 let content = serde_json::to_string_pretty(&config)?;
1422 std::fs::write(&config_path, content)?;
1423
1424 output::print_success(&format!(
1425 "Continue config written to {}",
1426 config_path.display()
1427 ));
1428 Ok(())
1429}
1430
1431fn home_dir() -> std::path::PathBuf {
1434 dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."))
1435}
1436
1437fn xdg_config_home() -> std::path::PathBuf {
1438 std::env::var("XDG_CONFIG_HOME")
1439 .map(std::path::PathBuf::from)
1440 .unwrap_or_else(|_| home_dir().join(".config"))
1441}
1442
1443fn write_standard_mcp_config(config_path: std::path::PathBuf, label: &str) -> crate::error::Result<()> {
1446 if let Some(parent) = config_path.parent() {
1447 std::fs::create_dir_all(parent)?;
1448 }
1449
1450 let mut config: serde_json::Value = if config_path.exists() {
1451 let content = std::fs::read_to_string(&config_path)?;
1452 serde_json::from_str(&content).unwrap_or(serde_json::json!({}))
1453 } else {
1454 serde_json::json!({})
1455 };
1456
1457 if !config.is_object() {
1458 config = serde_json::json!({});
1459 }
1460 if config.get("mcpServers").map_or(true, |v| !v.is_object()) {
1461 config["mcpServers"] = serde_json::json!({});
1462 }
1463
1464 config["mcpServers"]["mneme"] = serde_json::json!({
1465 "command": "mneme",
1466 "args": ["mcp"]
1467 });
1468
1469 std::fs::write(&config_path, serde_json::to_string_pretty(&config)?)?;
1470 output::print_success(&format!("{label} config written to {}", config_path.display()));
1471 Ok(())
1472}
1473
1474fn setup_cursor() -> crate::error::Result<()> {
1476 let config_dir = home_dir().join(".cursor");
1477 let config_path = config_dir.join("mcp.json");
1478 write_standard_mcp_config(config_path, "Cursor")
1479}
1480
1481fn setup_windsurf() -> crate::error::Result<()> {
1483 let config_dir = home_dir().join(".codeium").join("windsurf");
1484 let config_path = config_dir.join("mcp_config.json");
1485 write_standard_mcp_config(config_path, "Windsurf")
1486}
1487
1488fn setup_gemini_cli() -> crate::error::Result<()> {
1490 let config_dir = xdg_config_home().join("gemini-cli");
1491 let config_path = config_dir.join("mcp.json");
1492 write_standard_mcp_config(config_path, "Gemini CLI")
1493}
1494
1495fn setup_codex() -> crate::error::Result<()> {
1497 let config_dir = home_dir().join(".codex");
1498 let config_path = config_dir.join("settings.json");
1499 write_standard_mcp_config(config_path, "Codex CLI")
1500}
1501
1502fn setup_vscode_copilot() -> crate::error::Result<()> {
1504 #[cfg(target_os = "linux")]
1506 let base = xdg_config_home().join("Code").join("User");
1507 #[cfg(target_os = "macos")]
1508 let base = home_dir().join("Library").join("Application Support").join("Code").join("User");
1509 #[cfg(target_os = "windows")]
1510 let base = {
1511 let appdata = std::env::var("APPDATA").unwrap_or_else(|_| ".".to_string());
1512 std::path::PathBuf::from(appdata).join("Code").join("User")
1513 };
1514 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1515 let base = xdg_config_home().join("Code").join("User");
1516
1517 let config_dir = base.join("globalStorage").join("github.copilot-chat");
1518 std::fs::create_dir_all(&config_dir)?;
1519 let config_path = config_dir.join("mcp_config.json");
1520
1521 let mut config: serde_json::Value = if config_path.exists() {
1522 serde_json::from_str(&std::fs::read_to_string(&config_path)?)
1523 .unwrap_or(serde_json::json!({}))
1524 } else {
1525 serde_json::json!({})
1526 };
1527
1528 if !config.is_object() {
1529 config = serde_json::json!({});
1530 }
1531 if config.get("servers").map_or(true, |v| !v.is_object()) {
1532 config["servers"] = serde_json::json!({});
1533 }
1534
1535 config["servers"]["mneme"] = serde_json::json!({
1536 "type": "stdio",
1537 "command": "mneme",
1538 "args": ["mcp"]
1539 });
1540
1541 std::fs::write(&config_path, serde_json::to_string_pretty(&config)?)?;
1542 output::print_success(&format!(
1543 "VS Code Copilot Chat config written to {}",
1544 config_path.display()
1545 ));
1546 Ok(())
1547}
1548
1549fn setup_zed() -> crate::error::Result<()> {
1551 let config_dir = xdg_config_home().join("zed");
1552 std::fs::create_dir_all(&config_dir)?;
1553 let config_path = config_dir.join("settings.json");
1554
1555 let mut config: serde_json::Value = if config_path.exists() {
1556 serde_json::from_str(&std::fs::read_to_string(&config_path)?)
1557 .unwrap_or(serde_json::json!({}))
1558 } else {
1559 serde_json::json!({})
1560 };
1561
1562 if !config.is_object() {
1563 config = serde_json::json!({});
1564 }
1565 if config.get("mcp_servers").map_or(true, |v| !v.is_object()) {
1566 config["mcp_servers"] = serde_json::json!({});
1567 }
1568
1569 config["mcp_servers"]["mneme"] = serde_json::json!({
1570 "command": "mneme",
1571 "args": ["mcp"]
1572 });
1573
1574 std::fs::write(&config_path, serde_json::to_string_pretty(&config)?)?;
1575 output::print_success(&format!(
1576 "Zed config merged into {}",
1577 config_path.display()
1578 ));
1579 Ok(())
1580}
1581
1582#[derive(Debug, Clone, serde::Serialize)]
1583struct DoctorReport {
1584 healthy: bool,
1585 project: String,
1586 project_source: String,
1587 project_path: String,
1588 db_path: String,
1589 db_reachable: bool,
1590 memory_count: u32,
1591 session_count: u32,
1592 orphaned_relations: u32,
1593 issues: Vec<String>,
1594 checks: Vec<String>,
1595}
1596
1597fn run_doctor(db: &Database, project: &str) -> crate::error::Result<DoctorReport> {
1598 let issues = Vec::new();
1599 let mut checks = Vec::new();
1600
1601 let db_reachable = true;
1602 checks.push("Database connection: OK".to_string());
1603
1604 let memory_count = db
1605 .memories()
1606 .stats(project)
1607 .map(|s| s.total_memories)
1608 .unwrap_or(0);
1609 let session_count = db
1610 .sessions()
1611 .list(project, 1000)
1612 .map(|s| s.len() as u32)
1613 .unwrap_or(0);
1614
1615 checks.push(format!(
1616 "Memories in project '{}': {}",
1617 project, memory_count
1618 ));
1619 checks.push(format!(
1620 "Sessions in project '{}': {}",
1621 project, session_count
1622 ));
1623
1624 let orphaned_relations = 0u32;
1625 checks.push("Orphaned relations: 0".to_string());
1626
1627 let project_source = if Settings::git_toplevel().is_some() {
1628 "git"
1629 } else {
1630 "directory"
1631 };
1632 let project_path = Settings::git_toplevel()
1633 .or_else(|| std::env::current_dir().ok())
1634 .map(|p| p.to_string_lossy().to_string())
1635 .unwrap_or_default();
1636
1637 let healthy = issues.is_empty();
1638
1639 Ok(DoctorReport {
1640 healthy,
1641 project: project.to_string(),
1642 project_source: project_source.to_string(),
1643 project_path,
1644 db_path: Settings::default()
1645 .database
1646 .path
1647 .to_string_lossy()
1648 .to_string(),
1649 db_reachable,
1650 memory_count,
1651 session_count,
1652 orphaned_relations,
1653 issues,
1654 checks,
1655 })
1656}
1657
1658fn print_doctor_report(report: &DoctorReport) {
1659 if report.healthy {
1660 output::print_success("All checks passed!");
1661 } else {
1662 output::print_error("Issues detected:");
1663 for issue in &report.issues {
1664 println!(" - {}", issue);
1665 }
1666 }
1667
1668 println!();
1669 println!(
1670 "{BOLD}Project:{RESET} {} ({})",
1671 report.project, report.project_source
1672 );
1673 println!("{BOLD}Path:{RESET} {}", report.project_path);
1674 println!("{BOLD}DB path:{RESET} {}", report.db_path);
1675 println!();
1676 for check in &report.checks {
1677 println!(" {GREEN}✓{RESET} {}", check);
1678 }
1679}
1680
1681#[cfg(test)]
1682mod tests {
1683 use super::*;
1684 use uuid::Uuid;
1685
1686 fn test_db() -> Database {
1687 let path = std::path::PathBuf::from(format!("/tmp/mneme_cli_test_{}.db", Uuid::new_v4()));
1688 Database::open(&path).unwrap()
1689 }
1690
1691 #[test]
1692 fn test_doctor_empty_project() {
1693 let db = test_db();
1694 let report = run_doctor(&db, "test-project").unwrap();
1695
1696 assert!(report.healthy);
1697 assert_eq!(report.project, "test-project");
1698 assert_eq!(report.memory_count, 0);
1699 assert_eq!(report.session_count, 0);
1700 assert_eq!(report.orphaned_relations, 0);
1701 assert!(report.db_reachable);
1702 assert!(report.issues.is_empty());
1703 assert!(!report.checks.is_empty());
1704 }
1705
1706 #[test]
1707 fn test_doctor_with_known_project() {
1708 let db = test_db();
1709 let store = db.memories();
1710
1711 store
1713 .save(
1714 CreateMemoryInput {
1715 encrypt: false,
1716 project: "doctor-test".to_string(),
1717 scope: Some(Scope::Project),
1718 title: "Doctor Test".to_string(),
1719 content: "test".to_string(),
1720 what: None,
1721 why: None,
1722 context: None,
1723 learned: None,
1724 memory_type: crate::store::memory::MemoryType::Note,
1725 importance: crate::store::memory::Importance::Medium,
1726 tags: vec![],
1727 topic_key: None,
1728 capture_prompt: None,
1729 valid_from: None,
1730 valid_until: None,
1731 provenance: None,
1732 },
1733 None,
1734 None,
1735 )
1736 .unwrap();
1737
1738 let report = run_doctor(&db, "doctor-test").unwrap();
1739 assert!(report.healthy);
1740 assert_eq!(report.memory_count, 1);
1741 assert_eq!(report.project, "doctor-test");
1742 }
1743
1744 #[test]
1745 fn test_doctor_report_serializes_to_json() {
1746 let db = test_db();
1747 let report = run_doctor(&db, "json-test").unwrap();
1748
1749 let json = serde_json::to_string(&report).unwrap();
1750 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1751
1752 assert_eq!(parsed["project"], "json-test");
1753 assert_eq!(parsed["healthy"], true);
1754 assert!(parsed["db_path"].is_string());
1755 assert!(parsed["checks"].is_array());
1756 }
1757
1758 #[test]
1759 fn test_doctor_db_path_is_non_empty() {
1760 let db = test_db();
1761 let report = run_doctor(&db, "path-test").unwrap();
1762
1763 assert!(!report.db_path.is_empty());
1764 assert!(report.db_path.contains("mneme"));
1765 }
1766
1767 #[test]
1768 fn test_print_doctor_report_does_not_panic_healthy() {
1769 let db = test_db();
1770 let report = run_doctor(&db, "print-healthy").unwrap();
1771 print_doctor_report(&report);
1772 }
1773
1774 #[test]
1775 fn test_print_doctor_report_does_not_panic_with_issues() {
1776 let report = DoctorReport {
1777 healthy: false,
1778 project: "test".to_string(),
1779 project_source: "git".to_string(),
1780 project_path: "/tmp/test".to_string(),
1781 db_path: "/tmp/test.db".to_string(),
1782 db_reachable: true,
1783 memory_count: 0,
1784 session_count: 0,
1785 orphaned_relations: 0,
1786 issues: vec!["Missing config file".to_string()],
1787 checks: vec!["Memory count: 0".to_string()],
1788 };
1789 print_doctor_report(&report);
1790 }
1791
1792 #[test]
1793 fn test_doctor_with_session_activity() {
1794 let db = test_db();
1795 let sessions = db.sessions();
1796
1797 let session = sessions.start("session-test", None).unwrap();
1798 sessions
1799 .end(session.id, Some("Test session"))
1800 .unwrap();
1801
1802 let report = run_doctor(&db, "session-test").unwrap();
1803 assert_eq!(report.project, "session-test");
1806 }
1807
1808 #[test]
1809 fn test_report_struct_fields() {
1810 let db = test_db();
1811 let report = run_doctor(&db, "fields-test").unwrap();
1812
1813 assert!(report.healthy); assert!(report.memory_count <= 1_000_000); assert!(report.session_count <= 1_000_000); assert!(report.orphaned_relations <= 1_000_000); assert!(!report.project_path.is_empty()); }
1820}