Skip to main content

sqlite_graphrag/
cli.rs

1//! CLI argument structs and command surface (clap-based).
2//!
3//! Defines `Cli` and all subcommand enums; contains no business logic.
4
5use crate::commands::*;
6use crate::i18n::{current, Language};
7use clap::{Parser, Subcommand};
8
9/// Returns the maximum simultaneous invocations allowed by the CPU heuristic.
10fn 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    /// Stream one JSON object per entity, then one per edge, then a summary line.
22    Ndjson,
23}
24
25/// v1.0.82 (GAP-003): backend LLM para embedding. Aceita `auto` (default —
26/// detecta `codex` ou `claude` no PATH), `codex` (força codex exec), `claude`
27/// (força claude -p), ou `none` (skip-a embedding; útil para testes).
28#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
29pub enum LlmBackendChoice {
30    Auto,
31    Claude,
32    Codex,
33    None,
34}
35
36impl LlmBackendChoice {
37    /// v1.0.82 (GAP-003): converte a escolha do CLI em uma chain ordenada
38    /// de backends que `embedder::embed_with_fallback` itera. O primeiro
39    /// elemento da chain é o backend preferido; elementos subsequentes
40    /// são fallbacks quando o preferido falha com `LlmBackendError`.
41    ///
42    /// `Auto` produz `[Codex, Claude, None]` — codex é o default da v1.0.76+,
43    /// claude é o fallback se codex falhar (OAuth contention, quota), e
44    /// `None` permite `embed_with_fallback` retornar vetor vazio quando
45    /// `skip_on_failure` está ativo.
46    pub fn to_chain(self) -> Vec<crate::embedder::LlmBackendKind> {
47        use crate::embedder::LlmBackendKind;
48        match self {
49            LlmBackendChoice::Codex => vec![LlmBackendKind::Codex, LlmBackendKind::None],
50            LlmBackendChoice::Claude => vec![LlmBackendKind::Claude, LlmBackendKind::None],
51            LlmBackendChoice::None => vec![LlmBackendKind::None],
52            LlmBackendChoice::Auto => vec![
53                LlmBackendKind::Codex,
54                LlmBackendKind::Claude,
55                LlmBackendKind::None,
56            ],
57        }
58    }
59}
60
61#[derive(Parser)]
62#[command(name = "sqlite-graphrag")]
63#[command(version)]
64#[command(about = "Local GraphRAG memory for LLMs in a single SQLite file")]
65#[command(arg_required_else_help = true)]
66pub struct Cli {
67    /// Maximum number of simultaneous CLI invocations allowed (default: 4).
68    ///
69    /// Caps the counting semaphore used for CLI concurrency slots. The value must
70    /// stay within [1, 2×nCPUs]. Values above the ceiling are rejected with exit 2.
71    #[arg(long, global = true, value_name = "N")]
72    pub max_concurrency: Option<usize>,
73
74    /// Wait up to SECONDS for a free concurrency slot before giving up (exit 75).
75    ///
76    /// Useful in retrying agent pipelines: the process polls every 500 ms until a
77    /// slot opens or the timeout expires. Default: 300s (5 minutes).
78    #[arg(long, global = true, value_name = "SECONDS")]
79    pub wait_lock: Option<u64>,
80
81    /// Skip the available-memory check before loading the model.
82    ///
83    /// Exclusive use in automated tests where real allocation does not occur.
84    #[arg(long, global = true, hide = true, default_value_t = false)]
85    pub skip_memory_guard: bool,
86
87    /// v1.0.83 (ADR-0041): strict env-clear mode for compliance environments.
88    ///
89    /// When enabled, the LLM subprocess receives ONLY `PATH` — no
90    /// `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_BASE_URL`, `OPENAI_BASE_URL`
91    /// or other custom-provider credentials are forwarded. Defaults to
92    /// the standard v1.0.83 whitelist that preserves custom-provider
93    /// credentials (ADR-0041). Honors env var
94    /// `SQLITE_GRAPHRAG_STRICT_ENV_CLEAR=1` when set.
95    #[arg(
96        long,
97        global = true,
98        hide = true,
99        default_value_t = false,
100        env = "SQLITE_GRAPHRAG_STRICT_ENV_CLEAR"
101    )]
102    pub strict_env_clear: bool,
103
104    /// v1.0.84 (ADR-0042 / GAP-002): resolve and print the LLM backend that
105    /// WOULD be invoked for embedding (binary path + model + flavour),
106    /// then exit 0 without executing the subprocess. Useful for CI
107    /// audit and sanity-check of `--llm-backend` before long sessions.
108    ///
109    /// Honors env var `SQLITE_GRAPHRAG_DRY_RUN_BACKEND=1` when set.
110    #[arg(
111        long,
112        global = true,
113        hide = true,
114        default_value_t = false,
115        env = "SQLITE_GRAPHRAG_DRY_RUN_BACKEND"
116    )]
117    pub dry_run_backend: bool,
118
119    /// Language for human-facing stderr messages. Accepts `en` or `pt`.
120    ///
121    /// Without the flag, detection falls back to `SQLITE_GRAPHRAG_LANG` and then
122    /// `LC_ALL`/`LANG`. JSON stdout stays deterministic and identical across
123    /// languages; only human-facing strings are affected.
124    #[arg(long, global = true, value_enum, value_name = "LANG")]
125    pub lang: Option<crate::i18n::Language>,
126
127    /// Time zone for `*_iso` fields in JSON output (for example `America/Sao_Paulo`).
128    ///
129    /// Accepts any IANA time zone name. Without the flag, it falls back to
130    /// `SQLITE_GRAPHRAG_DISPLAY_TZ`; if unset, UTC is used. Integer epoch fields
131    /// are not affected.
132    #[arg(long, global = true, value_name = "IANA")]
133    pub tz: Option<chrono_tz::Tz>,
134
135    /// Increase logging verbosity (-v=info, -vv=debug, -vvv=trace).
136    ///
137    /// Overrides `SQLITE_GRAPHRAG_LOG_LEVEL` env var when present. Logs are emitted
138    /// to stderr; JSON stdout is unaffected.
139    #[arg(short = 'v', long, global = true, action = clap::ArgAction::Count)]
140    pub verbose: u8,
141
142    /// v1.0.75 (G21 solution): extraction backend selector. Accepts
143    /// `llm` (default), `embedding` (legacy), `none`, or `both` (composite).
144    /// The `llm` backend invokes claude code / codex CLI headless to extract
145    /// entities and relationships; `embedding` is a permanent stub since
146    /// v1.0.79 (legacy fastembed pipeline removed) that returns a clear
147    /// migration error.
148    #[arg(long, global = true, value_name = "KIND", default_value = "llm")]
149    pub extraction_backend: Option<String>,
150
151    /// v1.0.79 (G42/S1): embedding dimensionality override (default 64).
152    ///
153    /// Precedence: this flag > `SQLITE_GRAPHRAG_EMBEDDING_DIM` env var >
154    /// the `dim` recorded in the database `schema_meta` > 64. Existing
155    /// databases keep their recorded dimensionality automatically; use
156    /// this flag only to migrate a corpus to a new dimensionality
157    /// (followed by `enrich --operation re-embed`). Range: [8, 4096].
158    #[arg(long, global = true, value_name = "N", value_parser = clap::value_parser!(u64).range(8..=4096))]
159    pub embedding_dim: Option<u64>,
160
161    /// v1.0.82 (GAP-003) / v1.0.84 (ADR-0042): backend LLM para embedding.
162    /// Aceita `auto` (detecta via PATH, codex-first), `codex` (força
163    /// `codex exec`), `claude` (força `claude -p`; desde v1.0.84 NÃO cai em
164    /// codex — emite `AppError::Validation` se `claude` ausente), ou `none`
165    /// (skip-a embedding; útil para testes). Honra env var
166    /// `SQLITE_GRAPHRAG_LLM_BACKEND`.
167    #[arg(long, global = true, value_enum, default_value_t = LlmBackendChoice::Auto, env = "SQLITE_GRAPHRAG_LLM_BACKEND")]
168    pub llm_backend: LlmBackendChoice,
169
170    /// v1.0.82 (GAP-003): modelo a invocar no backend escolhido.
171    /// Honra env var `SQLITE_GRAPHRAG_LLM_MODEL`. Default depende
172    /// do backend (codex: `gpt-5.5`; claude: `claude-sonnet-4-6`).
173    #[arg(
174        long,
175        global = true,
176        value_name = "MODEL",
177        env = "SQLITE_GRAPHRAG_LLM_MODEL"
178    )]
179    pub llm_model: Option<String>,
180
181    /// v1.0.82 (GAP-003): path para o binário `claude` (override de
182    /// detecção via PATH). Honra env var `SQLITE_GRAPHRAG_CLAUDE_BINARY`.
183    #[arg(
184        long,
185        global = true,
186        value_name = "PATH",
187        env = "SQLITE_GRAPHRAG_CLAUDE_BINARY"
188    )]
189    pub claude_binary: Option<std::path::PathBuf>,
190
191    /// v1.0.82 (GAP-005): cadeia de backends LLM tentados em ordem
192    /// quando o primário falha. Default `codex,claude,none`. Honra
193    /// env var `SQLITE_GRAPHRAG_LLM_FALLBACK`.
194    #[arg(
195        long,
196        global = true,
197        default_value = "codex,claude,none",
198        env = "SQLITE_GRAPHRAG_LLM_FALLBACK"
199    )]
200    pub llm_fallback: String,
201
202    /// v1.0.82 (GAP-005): persiste com embedding NULL quando todos
203    /// os backends da cadeia falham. Memória fica em `pending_embeddings`
204    /// para reprocessamento via `embedding retry`. Honra env var
205    /// `SQLITE_GRAPHRAG_SKIP_EMBEDDING_ON_FAILURE`.
206    #[arg(
207        long,
208        global = true,
209        default_value_t = false,
210        env = "SQLITE_GRAPHRAG_SKIP_EMBEDDING_ON_FAILURE"
211    )]
212    pub skip_embedding_on_failure: bool,
213
214    /// v1.0.82 (GAP-004): limite host-wide de subprocessos LLM
215    /// simultâneos. Default derivado de `ncpus`. Honra env var
216    /// `SQLITE_GRAPHRAG_LLM_MAX_HOST_CONCURRENCY`.
217    #[arg(
218        long,
219        global = true,
220        value_name = "N",
221        env = "SQLITE_GRAPHRAG_LLM_MAX_HOST_CONCURRENCY"
222    )]
223    pub llm_max_host_concurrency: Option<u32>,
224
225    /// v1.0.82 (GAP-004): segundos para aguardar slot LLM livre
226    /// antes de falhar com exit 75. Default 30s. Honra env var
227    /// `SQLITE_GRAPHRAG_LLM_SLOT_WAIT_SECS`.
228    #[arg(
229        long,
230        global = true,
231        value_name = "SECONDS",
232        env = "SQLITE_GRAPHRAG_LLM_SLOT_WAIT_SECS"
233    )]
234    pub llm_slot_wait_secs: Option<u64>,
235
236    /// v1.0.82 (GAP-004): se setado, falha imediatamente (exit 75)
237    /// quando nenhum slot LLM está livre. Honra env var
238    /// `SQLITE_GRAPHRAG_LLM_SLOT_NO_WAIT`.
239    #[arg(
240        long,
241        global = true,
242        default_value_t = false,
243        env = "SQLITE_GRAPHRAG_LLM_SLOT_NO_WAIT"
244    )]
245    pub llm_slot_no_wait: bool,
246
247    #[command(subcommand)]
248    pub command: Option<Commands>,
249}
250
251#[cfg(test)]
252mod json_only_format_tests {
253    use super::Cli;
254    use clap::Parser;
255
256    #[test]
257    fn restore_accepts_only_format_json() {
258        assert!(Cli::try_parse_from([
259            "sqlite-graphrag",
260            "restore",
261            "--name",
262            "mem",
263            "--version",
264            "1",
265            "--format",
266            "json",
267        ])
268        .is_ok());
269
270        assert!(Cli::try_parse_from([
271            "sqlite-graphrag",
272            "restore",
273            "--name",
274            "mem",
275            "--version",
276            "1",
277            "--format",
278            "text",
279        ])
280        .is_err());
281    }
282
283    #[test]
284    fn hybrid_search_accepts_only_format_json() {
285        assert!(Cli::try_parse_from([
286            "sqlite-graphrag",
287            "hybrid-search",
288            "query",
289            "--format",
290            "json",
291        ])
292        .is_ok());
293
294        assert!(Cli::try_parse_from([
295            "sqlite-graphrag",
296            "hybrid-search",
297            "query",
298            "--format",
299            "markdown",
300        ])
301        .is_err());
302    }
303
304    #[test]
305    fn remember_recall_rename_vacuum_json_only() {
306        assert!(Cli::try_parse_from([
307            "sqlite-graphrag",
308            "remember",
309            "--name",
310            "mem",
311            "--type",
312            "project",
313            "--description",
314            "desc",
315            "--format",
316            "json",
317        ])
318        .is_ok());
319        assert!(Cli::try_parse_from([
320            "sqlite-graphrag",
321            "remember",
322            "--name",
323            "mem",
324            "--type",
325            "project",
326            "--description",
327            "desc",
328            "--format",
329            "text",
330        ])
331        .is_err());
332
333        assert!(
334            Cli::try_parse_from(["sqlite-graphrag", "recall", "query", "--format", "json",])
335                .is_ok()
336        );
337        assert!(
338            Cli::try_parse_from(["sqlite-graphrag", "recall", "query", "--format", "text",])
339                .is_err()
340        );
341
342        assert!(Cli::try_parse_from([
343            "sqlite-graphrag",
344            "rename",
345            "--name",
346            "old",
347            "--new-name",
348            "new",
349            "--format",
350            "json",
351        ])
352        .is_ok());
353        assert!(Cli::try_parse_from([
354            "sqlite-graphrag",
355            "rename",
356            "--name",
357            "old",
358            "--new-name",
359            "new",
360            "--format",
361            "markdown",
362        ])
363        .is_err());
364
365        assert!(Cli::try_parse_from(["sqlite-graphrag", "vacuum", "--format", "json",]).is_ok());
366        assert!(Cli::try_parse_from(["sqlite-graphrag", "vacuum", "--format", "text",]).is_err());
367    }
368}
369
370impl Cli {
371    /// Validates concurrency flags and returns a localised descriptive error if invalid.
372    ///
373    /// Requires that `crate::i18n::init()` has already been called (happens before this
374    /// function in the `main` flow). In English it emits EN messages; in Portuguese it emits PT.
375    pub fn validate_flags(&self) -> Result<(), String> {
376        if let Some(n) = self.max_concurrency {
377            if n == 0 {
378                return Err(match current() {
379                    Language::English => "--max-concurrency must be >= 1".to_string(),
380                    Language::Portuguese => "--max-concurrency deve ser >= 1".to_string(),
381                });
382            }
383            let teto = max_concurrency_ceiling();
384            if n > teto {
385                return Err(match current() {
386                    Language::English => format!(
387                        "--max-concurrency {n} exceeds the ceiling of {teto} (2×nCPUs) on this system"
388                    ),
389                    Language::Portuguese => format!(
390                        "--max-concurrency {n} excede o teto de {teto} (2×nCPUs) neste sistema"
391                    ),
392                });
393            }
394        }
395        Ok(())
396    }
397}
398
399impl Commands {
400    /// Returns true for subcommands that load the ONNX model locally.
401    pub fn is_embedding_heavy(&self) -> bool {
402        matches!(
403            self,
404            Self::Init(_)
405                | Self::Remember(_)
406                | Self::RememberBatch(_)
407                | Self::Recall(_)
408                | Self::HybridSearch(_)
409                | Self::DeepResearch(_)
410        )
411    }
412
413    pub fn uses_cli_slot(&self) -> bool {
414        true
415    }
416}
417
418/// GAP-E2E-010 (v1.0.89): `codex-models` accepts `--json` as a no-op so
419/// agents that append `--json` to every subcommand never see clap errors.
420/// The handler in `main.rs` always emits JSON on stdout; this flag is
421/// accepted and ignored for parity with the rest of the CLI surface.
422#[derive(Debug, clap::Args)]
423pub struct CodexModelsArgs {
424    /// No-op; JSON is always emitted on stdout by `codex-models`.
425    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
426    pub json: bool,
427}
428
429#[derive(Subcommand)]
430pub enum Commands {
431    /// Initialize database and download embedding model
432    #[command(after_long_help = "EXAMPLES:\n  \
433        # Initialize in current directory (default behavior)\n  \
434        sqlite-graphrag init\n\n  \
435        # Initialize at a specific path\n  \
436        sqlite-graphrag init --db /path/to/graphrag.sqlite\n\n  \
437        # Initialize using SQLITE_GRAPHRAG_HOME env var\n  \
438        SQLITE_GRAPHRAG_HOME=/data sqlite-graphrag init\n\n\
439        NOTES:\n  \
440        - `init` is OPTIONAL: any subsequent CRUD command auto-initializes graphrag.sqlite if missing.\n  \
441        - As a side effect, `init` warms a smoke-test embedding via the LLM-only one-shot pipeline.")]
442    Init(init::InitArgs),
443    /// Save a memory with optional entity graph
444    #[command(after_long_help = "EXAMPLES:\n  \
445        # Inline body\n  \
446        sqlite-graphrag remember --name onboarding --type user --description \"intro\" --body \"hello\"\n\n  \
447        # Body from file\n  \
448        sqlite-graphrag remember --name doc1 --type document --description \"...\" --body-file ./README.md\n\n  \
449        # Body from stdin (pipe)\n  \
450        cat README.md | sqlite-graphrag remember --name doc1 --type document --description \"...\" --body-stdin\n\n  \
451        # Enable automatic URL extraction (URL-regex only since v1.0.79; GLiNER removed)\n  \
452        sqlite-graphrag remember --name rich --type note --description \"...\" --body \"...\" --enable-ner")]
453    Remember(remember::RememberArgs),
454    /// Batch-create memories from NDJSON stdin (one invocation, one slot)
455    #[command(after_long_help = "EXAMPLES:\n  \
456        # Batch create from NDJSON\n  \
457        cat memories.ndjson | sqlite-graphrag remember-batch --force-merge --json\n\n  \
458        # Atomic batch\n  \
459        cat memories.ndjson | sqlite-graphrag remember-batch --transaction --json")]
460    RememberBatch(remember_batch::RememberBatchArgs),
461    /// Bulk-ingest every file under a directory as separate memories (NDJSON output)
462    Ingest(ingest::IngestArgs),
463    /// Search memories semantically
464    #[command(after_long_help = "EXAMPLES:\n  \
465        # Top 10 semantic matches (default)\n  \
466        sqlite-graphrag recall \"agent memory\"\n\n  \
467        # Top 3 only\n  \
468        sqlite-graphrag recall \"agent memory\" -k 3\n\n  \
469        # Search across all namespaces\n  \
470        sqlite-graphrag recall \"agent memory\" --all-namespaces\n\n  \
471        # Disable graph traversal (vector-only)\n  \
472        sqlite-graphrag recall \"agent memory\" --no-graph")]
473    Recall(recall::RecallArgs),
474    /// Read a memory by exact name
475    Read(read::ReadArgs),
476    /// List memories with filters
477    List(list::ListArgs),
478    /// Soft-delete a memory
479    Forget(forget::ForgetArgs),
480    /// Permanently delete soft-deleted memories
481    Purge(purge::PurgeArgs),
482    /// Rename a memory preserving history
483    Rename(rename::RenameArgs),
484    /// Edit a memory's body or description
485    Edit(edit::EditArgs),
486    /// List all versions of a memory
487    History(history::HistoryArgs),
488    /// Restore a memory to a previous version
489    Restore(restore::RestoreArgs),
490    /// Search using hybrid vector + full-text search
491    #[command(after_long_help = "EXAMPLES:\n  \
492        # Hybrid search combining KNN + FTS5 BM25 with RRF\n  \
493        sqlite-graphrag hybrid-search \"agent memory architecture\"\n\n  \
494        # Custom weights for vector vs full-text components\n  \
495        sqlite-graphrag hybrid-search \"agent\" --weight-vec 0.7 --weight-fts 0.3")]
496    HybridSearch(hybrid_search::HybridSearchArgs),
497    /// Show database health
498    Health(health::HealthArgs),
499    /// Apply pending schema migrations
500    Migrate(migrate::MigrateArgs),
501    /// Resolve namespace precedence for the current invocation
502    NamespaceDetect(namespace_detect::NamespaceDetectArgs),
503    /// Run PRAGMA optimize on the database
504    Optimize(optimize::OptimizeArgs),
505    /// Show database statistics
506    Stats(stats::StatsArgs),
507    /// Create a checkpointed copy safe for file sync
508    SyncSafeCopy(sync_safe_copy::SyncSafeCopyArgs),
509    /// Back up the database using the SQLite Online Backup API
510    Backup(backup::BackupArgs),
511    /// Run VACUUM after checkpointing the WAL
512    Vacuum(vacuum::VacuumArgs),
513    /// Create an explicit relationship between two entities
514    Link(link::LinkArgs),
515    /// Remove a specific relationship between two entities
516    Unlink(unlink::UnlinkArgs),
517    /// Deep parallel multi-hop GraphRAG research
518    #[command(name = "deep-research")]
519    DeepResearch(deep_research::DeepResearchArgs),
520    /// List memories connected via the entity graph
521    Related(related::RelatedArgs),
522    /// Export a graph snapshot in json, dot or mermaid
523    Graph(graph_export::GraphArgs),
524    /// Export memories as NDJSON (one JSON line per memory, plus a summary line)
525    Export(export::ExportArgs),
526    /// FTS5 full-text search index management (rebuild or check)
527    Fts(fts::FtsArgs),
528    /// Vector index maintenance (orphan detection, purge, stats) — G39
529    Vec(vec::VecArgs),
530    /// List codex OAuth models accepted by ChatGPT Pro (G33).
531    ///
532    /// GAP-E2E-010 (v1.0.89): accepts `--json` as a no-op (JSON is always
533    /// emitted on stdout) so the flag never breaks agent pipelines that
534    /// append `--json` to every invocation.
535    #[command(name = "codex-models")]
536    CodexModels(CodexModelsArgs),
537    /// Bulk-delete all relationships of a given type (e.g. mentions)
538    PruneRelations(prune_relations::PruneRelationsArgs),
539    /// Remove NER bindings (memory_entities rows) for an entity or all entities
540    #[command(name = "prune-ner")]
541    PruneNer(prune_ner::PruneNerArgs),
542    /// Inspect and manage cross-process LLM slot semaphore (GAP-004, v1.0.82)
543    Slots(slots::SlotsArgs),
544    /// Inspect and manage the `remember` checkpoint queue (GAP-001, v1.0.82)
545    Pending(pending::PendingArgs),
546    /// Health and per-entry inspection of the pending-embeddings queue (GAP-005, v1.0.82)
547    Embedding(embedding::EmbeddingArgs),
548    /// Batch operations over the pending-embeddings queue (GAP-005, v1.0.82)
549    #[command(name = "pending-embeddings")]
550    PendingEmbeddings(pending_embeddings::PendingEmbeddingsArgs),
551    /// Remove entities that have no memories and no relationships
552    CleanupOrphans(cleanup_orphans::CleanupOrphansArgs),
553    /// List entities linked to a specific memory
554    MemoryEntities(memory_entities::MemoryEntitiesArgs),
555    /// Manage cached resources (embedding models, etc.)
556    Cache(cache::CacheArgs),
557    /// Delete an entity and all its relationships from the graph
558    #[command(name = "delete-entity")]
559    DeleteEntity(delete_entity::DeleteEntityArgs),
560    /// Reclassify one entity or a batch of entities to a new type
561    Reclassify(reclassify::ReclassifyArgs),
562    /// Rename an entity preserving all relationships and memory bindings
563    #[command(name = "rename-entity")]
564    RenameEntity(rename_entity::RenameEntityArgs),
565    /// Merge multiple source entities into a single target entity
566    #[command(name = "merge-entities")]
567    MergeEntities(merge_entities::MergeEntitiesArgs),
568    /// Enrich graph memories and entities using an LLM provider
569    Enrich(enrich::EnrichArgs),
570    /// Reclassify relationship types across the graph using rules or LLM judgment
571    #[command(name = "reclassify-relation")]
572    ReclassifyRelation(reclassify_relation::ReclassifyRelationArgs),
573    /// Normalize entity names (deduplicate, kebab-case, merge near-duplicates)
574    #[command(name = "normalize-entities")]
575    NormalizeEntities(normalize_entities::NormalizeEntitiesArgs),
576    /// Generate shell completions for Bash, Zsh, Fish, PowerShell, or Elvish
577    Completions(completions::CompletionsArgs),
578    #[command(name = "debug-schema", hide = true)]
579    DebugSchema(debug_schema::DebugSchemaArgs),
580}
581// FIX-1 (v1.0.89): manual `Debug` impl so test panic messages that print
582// `{:?}` on a captured `Commands` variant compile without requiring every
583// contained subcommand arg struct to derive `Debug`. The Debug output is
584// only used in test assertions for diagnostic messages; we emit the variant
585// name only — arg payload is intentionally omitted.
586impl std::fmt::Debug for Commands {
587    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
588        let name = match self {
589            Self::Init(_) => "Init",
590            Self::Health(_) => "Health",
591            Self::Stats(_) => "Stats",
592            Self::List(_) => "List",
593            Self::Read(_) => "Read",
594            Self::Edit(_) => "Edit",
595            Self::Rename(_) => "Rename",
596            Self::Restore(_) => "Restore",
597            Self::History(_) => "History",
598            Self::Forget(_) => "Forget",
599            Self::Purge(_) => "Purge",
600            Self::Remember(_) => "Remember",
601            Self::RememberBatch(_) => "RememberBatch",
602            Self::Recall(_) => "Recall",
603            Self::HybridSearch(_) => "HybridSearch",
604            Self::Enrich(_) => "Enrich",
605            Self::Ingest(_) => "Ingest",
606            Self::Optimize(_) => "Optimize",
607            Self::Migrate(_) => "Migrate",
608            Self::SyncSafeCopy(_) => "SyncSafeCopy",
609            Self::Backup(_) => "Backup",
610            Self::Vacuum(_) => "Vacuum",
611            Self::Link(_) => "Link",
612            Self::Unlink(_) => "Unlink",
613            Self::DeepResearch(_) => "DeepResearch",
614            Self::Related(_) => "Related",
615            Self::Graph(_) => "Graph",
616            Self::Export(_) => "Export",
617            Self::Fts(_) => "Fts",
618            Self::Vec(_) => "Vec",
619            Self::CodexModels(_) => "CodexModels",
620            Self::PruneRelations(_) => "PruneRelations",
621            Self::PruneNer(_) => "PruneNer",
622            Self::Slots(_) => "Slots",
623            Self::Pending(_) => "Pending",
624            Self::Embedding(_) => "Embedding",
625            Self::PendingEmbeddings(_) => "PendingEmbeddings",
626            Self::CleanupOrphans(_) => "CleanupOrphans",
627            Self::MemoryEntities(_) => "MemoryEntities",
628            Self::Cache(_) => "Cache",
629            Self::DeleteEntity(_) => "DeleteEntity",
630            Self::Reclassify(_) => "Reclassify",
631            Self::RenameEntity(_) => "RenameEntity",
632            Self::ReclassifyRelation(_) => "ReclassifyRelation",
633            Self::NormalizeEntities(_) => "NormalizeEntities",
634            Self::MergeEntities(_) => "MergeEntities",
635            Self::NamespaceDetect(_) => "NamespaceDetect",
636            Self::Completions(_) => "Completions",
637            Self::DebugSchema(_) => "DebugSchema",
638        };
639        f.write_str(name)
640    }
641}
642
643#[derive(Copy, Clone, Debug, Default, clap::ValueEnum)]
644pub enum MemoryType {
645    User,
646    Feedback,
647    Project,
648    Reference,
649    Decision,
650    Incident,
651    Skill,
652    #[default]
653    Document,
654    Note,
655}
656
657#[cfg(test)]
658mod heavy_concurrency_tests {
659    use super::*;
660
661    #[test]
662    fn command_heavy_detects_init_and_embeddings() {
663        let init = Cli::try_parse_from(["sqlite-graphrag", "init"]).expect("parse init");
664        assert!(init
665            .command
666            .as_ref()
667            .is_some_and(|c| c.is_embedding_heavy()));
668
669        let remember = Cli::try_parse_from([
670            "sqlite-graphrag",
671            "remember",
672            "--name",
673            "test-memory",
674            "--type",
675            "project",
676            "--description",
677            "desc",
678        ])
679        .expect("parse remember");
680        assert!(remember
681            .command
682            .as_ref()
683            .is_some_and(|c| c.is_embedding_heavy()));
684
685        let recall =
686            Cli::try_parse_from(["sqlite-graphrag", "recall", "query"]).expect("parse recall");
687        assert!(recall
688            .command
689            .as_ref()
690            .is_some_and(|c| c.is_embedding_heavy()));
691
692        let hybrid = Cli::try_parse_from(["sqlite-graphrag", "hybrid-search", "query"])
693            .expect("parse hybrid");
694        assert!(hybrid
695            .command
696            .as_ref()
697            .is_some_and(|c| c.is_embedding_heavy()));
698    }
699
700    #[test]
701    fn command_light_does_not_mark_stats() {
702        let stats = Cli::try_parse_from(["sqlite-graphrag", "stats"]).expect("parse stats");
703        assert!(!stats
704            .command
705            .as_ref()
706            .is_some_and(|c| c.is_embedding_heavy()));
707    }
708}
709
710impl MemoryType {
711    pub fn as_str(&self) -> &'static str {
712        match self {
713            Self::User => "user",
714            Self::Feedback => "feedback",
715            Self::Project => "project",
716            Self::Reference => "reference",
717            Self::Decision => "decision",
718            Self::Incident => "incident",
719            Self::Skill => "skill",
720            Self::Document => "document",
721            Self::Note => "note",
722        }
723    }
724}