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
560 || args.list_dead
561 || args.requeue_dead
562 || args.prune_dead_orphans
563 || args.prune_dead_entity_orphans
564 }
565 _ => false,
566 }
567 }
568}
569
570#[derive(Debug, clap::Args)]
575pub struct CodexModelsArgs {
576 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
578 pub json: bool,
579}
580
581#[derive(Subcommand)]
582pub enum Commands {
583 #[command(after_long_help = "EXAMPLES:\n \
585 # Initialize in current directory (default behavior)\n \
586 sqlite-graphrag init\n\n \
587 # Initialize at a specific path\n \
588 sqlite-graphrag init --db /path/to/graphrag.sqlite\n\n \
589 # Initialize using SQLITE_GRAPHRAG_HOME env var\n \
590 SQLITE_GRAPHRAG_HOME=/data sqlite-graphrag init\n\n\
591 NOTES:\n \
592 - `init` is OPTIONAL: any subsequent CRUD command auto-initializes graphrag.sqlite if missing.\n \
593 - As a side effect, `init` warms a smoke-test embedding via the LLM-only one-shot pipeline.")]
594 Init(init::InitArgs),
595 #[command(after_long_help = "EXAMPLES:\n \
597 # Inline body\n \
598 sqlite-graphrag remember --name onboarding --type user --description \"intro\" --body \"hello\"\n\n \
599 # Body from file\n \
600 sqlite-graphrag remember --name doc1 --type document --description \"...\" --body-file ./README.md\n\n \
601 # Body from stdin (pipe)\n \
602 cat README.md | sqlite-graphrag remember --name doc1 --type document --description \"...\" --body-stdin\n\n \
603 # Enable automatic URL extraction (URL-regex only since v1.0.79)\n \
604 sqlite-graphrag remember --name rich --type note --description \"...\" --body \"...\" --enable-ner")]
605 Remember(remember::RememberArgs),
606 #[command(after_long_help = "EXAMPLES:\n \
608 # Batch create from NDJSON\n \
609 cat memories.ndjson | sqlite-graphrag remember-batch --force-merge --json\n\n \
610 # Atomic batch\n \
611 cat memories.ndjson | sqlite-graphrag remember-batch --transaction --json")]
612 RememberBatch(remember_batch::RememberBatchArgs),
613 Ingest(ingest::IngestArgs),
615 #[command(after_long_help = "EXAMPLES:\n \
617 # Top 10 semantic matches (default)\n \
618 sqlite-graphrag recall \"agent memory\"\n\n \
619 # Top 3 only\n \
620 sqlite-graphrag recall \"agent memory\" -k 3\n\n \
621 # Search across all namespaces\n \
622 sqlite-graphrag recall \"agent memory\" --all-namespaces\n\n \
623 # Disable graph traversal (vector-only)\n \
624 sqlite-graphrag recall \"agent memory\" --no-graph")]
625 Recall(recall::RecallArgs),
626 Read(read::ReadArgs),
628 List(list::ListArgs),
630 Forget(forget::ForgetArgs),
632 Purge(purge::PurgeArgs),
634 Rename(rename::RenameArgs),
636 Edit(edit::EditArgs),
638 History(history::HistoryArgs),
640 Restore(restore::RestoreArgs),
642 #[command(after_long_help = "EXAMPLES:\n \
644 # Hybrid search combining KNN + FTS5 BM25 with RRF\n \
645 sqlite-graphrag hybrid-search \"agent memory architecture\"\n\n \
646 # Custom weights for vector vs full-text components\n \
647 sqlite-graphrag hybrid-search \"agent\" --weight-vec 0.7 --weight-fts 0.3")]
648 HybridSearch(hybrid_search::HybridSearchArgs),
649 Health(health::HealthArgs),
651 Migrate(migrate::MigrateArgs),
653 NamespaceDetect(namespace_detect::NamespaceDetectArgs),
655 Optimize(optimize::OptimizeArgs),
657 Stats(stats::StatsArgs),
659 SyncSafeCopy(sync_safe_copy::SyncSafeCopyArgs),
661 Backup(backup::BackupArgs),
663 Vacuum(vacuum::VacuumArgs),
665 Link(link::LinkArgs),
667 Unlink(unlink::UnlinkArgs),
669 #[command(name = "deep-research")]
671 DeepResearch(deep_research::DeepResearchArgs),
672 Related(related::RelatedArgs),
674 Graph(graph_export::GraphArgs),
676 Export(export::ExportArgs),
678 Fts(fts::FtsArgs),
680 Vec(vec::VecArgs),
682 #[command(name = "codex-models")]
688 CodexModels(CodexModelsArgs),
689 PruneRelations(prune_relations::PruneRelationsArgs),
691 #[command(name = "prune-ner")]
693 PruneNer(prune_ner::PruneNerArgs),
694 Slots(slots::SlotsArgs),
696 Pending(pending::PendingArgs),
698 Embedding(embedding::EmbeddingArgs),
700 #[command(name = "pending-embeddings")]
702 PendingEmbeddings(pending_embeddings::PendingEmbeddingsArgs),
703 CleanupOrphans(cleanup_orphans::CleanupOrphansArgs),
705 MemoryEntities(memory_entities::MemoryEntitiesArgs),
707 Cache(cache::CacheArgs),
709 #[command(name = "delete-entity")]
711 DeleteEntity(delete_entity::DeleteEntityArgs),
712 Reclassify(reclassify::ReclassifyArgs),
714 #[command(name = "rename-entity")]
716 RenameEntity(rename_entity::RenameEntityArgs),
717 #[command(name = "merge-entities")]
719 MergeEntities(merge_entities::MergeEntitiesArgs),
720 Enrich(enrich::EnrichArgs),
722 #[command(name = "reclassify-relation")]
724 ReclassifyRelation(reclassify_relation::ReclassifyRelationArgs),
725 #[command(name = "normalize-entities")]
727 NormalizeEntities(normalize_entities::NormalizeEntitiesArgs),
728 Completions(completions::CompletionsArgs),
730 #[command(name = "debug-schema", hide = true)]
731 DebugSchema(debug_schema::DebugSchemaArgs),
732 Config(config_cmd::ConfigArgs),
734}
735impl std::fmt::Debug for Commands {
741 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
742 let name = match self {
743 Self::Init(_) => "Init",
744 Self::Health(_) => "Health",
745 Self::Stats(_) => "Stats",
746 Self::List(_) => "List",
747 Self::Read(_) => "Read",
748 Self::Edit(_) => "Edit",
749 Self::Rename(_) => "Rename",
750 Self::Restore(_) => "Restore",
751 Self::History(_) => "History",
752 Self::Forget(_) => "Forget",
753 Self::Purge(_) => "Purge",
754 Self::Remember(_) => "Remember",
755 Self::RememberBatch(_) => "RememberBatch",
756 Self::Recall(_) => "Recall",
757 Self::HybridSearch(_) => "HybridSearch",
758 Self::Enrich(_) => "Enrich",
759 Self::Ingest(_) => "Ingest",
760 Self::Optimize(_) => "Optimize",
761 Self::Migrate(_) => "Migrate",
762 Self::SyncSafeCopy(_) => "SyncSafeCopy",
763 Self::Backup(_) => "Backup",
764 Self::Vacuum(_) => "Vacuum",
765 Self::Link(_) => "Link",
766 Self::Unlink(_) => "Unlink",
767 Self::DeepResearch(_) => "DeepResearch",
768 Self::Related(_) => "Related",
769 Self::Graph(_) => "Graph",
770 Self::Export(_) => "Export",
771 Self::Fts(_) => "Fts",
772 Self::Vec(_) => "Vec",
773 Self::CodexModels(_) => "CodexModels",
774 Self::PruneRelations(_) => "PruneRelations",
775 Self::PruneNer(_) => "PruneNer",
776 Self::Slots(_) => "Slots",
777 Self::Pending(_) => "Pending",
778 Self::Embedding(_) => "Embedding",
779 Self::PendingEmbeddings(_) => "PendingEmbeddings",
780 Self::CleanupOrphans(_) => "CleanupOrphans",
781 Self::MemoryEntities(_) => "MemoryEntities",
782 Self::Cache(_) => "Cache",
783 Self::DeleteEntity(_) => "DeleteEntity",
784 Self::Reclassify(_) => "Reclassify",
785 Self::RenameEntity(_) => "RenameEntity",
786 Self::ReclassifyRelation(_) => "ReclassifyRelation",
787 Self::NormalizeEntities(_) => "NormalizeEntities",
788 Self::MergeEntities(_) => "MergeEntities",
789 Self::NamespaceDetect(_) => "NamespaceDetect",
790 Self::Completions(_) => "Completions",
791 Self::DebugSchema(_) => "DebugSchema",
792 Self::Config(_) => "Config",
793 };
794 f.write_str(name)
795 }
796}
797
798#[derive(Copy, Clone, Debug, Default, clap::ValueEnum)]
799pub enum MemoryType {
800 User,
801 Feedback,
802 Project,
803 Reference,
804 Decision,
805 Incident,
806 Skill,
807 #[default]
808 Document,
809 Note,
810}
811
812#[cfg(test)]
813mod heavy_concurrency_tests {
814 use super::*;
815
816 #[test]
817 fn command_heavy_detects_init_and_embeddings() {
818 let init = Cli::try_parse_from(["sqlite-graphrag", "init"]).expect("parse init");
819 assert!(init
820 .command
821 .as_ref()
822 .is_some_and(|c| c.is_embedding_heavy()));
823
824 let remember = Cli::try_parse_from([
825 "sqlite-graphrag",
826 "remember",
827 "--name",
828 "test-memory",
829 "--type",
830 "project",
831 "--description",
832 "desc",
833 ])
834 .expect("parse remember");
835 assert!(remember
836 .command
837 .as_ref()
838 .is_some_and(|c| c.is_embedding_heavy()));
839
840 let recall =
841 Cli::try_parse_from(["sqlite-graphrag", "recall", "query"]).expect("parse recall");
842 assert!(recall
843 .command
844 .as_ref()
845 .is_some_and(|c| c.is_embedding_heavy()));
846
847 let hybrid = Cli::try_parse_from(["sqlite-graphrag", "hybrid-search", "query"])
848 .expect("parse hybrid");
849 assert!(hybrid
850 .command
851 .as_ref()
852 .is_some_and(|c| c.is_embedding_heavy()));
853 }
854
855 #[test]
856 fn command_light_does_not_mark_stats() {
857 let stats = Cli::try_parse_from(["sqlite-graphrag", "stats"]).expect("parse stats");
858 assert!(!stats
859 .command
860 .as_ref()
861 .is_some_and(|c| c.is_embedding_heavy()));
862 }
863}
864
865impl MemoryType {
866 pub fn as_str(&self) -> &'static str {
867 match self {
868 Self::User => "user",
869 Self::Feedback => "feedback",
870 Self::Project => "project",
871 Self::Reference => "reference",
872 Self::Decision => "decision",
873 Self::Incident => "incident",
874 Self::Skill => "skill",
875 Self::Document => "document",
876 Self::Note => "note",
877 }
878 }
879}
880
881#[cfg(test)]
883mod fase_g_parsing_tests {
884 use super::Cli;
885 use clap::Parser;
886
887 #[test]
889 fn enrich_status_optional_operation_and_mode() {
890 assert!(
891 Cli::try_parse_from(["sqlite-graphrag", "enrich", "--status"]).is_ok(),
892 "--status alone must not require --operation/--mode"
893 );
894 assert!(
895 Cli::try_parse_from(["sqlite-graphrag", "enrich", "--list-dead"]).is_ok(),
896 "--list-dead is read-only and must not require --operation/--mode"
897 );
898 assert!(
900 Cli::try_parse_from(["sqlite-graphrag", "enrich"]).is_err(),
901 "bare enrich (no status/list-dead/requeue-dead) must require --operation/--mode"
902 );
903 assert!(Cli::try_parse_from([
905 "sqlite-graphrag",
906 "enrich",
907 "--operation",
908 "memory-bindings",
909 "--mode",
910 "openrouter",
911 ])
912 .is_ok());
913 }
914
915 #[test]
917 fn config_doctor_accepts_json() {
918 assert!(Cli::try_parse_from(["sqlite-graphrag", "config", "doctor", "--json"]).is_ok());
919 assert!(Cli::try_parse_from(["sqlite-graphrag", "config", "list-keys", "--json"]).is_ok());
920 }
921
922 #[test]
925 fn remember_description_allows_leading_hyphen() {
926 assert!(Cli::try_parse_from([
927 "sqlite-graphrag",
928 "remember",
929 "--name",
930 "mem",
931 "--type",
932 "note",
933 "--description",
934 "- bullet description",
935 ])
936 .is_ok());
937 }
938
939 #[test]
941 fn remember_batch_accepts_llm_parallelism() {
942 assert!(Cli::try_parse_from([
943 "sqlite-graphrag",
944 "remember-batch",
945 "--llm-parallelism",
946 "4"
947 ])
948 .is_ok());
949 }
950
951 #[test]
954 fn remember_graph_file_combines_with_body_but_conflicts_with_graph_stdin() {
955 assert!(
956 Cli::try_parse_from([
957 "sqlite-graphrag",
958 "remember",
959 "--name",
960 "mem",
961 "--type",
962 "note",
963 "--body",
964 "inline body",
965 "--graph-file",
966 "/tmp/graph.json",
967 ])
968 .is_ok(),
969 "--body + --graph-file must coexist"
970 );
971 assert!(
972 Cli::try_parse_from([
973 "sqlite-graphrag",
974 "remember",
975 "--name",
976 "mem",
977 "--type",
978 "note",
979 "--graph-file",
980 "/tmp/graph.json",
981 "--graph-stdin",
982 ])
983 .is_err(),
984 "--graph-file conflicts with --graph-stdin"
985 );
986 }
987}