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