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