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    /// Número máximo de invocações CLI simultâneas permitidas (default: 4).
61    ///
62    /// Limita o semáforo de contagem de slots de concorrência. O valor é restrito
63    /// ao intervalo [1, 2×nCPUs]. Valores acima do teto são rejeitados com exit 2.
64    #[arg(long, global = true, value_name = "N")]
65    pub max_concurrency: Option<usize>,
66
67    /// Aguardar até SECONDS por um slot livre antes de desistir (exit 75).
68    ///
69    /// Útil em pipelines de agentes que fazem retry: a instância faz polling a
70    /// cada 500 ms até o timeout ou um slot abrir. Default: 300s (5 minutos).
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    /// Idioma das mensagens humanas (stderr). Aceita `en` ou `pt`.
81    ///
82    /// Sem a flag, detecta via env `SQLITE_GRAPHRAG_LANG` e depois `LC_ALL`/`LANG`.
83    /// JSON de stdout é determinístico e idêntico entre idiomas — apenas
84    /// strings destinadas a humanos são afetadas.
85    #[arg(long, global = true, value_enum, value_name = "LANG")]
86    pub lang: Option<crate::i18n::Language>,
87
88    /// Fuso horário para campos `*_iso` no JSON de saída (ex: `America/Sao_Paulo`).
89    ///
90    /// Aceita qualquer nome IANA da IANA Time Zone Database. Sem a flag, usa
91    /// `SQLITE_GRAPHRAG_DISPLAY_TZ`; se ausente, usa UTC. Não afeta campos epoch inteiros.
92    #[arg(long, global = true, value_name = "IANA")]
93    pub tz: Option<chrono_tz::Tz>,
94
95    #[command(subcommand)]
96    pub command: Commands,
97}
98
99#[cfg(test)]
100mod testes_formato_json_only {
101    use super::Cli;
102    use clap::Parser;
103
104    #[test]
105    fn restore_aceita_apenas_format_json() {
106        assert!(Cli::try_parse_from([
107            "sqlite-graphrag",
108            "restore",
109            "--name",
110            "mem",
111            "--version",
112            "1",
113            "--format",
114            "json",
115        ])
116        .is_ok());
117
118        assert!(Cli::try_parse_from([
119            "sqlite-graphrag",
120            "restore",
121            "--name",
122            "mem",
123            "--version",
124            "1",
125            "--format",
126            "text",
127        ])
128        .is_err());
129    }
130
131    #[test]
132    fn hybrid_search_aceita_apenas_format_json() {
133        assert!(Cli::try_parse_from([
134            "sqlite-graphrag",
135            "hybrid-search",
136            "query",
137            "--format",
138            "json",
139        ])
140        .is_ok());
141
142        assert!(Cli::try_parse_from([
143            "sqlite-graphrag",
144            "hybrid-search",
145            "query",
146            "--format",
147            "markdown",
148        ])
149        .is_err());
150    }
151
152    #[test]
153    fn remember_recall_rename_vacuum_json_only() {
154        assert!(Cli::try_parse_from([
155            "sqlite-graphrag",
156            "remember",
157            "--name",
158            "mem",
159            "--type",
160            "project",
161            "--description",
162            "desc",
163            "--format",
164            "json",
165        ])
166        .is_ok());
167        assert!(Cli::try_parse_from([
168            "sqlite-graphrag",
169            "remember",
170            "--name",
171            "mem",
172            "--type",
173            "project",
174            "--description",
175            "desc",
176            "--format",
177            "text",
178        ])
179        .is_err());
180
181        assert!(
182            Cli::try_parse_from(["sqlite-graphrag", "recall", "query", "--format", "json",])
183                .is_ok()
184        );
185        assert!(
186            Cli::try_parse_from(["sqlite-graphrag", "recall", "query", "--format", "text",])
187                .is_err()
188        );
189
190        assert!(Cli::try_parse_from([
191            "sqlite-graphrag",
192            "rename",
193            "--name",
194            "old",
195            "--new-name",
196            "new",
197            "--format",
198            "json",
199        ])
200        .is_ok());
201        assert!(Cli::try_parse_from([
202            "sqlite-graphrag",
203            "rename",
204            "--name",
205            "old",
206            "--new-name",
207            "new",
208            "--format",
209            "markdown",
210        ])
211        .is_err());
212
213        assert!(Cli::try_parse_from(["sqlite-graphrag", "vacuum", "--format", "json",]).is_ok());
214        assert!(Cli::try_parse_from(["sqlite-graphrag", "vacuum", "--format", "text",]).is_err());
215    }
216}
217
218impl Cli {
219    /// Valida flags de concorrência e retorna erro descritivo localizado se inválidas.
220    ///
221    /// Requer que `crate::i18n::init()` já tenha sido chamado (ocorre antes desta função
222    /// no fluxo de `main`). Em inglês emite mensagens EN; em português emite PT.
223    pub fn validate_flags(&self) -> Result<(), String> {
224        if let Some(n) = self.max_concurrency {
225            if n == 0 {
226                return Err(match current() {
227                    Language::English => "--max-concurrency must be >= 1".to_string(),
228                    Language::Portugues => "--max-concurrency deve ser >= 1".to_string(),
229                });
230            }
231            let teto = max_concurrency_ceiling();
232            if n > teto {
233                return Err(match current() {
234                    Language::English => format!(
235                        "--max-concurrency {n} exceeds the ceiling of {teto} (2×nCPUs) on this system"
236                    ),
237                    Language::Portugues => format!(
238                        "--max-concurrency {n} excede o teto de {teto} (2×nCPUs) neste sistema"
239                    ),
240                });
241            }
242        }
243        Ok(())
244    }
245}
246
247impl Commands {
248    /// Retorna true para subcomandos que carregam o modelo ONNX localmente.
249    pub fn is_embedding_heavy(&self) -> bool {
250        matches!(
251            self,
252            Self::Init(_) | Self::Remember(_) | Self::Recall(_) | Self::HybridSearch(_)
253        )
254    }
255}
256
257#[derive(Subcommand)]
258pub enum Commands {
259    /// Initialize database and download embedding model
260    Init(init::InitArgs),
261    /// Save a memory with optional entity graph
262    Remember(remember::RememberArgs),
263    /// Search memories semantically
264    Recall(recall::RecallArgs),
265    /// Read a memory by exact name
266    Read(read::ReadArgs),
267    /// List memories with filters
268    List(list::ListArgs),
269    /// Soft-delete a memory
270    Forget(forget::ForgetArgs),
271    /// Permanently delete soft-deleted memories
272    Purge(purge::PurgeArgs),
273    /// Rename a memory preserving history
274    Rename(rename::RenameArgs),
275    /// Edit a memory's body or description
276    Edit(edit::EditArgs),
277    /// List all versions of a memory
278    History(history::HistoryArgs),
279    /// Restore a memory to a previous version
280    Restore(restore::RestoreArgs),
281    /// Search using hybrid vector + full-text search
282    HybridSearch(hybrid_search::HybridSearchArgs),
283    /// Show database health
284    Health(health::HealthArgs),
285    /// Apply pending schema migrations
286    Migrate(migrate::MigrateArgs),
287    /// Resolve namespace precedence for the current invocation
288    NamespaceDetect(namespace_detect::NamespaceDetectArgs),
289    /// Run PRAGMA optimize on the database
290    Optimize(optimize::OptimizeArgs),
291    /// Show database statistics
292    Stats(stats::StatsArgs),
293    /// Create a checkpointed copy safe for file sync
294    SyncSafeCopy(sync_safe_copy::SyncSafeCopyArgs),
295    /// Run VACUUM after checkpointing the WAL
296    Vacuum(vacuum::VacuumArgs),
297    /// Create an explicit relationship between two entities
298    Link(link::LinkArgs),
299    /// Remove a specific relationship between two entities
300    Unlink(unlink::UnlinkArgs),
301    /// List memories connected via the entity graph
302    Related(related::RelatedArgs),
303    /// Export a graph snapshot in json, dot or mermaid
304    Graph(graph_export::GraphArgs),
305    /// Remove entities that have no memories and no relationships
306    CleanupOrphans(cleanup_orphans::CleanupOrphansArgs),
307    #[command(name = "__debug_schema", hide = true)]
308    DebugSchema(debug_schema::DebugSchemaArgs),
309}
310
311#[derive(Copy, Clone, Debug, clap::ValueEnum)]
312pub enum MemoryType {
313    User,
314    Feedback,
315    Project,
316    Reference,
317    Decision,
318    Incident,
319    Skill,
320}
321
322#[cfg(test)]
323mod testes_concorrencia_pesada {
324    use super::*;
325
326    #[test]
327    fn command_heavy_detecta_init_e_embeddings() {
328        let init = Cli::try_parse_from(["sqlite-graphrag", "init"]).expect("parse init");
329        assert!(init.command.is_embedding_heavy());
330
331        let remember = Cli::try_parse_from([
332            "sqlite-graphrag",
333            "remember",
334            "--name",
335            "memoria-teste",
336            "--type",
337            "project",
338            "--description",
339            "desc",
340        ])
341        .expect("parse remember");
342        assert!(remember.command.is_embedding_heavy());
343
344        let recall =
345            Cli::try_parse_from(["sqlite-graphrag", "recall", "consulta"]).expect("parse recall");
346        assert!(recall.command.is_embedding_heavy());
347
348        let hybrid = Cli::try_parse_from(["sqlite-graphrag", "hybrid-search", "consulta"])
349            .expect("parse hybrid");
350        assert!(hybrid.command.is_embedding_heavy());
351    }
352
353    #[test]
354    fn command_light_nao_marca_stats() {
355        let stats = Cli::try_parse_from(["sqlite-graphrag", "stats"]).expect("parse stats");
356        assert!(!stats.command.is_embedding_heavy());
357    }
358}
359
360impl MemoryType {
361    pub fn as_str(&self) -> &'static str {
362        match self {
363            Self::User => "user",
364            Self::Feedback => "feedback",
365            Self::Project => "project",
366            Self::Reference => "reference",
367            Self::Decision => "decision",
368            Self::Incident => "incident",
369            Self::Skill => "skill",
370        }
371    }
372}