Skip to main content

sqlite_graphrag/
cli.rs

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