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, clap::ValueEnum)]
17pub enum RelationKind {
18    AppliesTo,
19    Uses,
20    DependsOn,
21    Causes,
22    Fixes,
23    Contradicts,
24    Supports,
25    Follows,
26    Related,
27    Mentions,
28    Replaces,
29    TrackedIn,
30}
31
32impl RelationKind {
33    pub fn as_str(&self) -> &'static str {
34        match self {
35            Self::AppliesTo => "applies_to",
36            Self::Uses => "uses",
37            Self::DependsOn => "depends_on",
38            Self::Causes => "causes",
39            Self::Fixes => "fixes",
40            Self::Contradicts => "contradicts",
41            Self::Supports => "supports",
42            Self::Follows => "follows",
43            Self::Related => "related",
44            Self::Mentions => "mentions",
45            Self::Replaces => "replaces",
46            Self::TrackedIn => "tracked_in",
47        }
48    }
49}
50
51#[derive(Copy, Clone, Debug, clap::ValueEnum)]
52pub enum GraphExportFormat {
53    Json,
54    Dot,
55    Mermaid,
56}
57
58#[derive(Parser)]
59#[command(name = "sqlite-graphrag")]
60#[command(version)]
61#[command(about = "Local GraphRAG memory for LLMs in a single SQLite file")]
62#[command(arg_required_else_help = true)]
63pub struct Cli {
64    /// Maximum number of simultaneous CLI invocations allowed (default: 4).
65    ///
66    /// Caps the counting semaphore used for CLI concurrency slots. The value must
67    /// stay within [1, 2×nCPUs]. Values above the ceiling are rejected with exit 2.
68    #[arg(long, global = true, value_name = "N")]
69    pub max_concurrency: Option<usize>,
70
71    /// Wait up to SECONDS for a free concurrency slot before giving up (exit 75).
72    ///
73    /// Useful in retrying agent pipelines: the process polls every 500 ms until a
74    /// slot opens or the timeout expires. Default: 300s (5 minutes).
75    #[arg(long, global = true, value_name = "SECONDS")]
76    pub wait_lock: Option<u64>,
77
78    /// Skip the available-memory check before loading the model.
79    ///
80    /// Exclusive use in automated tests where real allocation does not occur.
81    #[arg(long, global = true, hide = true, default_value_t = false)]
82    pub skip_memory_guard: bool,
83
84    /// Language for human-facing stderr messages. Accepts `en` or `pt`.
85    ///
86    /// Without the flag, detection falls back to `SQLITE_GRAPHRAG_LANG` and then
87    /// `LC_ALL`/`LANG`. JSON stdout stays deterministic and identical across
88    /// languages; only human-facing strings are affected.
89    #[arg(long, global = true, value_enum, value_name = "LANG")]
90    pub lang: Option<crate::i18n::Language>,
91
92    /// Time zone for `*_iso` fields in JSON output (for example `America/Sao_Paulo`).
93    ///
94    /// Accepts any IANA time zone name. Without the flag, it falls back to
95    /// `SQLITE_GRAPHRAG_DISPLAY_TZ`; if unset, UTC is used. Integer epoch fields
96    /// are not affected.
97    #[arg(long, global = true, value_name = "IANA")]
98    pub tz: Option<chrono_tz::Tz>,
99
100    #[command(subcommand)]
101    pub command: Commands,
102}
103
104#[cfg(test)]
105mod testes_formato_json_only {
106    use super::Cli;
107    use clap::Parser;
108
109    #[test]
110    fn restore_aceita_apenas_format_json() {
111        assert!(Cli::try_parse_from([
112            "sqlite-graphrag",
113            "restore",
114            "--name",
115            "mem",
116            "--version",
117            "1",
118            "--format",
119            "json",
120        ])
121        .is_ok());
122
123        assert!(Cli::try_parse_from([
124            "sqlite-graphrag",
125            "restore",
126            "--name",
127            "mem",
128            "--version",
129            "1",
130            "--format",
131            "text",
132        ])
133        .is_err());
134    }
135
136    #[test]
137    fn hybrid_search_aceita_apenas_format_json() {
138        assert!(Cli::try_parse_from([
139            "sqlite-graphrag",
140            "hybrid-search",
141            "query",
142            "--format",
143            "json",
144        ])
145        .is_ok());
146
147        assert!(Cli::try_parse_from([
148            "sqlite-graphrag",
149            "hybrid-search",
150            "query",
151            "--format",
152            "markdown",
153        ])
154        .is_err());
155    }
156
157    #[test]
158    fn remember_recall_rename_vacuum_json_only() {
159        assert!(Cli::try_parse_from([
160            "sqlite-graphrag",
161            "remember",
162            "--name",
163            "mem",
164            "--type",
165            "project",
166            "--description",
167            "desc",
168            "--format",
169            "json",
170        ])
171        .is_ok());
172        assert!(Cli::try_parse_from([
173            "sqlite-graphrag",
174            "remember",
175            "--name",
176            "mem",
177            "--type",
178            "project",
179            "--description",
180            "desc",
181            "--format",
182            "text",
183        ])
184        .is_err());
185
186        assert!(
187            Cli::try_parse_from(["sqlite-graphrag", "recall", "query", "--format", "json",])
188                .is_ok()
189        );
190        assert!(
191            Cli::try_parse_from(["sqlite-graphrag", "recall", "query", "--format", "text",])
192                .is_err()
193        );
194
195        assert!(Cli::try_parse_from([
196            "sqlite-graphrag",
197            "rename",
198            "--name",
199            "old",
200            "--new-name",
201            "new",
202            "--format",
203            "json",
204        ])
205        .is_ok());
206        assert!(Cli::try_parse_from([
207            "sqlite-graphrag",
208            "rename",
209            "--name",
210            "old",
211            "--new-name",
212            "new",
213            "--format",
214            "markdown",
215        ])
216        .is_err());
217
218        assert!(Cli::try_parse_from(["sqlite-graphrag", "vacuum", "--format", "json",]).is_ok());
219        assert!(Cli::try_parse_from(["sqlite-graphrag", "vacuum", "--format", "text",]).is_err());
220    }
221}
222
223impl Cli {
224    /// Validates concurrency flags and returns a localised descriptive error if invalid.
225    ///
226    /// Requires that `crate::i18n::init()` has already been called (happens before this
227    /// function in the `main` flow). In English it emits EN messages; in Portuguese it emits PT.
228    pub fn validate_flags(&self) -> Result<(), String> {
229        if let Some(n) = self.max_concurrency {
230            if n == 0 {
231                return Err(match current() {
232                    Language::English => "--max-concurrency must be >= 1".to_string(),
233                    Language::Portuguese => "--max-concurrency deve ser >= 1".to_string(),
234                });
235            }
236            let teto = max_concurrency_ceiling();
237            if n > teto {
238                return Err(match current() {
239                    Language::English => format!(
240                        "--max-concurrency {n} exceeds the ceiling of {teto} (2×nCPUs) on this system"
241                    ),
242                    Language::Portuguese => format!(
243                        "--max-concurrency {n} excede o teto de {teto} (2×nCPUs) neste sistema"
244                    ),
245                });
246            }
247        }
248        Ok(())
249    }
250}
251
252impl Commands {
253    /// Retorna true para subcomandos que carregam o modelo ONNX localmente.
254    pub fn is_embedding_heavy(&self) -> bool {
255        matches!(
256            self,
257            Self::Init(_) | Self::Remember(_) | Self::Recall(_) | Self::HybridSearch(_)
258        )
259    }
260
261    pub fn uses_cli_slot(&self) -> bool {
262        !matches!(self, Self::Daemon(_))
263    }
264}
265
266#[derive(Subcommand)]
267pub enum Commands {
268    /// Initialize database and download embedding model
269    Init(init::InitArgs),
270    /// Run or control the persistent embedding daemon
271    Daemon(daemon::DaemonArgs),
272    /// Save a memory with optional entity graph
273    Remember(remember::RememberArgs),
274    /// Search memories semantically
275    Recall(recall::RecallArgs),
276    /// Read a memory by exact name
277    Read(read::ReadArgs),
278    /// List memories with filters
279    List(list::ListArgs),
280    /// Soft-delete a memory
281    Forget(forget::ForgetArgs),
282    /// Permanently delete soft-deleted memories
283    Purge(purge::PurgeArgs),
284    /// Rename a memory preserving history
285    Rename(rename::RenameArgs),
286    /// Edit a memory's body or description
287    Edit(edit::EditArgs),
288    /// List all versions of a memory
289    History(history::HistoryArgs),
290    /// Restore a memory to a previous version
291    Restore(restore::RestoreArgs),
292    /// Search using hybrid vector + full-text search
293    HybridSearch(hybrid_search::HybridSearchArgs),
294    /// Show database health
295    Health(health::HealthArgs),
296    /// Apply pending schema migrations
297    Migrate(migrate::MigrateArgs),
298    /// Resolve namespace precedence for the current invocation
299    NamespaceDetect(namespace_detect::NamespaceDetectArgs),
300    /// Run PRAGMA optimize on the database
301    Optimize(optimize::OptimizeArgs),
302    /// Show database statistics
303    Stats(stats::StatsArgs),
304    /// Create a checkpointed copy safe for file sync
305    SyncSafeCopy(sync_safe_copy::SyncSafeCopyArgs),
306    /// Run VACUUM after checkpointing the WAL
307    Vacuum(vacuum::VacuumArgs),
308    /// Create an explicit relationship between two entities
309    Link(link::LinkArgs),
310    /// Remove a specific relationship between two entities
311    Unlink(unlink::UnlinkArgs),
312    /// List memories connected via the entity graph
313    Related(related::RelatedArgs),
314    /// Export a graph snapshot in json, dot or mermaid
315    Graph(graph_export::GraphArgs),
316    /// Remove entities that have no memories and no relationships
317    CleanupOrphans(cleanup_orphans::CleanupOrphansArgs),
318    #[command(name = "__debug_schema", hide = true)]
319    DebugSchema(debug_schema::DebugSchemaArgs),
320}
321
322#[derive(Copy, Clone, Debug, clap::ValueEnum)]
323pub enum MemoryType {
324    User,
325    Feedback,
326    Project,
327    Reference,
328    Decision,
329    Incident,
330    Skill,
331}
332
333#[cfg(test)]
334mod testes_concorrencia_pesada {
335    use super::*;
336
337    #[test]
338    fn command_heavy_detecta_init_e_embeddings() {
339        let init = Cli::try_parse_from(["sqlite-graphrag", "init"]).expect("parse init");
340        assert!(init.command.is_embedding_heavy());
341
342        let remember = Cli::try_parse_from([
343            "sqlite-graphrag",
344            "remember",
345            "--name",
346            "memoria-teste",
347            "--type",
348            "project",
349            "--description",
350            "desc",
351        ])
352        .expect("parse remember");
353        assert!(remember.command.is_embedding_heavy());
354
355        let recall =
356            Cli::try_parse_from(["sqlite-graphrag", "recall", "consulta"]).expect("parse recall");
357        assert!(recall.command.is_embedding_heavy());
358
359        let hybrid = Cli::try_parse_from(["sqlite-graphrag", "hybrid-search", "consulta"])
360            .expect("parse hybrid");
361        assert!(hybrid.command.is_embedding_heavy());
362    }
363
364    #[test]
365    fn command_light_nao_marca_stats() {
366        let stats = Cli::try_parse_from(["sqlite-graphrag", "stats"]).expect("parse stats");
367        assert!(!stats.command.is_embedding_heavy());
368    }
369}
370
371impl MemoryType {
372    pub fn as_str(&self) -> &'static str {
373        match self {
374            Self::User => "user",
375            Self::Feedback => "feedback",
376            Self::Project => "project",
377            Self::Reference => "reference",
378            Self::Decision => "decision",
379            Self::Incident => "incident",
380            Self::Skill => "skill",
381        }
382    }
383}