1use crate::commands::*;
6use crate::i18n::{current, Language};
7use clap::{Parser, Subcommand};
8
9fn max_concurrency_ceiling() -> usize {
11 std::thread::available_parallelism()
12 .map(|n| n.get() * 2)
13 .unwrap_or(8)
14}
15
16#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
17pub enum GraphExportFormat {
18 Json,
19 Dot,
20 Mermaid,
21 Ndjson,
23}
24
25#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
29pub enum LlmBackendChoice {
30 Auto,
31 Claude,
32 Codex,
33 Opencode,
34 OpenRouter,
35 None,
36}
37
38#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
44pub enum EmbeddingBackendChoice {
45 Auto,
46 Openrouter,
47 Llm,
48}
49
50impl EmbeddingBackendChoice {
51 pub fn to_chain(self, llm_choice: LlmBackendChoice) -> Vec<crate::embedder::LlmBackendKind> {
54 use crate::embedder::LlmBackendKind;
55 match self {
56 EmbeddingBackendChoice::Openrouter => vec![LlmBackendKind::OpenRouter],
57 EmbeddingBackendChoice::Llm => llm_choice.to_chain(),
58 EmbeddingBackendChoice::Auto => {
59 if crate::embedder::is_openrouter_initialized() {
60 let mut chain = vec![LlmBackendKind::OpenRouter];
61 chain.extend(llm_choice.to_chain());
62 chain
63 } else {
64 llm_choice.to_chain()
65 }
66 }
67 }
68 }
69}
70
71impl LlmBackendChoice {
72 pub fn to_chain(self) -> Vec<crate::embedder::LlmBackendKind> {
82 use crate::embedder::LlmBackendKind;
83 match self {
84 LlmBackendChoice::Codex => vec![LlmBackendKind::Codex, LlmBackendKind::None],
85 LlmBackendChoice::Claude => vec![LlmBackendKind::Claude, LlmBackendKind::None],
86 LlmBackendChoice::Opencode => vec![
87 LlmBackendKind::Opencode,
88 LlmBackendKind::Codex,
89 LlmBackendKind::Claude,
90 LlmBackendKind::None,
91 ],
92 LlmBackendChoice::OpenRouter => vec![
93 LlmBackendKind::OpenRouter,
94 LlmBackendKind::Codex,
95 LlmBackendKind::None,
96 ],
97 LlmBackendChoice::None => vec![LlmBackendKind::None],
98 LlmBackendChoice::Auto => parse_fallback_chain(
99 &std::env::var("SQLITE_GRAPHRAG_LLM_FALLBACK")
100 .unwrap_or_else(|_| "codex,claude,none".to_string()),
101 ),
102 }
103 }
104}
105
106fn parse_fallback_chain(s: &str) -> Vec<crate::embedder::LlmBackendKind> {
107 use crate::embedder::LlmBackendKind;
108 let mut chain: Vec<LlmBackendKind> = s
109 .split(',')
110 .filter_map(|tok| match tok.trim().to_ascii_lowercase().as_str() {
111 "codex" => Some(LlmBackendKind::Codex),
112 "claude" | "claude-code" => Some(LlmBackendKind::Claude),
113 "opencode" => Some(LlmBackendKind::Opencode),
114 "openrouter" => Some(LlmBackendKind::OpenRouter),
115 "none" => Some(LlmBackendKind::None),
116 _ => {
117 tracing::warn!(
118 token = tok.trim(),
119 "unknown backend in --llm-fallback, skipping"
120 );
121 Option::None
122 }
123 })
124 .collect();
125 if chain.is_empty() {
126 chain = vec![
127 LlmBackendKind::Codex,
128 LlmBackendKind::Claude,
129 LlmBackendKind::None,
130 ];
131 }
132 chain
133}
134
135#[derive(Parser)]
136#[command(name = "sqlite-graphrag")]
137#[command(version)]
138#[command(about = "Local GraphRAG memory for LLMs in a single SQLite file")]
139#[command(arg_required_else_help = true)]
140#[command(after_help = "DATABASE PATH (GAP-SG-32):\n \
141 `--db` is a PER-SUBCOMMAND flag, so it must come AFTER the subcommand:\n \
142 sqlite-graphrag remember --db ./graphrag.sqlite --name mem --type note ...\n \
143 Placing it before the subcommand (e.g. `sqlite-graphrag --db x.sqlite remember`) is rejected.\n \
144 For a position-independent path, set the canonical env var instead:\n \
145 SQLITE_GRAPHRAG_DB_PATH=./graphrag.sqlite sqlite-graphrag remember --name mem ...")]
146pub struct Cli {
147 #[arg(long, global = true, value_name = "N")]
152 pub max_concurrency: Option<usize>,
153
154 #[arg(long, global = true, value_name = "SECONDS")]
159 pub wait_lock: Option<u64>,
160
161 #[arg(long, global = true, hide = true, default_value_t = false)]
165 pub skip_memory_guard: bool,
166
167 #[arg(
176 long,
177 global = true,
178 hide = true,
179 default_value_t = false,
180 value_parser = clap::builder::BoolishValueParser::new(),
181 env = "SQLITE_GRAPHRAG_STRICT_ENV_CLEAR"
182 )]
183 pub strict_env_clear: bool,
184
185 #[arg(
192 long,
193 global = true,
194 hide = true,
195 default_value_t = false,
196 value_parser = clap::builder::BoolishValueParser::new(),
197 env = "SQLITE_GRAPHRAG_DRY_RUN_BACKEND"
198 )]
199 pub dry_run_backend: bool,
200
201 #[arg(long, global = true, value_enum, value_name = "LANG")]
207 pub lang: Option<crate::i18n::Language>,
208
209 #[arg(long, global = true, value_name = "IANA")]
215 pub tz: Option<chrono_tz::Tz>,
216
217 #[arg(short = 'v', long, global = true, action = clap::ArgAction::Count)]
222 pub verbose: u8,
223
224 #[arg(long, global = true, value_name = "KIND", default_value = "llm")]
231 pub extraction_backend: Option<String>,
232
233 #[arg(long, global = true, value_name = "N", value_parser = clap::value_parser!(u64).range(8..=4096))]
241 pub embedding_dim: Option<u64>,
242
243 #[arg(long, global = true, value_enum, default_value_t = LlmBackendChoice::Auto, env = "SQLITE_GRAPHRAG_LLM_BACKEND")]
251 pub llm_backend: LlmBackendChoice,
252
253 #[arg(
257 long,
258 global = true,
259 value_name = "MODEL",
260 env = "SQLITE_GRAPHRAG_LLM_MODEL"
261 )]
262 pub llm_model: Option<String>,
263
264 #[arg(
267 long,
268 global = true,
269 value_name = "PATH",
270 env = "SQLITE_GRAPHRAG_CLAUDE_BINARY"
271 )]
272 pub claude_binary: Option<std::path::PathBuf>,
273
274 #[arg(
277 long,
278 global = true,
279 value_name = "PATH",
280 env = "SQLITE_GRAPHRAG_CODEX_BINARY"
281 )]
282 pub codex_binary: Option<std::path::PathBuf>,
283
284 #[arg(
287 long,
288 global = true,
289 value_name = "PATH",
290 env = "SQLITE_GRAPHRAG_OPENCODE_BINARY"
291 )]
292 pub opencode_binary: Option<std::path::PathBuf>,
293
294 #[arg(
298 long,
299 global = true,
300 default_value = "codex,claude,none",
301 env = "SQLITE_GRAPHRAG_LLM_FALLBACK"
302 )]
303 pub llm_fallback: String,
304
305 #[arg(
310 long,
311 global = true,
312 default_value_t = false,
313 value_parser = clap::builder::BoolishValueParser::new(),
314 env = "SQLITE_GRAPHRAG_SKIP_EMBEDDING_ON_FAILURE"
315 )]
316 pub skip_embedding_on_failure: bool,
317
318 #[arg(
322 long,
323 global = true,
324 value_name = "N",
325 env = "SQLITE_GRAPHRAG_LLM_MAX_HOST_CONCURRENCY"
326 )]
327 pub llm_max_host_concurrency: Option<u32>,
328
329 #[arg(
333 long,
334 global = true,
335 value_name = "SECONDS",
336 env = "SQLITE_GRAPHRAG_LLM_SLOT_WAIT_SECS"
337 )]
338 pub llm_slot_wait_secs: Option<u64>,
339
340 #[arg(
344 long,
345 global = true,
346 default_value_t = false,
347 value_parser = clap::builder::BoolishValueParser::new(),
348 env = "SQLITE_GRAPHRAG_LLM_SLOT_NO_WAIT"
349 )]
350 pub llm_slot_no_wait: bool,
351
352 #[arg(long, global = true, value_enum, default_value_t = EmbeddingBackendChoice::Auto, env = "SQLITE_GRAPHRAG_EMBEDDING_BACKEND")]
356 pub embedding_backend: EmbeddingBackendChoice,
357
358 #[arg(
361 long,
362 global = true,
363 value_name = "MODEL",
364 env = "SQLITE_GRAPHRAG_EMBEDDING_MODEL"
365 )]
366 pub embedding_model: Option<String>,
367
368 #[arg(
371 long,
372 global = true,
373 value_name = "KEY",
374 hide = true,
375 env = "OPENROUTER_API_KEY"
376 )]
377 pub openrouter_api_key: Option<String>,
378
379 #[command(subcommand)]
380 pub command: Option<Commands>,
381}
382
383#[cfg(test)]
384mod json_only_format_tests {
385 use super::Cli;
386 use clap::Parser;
387
388 #[test]
389 fn restore_accepts_only_format_json() {
390 assert!(Cli::try_parse_from([
391 "sqlite-graphrag",
392 "restore",
393 "--name",
394 "mem",
395 "--version",
396 "1",
397 "--format",
398 "json",
399 ])
400 .is_ok());
401
402 assert!(Cli::try_parse_from([
403 "sqlite-graphrag",
404 "restore",
405 "--name",
406 "mem",
407 "--version",
408 "1",
409 "--format",
410 "text",
411 ])
412 .is_err());
413 }
414
415 #[test]
416 fn hybrid_search_accepts_only_format_json() {
417 assert!(Cli::try_parse_from([
418 "sqlite-graphrag",
419 "hybrid-search",
420 "query",
421 "--format",
422 "json",
423 ])
424 .is_ok());
425
426 assert!(Cli::try_parse_from([
427 "sqlite-graphrag",
428 "hybrid-search",
429 "query",
430 "--format",
431 "markdown",
432 ])
433 .is_err());
434 }
435
436 #[test]
437 fn remember_recall_rename_vacuum_json_only() {
438 assert!(Cli::try_parse_from([
439 "sqlite-graphrag",
440 "remember",
441 "--name",
442 "mem",
443 "--type",
444 "project",
445 "--description",
446 "desc",
447 "--format",
448 "json",
449 ])
450 .is_ok());
451 assert!(Cli::try_parse_from([
452 "sqlite-graphrag",
453 "remember",
454 "--name",
455 "mem",
456 "--type",
457 "project",
458 "--description",
459 "desc",
460 "--format",
461 "text",
462 ])
463 .is_err());
464
465 assert!(
466 Cli::try_parse_from(["sqlite-graphrag", "recall", "query", "--format", "json",])
467 .is_ok()
468 );
469 assert!(
470 Cli::try_parse_from(["sqlite-graphrag", "recall", "query", "--format", "text",])
471 .is_err()
472 );
473
474 assert!(Cli::try_parse_from([
475 "sqlite-graphrag",
476 "rename",
477 "--name",
478 "old",
479 "--new-name",
480 "new",
481 "--format",
482 "json",
483 ])
484 .is_ok());
485 assert!(Cli::try_parse_from([
486 "sqlite-graphrag",
487 "rename",
488 "--name",
489 "old",
490 "--new-name",
491 "new",
492 "--format",
493 "markdown",
494 ])
495 .is_err());
496
497 assert!(Cli::try_parse_from(["sqlite-graphrag", "vacuum", "--format", "json",]).is_ok());
498 assert!(Cli::try_parse_from(["sqlite-graphrag", "vacuum", "--format", "text",]).is_err());
499 }
500}
501
502impl Cli {
503 pub fn validate_flags(&self) -> Result<(), String> {
508 if let Some(n) = self.max_concurrency {
509 if n == 0 {
510 return Err(match current() {
511 Language::English => "--max-concurrency must be >= 1".to_string(),
512 Language::Portuguese => "--max-concurrency deve ser >= 1".to_string(),
513 });
514 }
515 let teto = max_concurrency_ceiling();
516 if n > teto {
517 return Err(match current() {
518 Language::English => format!(
519 "--max-concurrency {n} exceeds the ceiling of {teto} (2×nCPUs) on this system"
520 ),
521 Language::Portuguese => format!(
522 "--max-concurrency {n} excede o teto de {teto} (2×nCPUs) neste sistema"
523 ),
524 });
525 }
526 }
527 Ok(())
528 }
529}
530
531impl Commands {
532 pub fn is_embedding_heavy(&self) -> bool {
534 matches!(
535 self,
536 Self::Init(_)
537 | Self::Remember(_)
538 | Self::RememberBatch(_)
539 | Self::Recall(_)
540 | Self::HybridSearch(_)
541 | Self::DeepResearch(_)
542 )
543 }
544
545 pub fn uses_cli_slot(&self) -> bool {
546 true
547 }
548
549 pub fn tolerates_missing_embedding_key(&self) -> bool {
556 match self {
557 Self::Init(_) => true,
558 Self::Enrich(args) => {
559 args.status || args.list_dead || args.requeue_dead || args.prune_dead_orphans
560 }
561 _ => false,
562 }
563 }
564}
565
566#[derive(Debug, clap::Args)]
571pub struct CodexModelsArgs {
572 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
574 pub json: bool,
575}
576
577#[derive(Subcommand)]
578pub enum Commands {
579 #[command(after_long_help = "EXAMPLES:\n \
581 # Initialize in current directory (default behavior)\n \
582 sqlite-graphrag init\n\n \
583 # Initialize at a specific path\n \
584 sqlite-graphrag init --db /path/to/graphrag.sqlite\n\n \
585 # Initialize using SQLITE_GRAPHRAG_HOME env var\n \
586 SQLITE_GRAPHRAG_HOME=/data sqlite-graphrag init\n\n\
587 NOTES:\n \
588 - `init` is OPTIONAL: any subsequent CRUD command auto-initializes graphrag.sqlite if missing.\n \
589 - As a side effect, `init` warms a smoke-test embedding via the LLM-only one-shot pipeline.")]
590 Init(init::InitArgs),
591 #[command(after_long_help = "EXAMPLES:\n \
593 # Inline body\n \
594 sqlite-graphrag remember --name onboarding --type user --description \"intro\" --body \"hello\"\n\n \
595 # Body from file\n \
596 sqlite-graphrag remember --name doc1 --type document --description \"...\" --body-file ./README.md\n\n \
597 # Body from stdin (pipe)\n \
598 cat README.md | sqlite-graphrag remember --name doc1 --type document --description \"...\" --body-stdin\n\n \
599 # Enable automatic URL extraction (URL-regex only since v1.0.79; GLiNER removed)\n \
600 sqlite-graphrag remember --name rich --type note --description \"...\" --body \"...\" --enable-ner")]
601 Remember(remember::RememberArgs),
602 #[command(after_long_help = "EXAMPLES:\n \
604 # Batch create from NDJSON\n \
605 cat memories.ndjson | sqlite-graphrag remember-batch --force-merge --json\n\n \
606 # Atomic batch\n \
607 cat memories.ndjson | sqlite-graphrag remember-batch --transaction --json")]
608 RememberBatch(remember_batch::RememberBatchArgs),
609 Ingest(ingest::IngestArgs),
611 #[command(after_long_help = "EXAMPLES:\n \
613 # Top 10 semantic matches (default)\n \
614 sqlite-graphrag recall \"agent memory\"\n\n \
615 # Top 3 only\n \
616 sqlite-graphrag recall \"agent memory\" -k 3\n\n \
617 # Search across all namespaces\n \
618 sqlite-graphrag recall \"agent memory\" --all-namespaces\n\n \
619 # Disable graph traversal (vector-only)\n \
620 sqlite-graphrag recall \"agent memory\" --no-graph")]
621 Recall(recall::RecallArgs),
622 Read(read::ReadArgs),
624 List(list::ListArgs),
626 Forget(forget::ForgetArgs),
628 Purge(purge::PurgeArgs),
630 Rename(rename::RenameArgs),
632 Edit(edit::EditArgs),
634 History(history::HistoryArgs),
636 Restore(restore::RestoreArgs),
638 #[command(after_long_help = "EXAMPLES:\n \
640 # Hybrid search combining KNN + FTS5 BM25 with RRF\n \
641 sqlite-graphrag hybrid-search \"agent memory architecture\"\n\n \
642 # Custom weights for vector vs full-text components\n \
643 sqlite-graphrag hybrid-search \"agent\" --weight-vec 0.7 --weight-fts 0.3")]
644 HybridSearch(hybrid_search::HybridSearchArgs),
645 Health(health::HealthArgs),
647 Migrate(migrate::MigrateArgs),
649 NamespaceDetect(namespace_detect::NamespaceDetectArgs),
651 Optimize(optimize::OptimizeArgs),
653 Stats(stats::StatsArgs),
655 SyncSafeCopy(sync_safe_copy::SyncSafeCopyArgs),
657 Backup(backup::BackupArgs),
659 Vacuum(vacuum::VacuumArgs),
661 Link(link::LinkArgs),
663 Unlink(unlink::UnlinkArgs),
665 #[command(name = "deep-research")]
667 DeepResearch(deep_research::DeepResearchArgs),
668 Related(related::RelatedArgs),
670 Graph(graph_export::GraphArgs),
672 Export(export::ExportArgs),
674 Fts(fts::FtsArgs),
676 Vec(vec::VecArgs),
678 #[command(name = "codex-models")]
684 CodexModels(CodexModelsArgs),
685 PruneRelations(prune_relations::PruneRelationsArgs),
687 #[command(name = "prune-ner")]
689 PruneNer(prune_ner::PruneNerArgs),
690 Slots(slots::SlotsArgs),
692 Pending(pending::PendingArgs),
694 Embedding(embedding::EmbeddingArgs),
696 #[command(name = "pending-embeddings")]
698 PendingEmbeddings(pending_embeddings::PendingEmbeddingsArgs),
699 CleanupOrphans(cleanup_orphans::CleanupOrphansArgs),
701 MemoryEntities(memory_entities::MemoryEntitiesArgs),
703 Cache(cache::CacheArgs),
705 #[command(name = "delete-entity")]
707 DeleteEntity(delete_entity::DeleteEntityArgs),
708 Reclassify(reclassify::ReclassifyArgs),
710 #[command(name = "rename-entity")]
712 RenameEntity(rename_entity::RenameEntityArgs),
713 #[command(name = "merge-entities")]
715 MergeEntities(merge_entities::MergeEntitiesArgs),
716 Enrich(enrich::EnrichArgs),
718 #[command(name = "reclassify-relation")]
720 ReclassifyRelation(reclassify_relation::ReclassifyRelationArgs),
721 #[command(name = "normalize-entities")]
723 NormalizeEntities(normalize_entities::NormalizeEntitiesArgs),
724 Completions(completions::CompletionsArgs),
726 #[command(name = "debug-schema", hide = true)]
727 DebugSchema(debug_schema::DebugSchemaArgs),
728 Config(config_cmd::ConfigArgs),
730}
731impl std::fmt::Debug for Commands {
737 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
738 let name = match self {
739 Self::Init(_) => "Init",
740 Self::Health(_) => "Health",
741 Self::Stats(_) => "Stats",
742 Self::List(_) => "List",
743 Self::Read(_) => "Read",
744 Self::Edit(_) => "Edit",
745 Self::Rename(_) => "Rename",
746 Self::Restore(_) => "Restore",
747 Self::History(_) => "History",
748 Self::Forget(_) => "Forget",
749 Self::Purge(_) => "Purge",
750 Self::Remember(_) => "Remember",
751 Self::RememberBatch(_) => "RememberBatch",
752 Self::Recall(_) => "Recall",
753 Self::HybridSearch(_) => "HybridSearch",
754 Self::Enrich(_) => "Enrich",
755 Self::Ingest(_) => "Ingest",
756 Self::Optimize(_) => "Optimize",
757 Self::Migrate(_) => "Migrate",
758 Self::SyncSafeCopy(_) => "SyncSafeCopy",
759 Self::Backup(_) => "Backup",
760 Self::Vacuum(_) => "Vacuum",
761 Self::Link(_) => "Link",
762 Self::Unlink(_) => "Unlink",
763 Self::DeepResearch(_) => "DeepResearch",
764 Self::Related(_) => "Related",
765 Self::Graph(_) => "Graph",
766 Self::Export(_) => "Export",
767 Self::Fts(_) => "Fts",
768 Self::Vec(_) => "Vec",
769 Self::CodexModels(_) => "CodexModels",
770 Self::PruneRelations(_) => "PruneRelations",
771 Self::PruneNer(_) => "PruneNer",
772 Self::Slots(_) => "Slots",
773 Self::Pending(_) => "Pending",
774 Self::Embedding(_) => "Embedding",
775 Self::PendingEmbeddings(_) => "PendingEmbeddings",
776 Self::CleanupOrphans(_) => "CleanupOrphans",
777 Self::MemoryEntities(_) => "MemoryEntities",
778 Self::Cache(_) => "Cache",
779 Self::DeleteEntity(_) => "DeleteEntity",
780 Self::Reclassify(_) => "Reclassify",
781 Self::RenameEntity(_) => "RenameEntity",
782 Self::ReclassifyRelation(_) => "ReclassifyRelation",
783 Self::NormalizeEntities(_) => "NormalizeEntities",
784 Self::MergeEntities(_) => "MergeEntities",
785 Self::NamespaceDetect(_) => "NamespaceDetect",
786 Self::Completions(_) => "Completions",
787 Self::DebugSchema(_) => "DebugSchema",
788 Self::Config(_) => "Config",
789 };
790 f.write_str(name)
791 }
792}
793
794#[derive(Copy, Clone, Debug, Default, clap::ValueEnum)]
795pub enum MemoryType {
796 User,
797 Feedback,
798 Project,
799 Reference,
800 Decision,
801 Incident,
802 Skill,
803 #[default]
804 Document,
805 Note,
806}
807
808#[cfg(test)]
809mod heavy_concurrency_tests {
810 use super::*;
811
812 #[test]
813 fn command_heavy_detects_init_and_embeddings() {
814 let init = Cli::try_parse_from(["sqlite-graphrag", "init"]).expect("parse init");
815 assert!(init
816 .command
817 .as_ref()
818 .is_some_and(|c| c.is_embedding_heavy()));
819
820 let remember = Cli::try_parse_from([
821 "sqlite-graphrag",
822 "remember",
823 "--name",
824 "test-memory",
825 "--type",
826 "project",
827 "--description",
828 "desc",
829 ])
830 .expect("parse remember");
831 assert!(remember
832 .command
833 .as_ref()
834 .is_some_and(|c| c.is_embedding_heavy()));
835
836 let recall =
837 Cli::try_parse_from(["sqlite-graphrag", "recall", "query"]).expect("parse recall");
838 assert!(recall
839 .command
840 .as_ref()
841 .is_some_and(|c| c.is_embedding_heavy()));
842
843 let hybrid = Cli::try_parse_from(["sqlite-graphrag", "hybrid-search", "query"])
844 .expect("parse hybrid");
845 assert!(hybrid
846 .command
847 .as_ref()
848 .is_some_and(|c| c.is_embedding_heavy()));
849 }
850
851 #[test]
852 fn command_light_does_not_mark_stats() {
853 let stats = Cli::try_parse_from(["sqlite-graphrag", "stats"]).expect("parse stats");
854 assert!(!stats
855 .command
856 .as_ref()
857 .is_some_and(|c| c.is_embedding_heavy()));
858 }
859}
860
861impl MemoryType {
862 pub fn as_str(&self) -> &'static str {
863 match self {
864 Self::User => "user",
865 Self::Feedback => "feedback",
866 Self::Project => "project",
867 Self::Reference => "reference",
868 Self::Decision => "decision",
869 Self::Incident => "incident",
870 Self::Skill => "skill",
871 Self::Document => "document",
872 Self::Note => "note",
873 }
874 }
875}
876
877#[cfg(test)]
879mod fase_g_parsing_tests {
880 use super::Cli;
881 use clap::Parser;
882
883 #[test]
885 fn enrich_status_optional_operation_and_mode() {
886 assert!(
887 Cli::try_parse_from(["sqlite-graphrag", "enrich", "--status"]).is_ok(),
888 "--status alone must not require --operation/--mode"
889 );
890 assert!(
891 Cli::try_parse_from(["sqlite-graphrag", "enrich", "--list-dead"]).is_ok(),
892 "--list-dead is read-only and must not require --operation/--mode"
893 );
894 assert!(
896 Cli::try_parse_from(["sqlite-graphrag", "enrich"]).is_err(),
897 "bare enrich (no status/list-dead/requeue-dead) must require --operation/--mode"
898 );
899 assert!(Cli::try_parse_from([
901 "sqlite-graphrag",
902 "enrich",
903 "--operation",
904 "memory-bindings",
905 "--mode",
906 "openrouter",
907 ])
908 .is_ok());
909 }
910
911 #[test]
913 fn config_doctor_accepts_json() {
914 assert!(Cli::try_parse_from(["sqlite-graphrag", "config", "doctor", "--json"]).is_ok());
915 assert!(Cli::try_parse_from(["sqlite-graphrag", "config", "list-keys", "--json"]).is_ok());
916 }
917
918 #[test]
921 fn remember_description_allows_leading_hyphen() {
922 assert!(Cli::try_parse_from([
923 "sqlite-graphrag",
924 "remember",
925 "--name",
926 "mem",
927 "--type",
928 "note",
929 "--description",
930 "- bullet description",
931 ])
932 .is_ok());
933 }
934
935 #[test]
937 fn remember_batch_accepts_llm_parallelism() {
938 assert!(Cli::try_parse_from([
939 "sqlite-graphrag",
940 "remember-batch",
941 "--llm-parallelism",
942 "4"
943 ])
944 .is_ok());
945 }
946
947 #[test]
950 fn remember_graph_file_combines_with_body_but_conflicts_with_graph_stdin() {
951 assert!(
952 Cli::try_parse_from([
953 "sqlite-graphrag",
954 "remember",
955 "--name",
956 "mem",
957 "--type",
958 "note",
959 "--body",
960 "inline body",
961 "--graph-file",
962 "/tmp/graph.json",
963 ])
964 .is_ok(),
965 "--body + --graph-file must coexist"
966 );
967 assert!(
968 Cli::try_parse_from([
969 "sqlite-graphrag",
970 "remember",
971 "--name",
972 "mem",
973 "--type",
974 "note",
975 "--graph-file",
976 "/tmp/graph.json",
977 "--graph-stdin",
978 ])
979 .is_err(),
980 "--graph-file conflicts with --graph-stdin"
981 );
982 }
983}