Skip to main content

sqlite_graphrag/
errors.rs

1//! Library-wide error type.
2//!
3//! `AppError` is the single error type returned by every public API in the
4//! crate. Each variant maps to a deterministic exit code through
5//! `AppError::exit_code`, which the binary propagates to the shell on
6//! failure. See the README for the full exit code contract.
7
8use crate::i18n::{current, Language};
9use thiserror::Error;
10
11/// Unified error type for all CLI and library operations.
12///
13/// Each variant corresponds to a distinct failure category. The
14/// [`AppError::exit_code`] method converts a variant into a stable numeric
15/// code so that shell callers and LLM agents can route on it.
16#[derive(Error, Debug)]
17pub enum AppError {
18    /// Input failed schema, length or format validation. Maps to exit code `1`.
19    #[error("validation error: {0}")]
20    Validation(String),
21
22    /// A memory or entity with the same `(namespace, name)` already exists. Maps to exit code `2`.
23    #[error("duplicate detected: {0}")]
24    Duplicate(String),
25
26    /// Optimistic update lost the race because `updated_at` changed. Maps to exit code `3`.
27    #[error("conflict: {0}")]
28    Conflict(String),
29
30    /// The requested record does not exist or was soft-deleted. Maps to exit code `4`.
31    #[error("not found: {0}")]
32    NotFound(String),
33
34    /// Namespace could not be resolved from flag, environment or markers. Maps to exit code `5`.
35    #[error("namespace not resolved: {0}")]
36    NamespaceError(String),
37
38    /// Payload exceeded one of the configured body, name or batch limits. Maps to exit code `6`.
39    #[error("limit exceeded: {0}")]
40    LimitExceeded(String),
41
42    /// Low-level SQLite error propagated from `rusqlite`. Maps to exit code `10`.
43    #[error("database error: {0}")]
44    Database(#[from] rusqlite::Error),
45
46    /// Embedding generation via `fastembed` failed or produced the wrong shape. Maps to exit code `11`.
47    #[error("embedding error: {0}")]
48    Embedding(String),
49
50    /// The `sqlite-vec` extension could not load or register its virtual table. Maps to exit code `12`.
51    #[error("sqlite-vec extension failed: {0}")]
52    VecExtension(String),
53
54    /// SQLite returned `SQLITE_BUSY` after exhausting retries. Maps to exit code `15` (antes de v2.0.0 era `13`; movido para liberar `13` para BatchPartialFailure conforme PRD).
55    #[error("database busy: {0}")]
56    DbBusy(String),
57
58    /// Batch operation failed partially — N of M items failed. Maps to exit code `13` (PRD 1822).
59    ///
60    /// Reservado para uso em `import`, `reindex` e batch stdin (BLOCO 3/4). Variante presente
61    /// desde v2.0.0 mesmo que call-sites ainda não existam — mapeamento estável de exit code.
62    #[error("batch partial failure: {failed} of {total} items failed")]
63    BatchPartialFailure { total: usize, failed: usize },
64
65    /// Filesystem I/O error while reading or writing the database or cache. Maps to exit code `14`.
66    #[error("IO error: {0}")]
67    Io(#[from] std::io::Error),
68
69    /// Unexpected internal error surfaced through `anyhow`. Maps to exit code `20`.
70    #[error("internal error: {0}")]
71    Internal(#[from] anyhow::Error),
72
73    /// JSON serialization or deserialization failure. Maps to exit code `20`.
74    #[error("json error: {0}")]
75    Json(#[from] serde_json::Error),
76
77    /// Another instance is already running and holds the advisory lock. Maps to exit code `75`.
78    ///
79    /// Use `--allow-parallel` to skip the lock or `--wait-lock SECONDS` to retry.
80    #[error("lock busy: {0}")]
81    LockBusy(String),
82
83    /// Todos os slots de concorrência estão ocupados após o tempo de espera. Maps to exit code `75`.
84    ///
85    /// Ocorre quando [`crate::constants::MAX_CONCURRENT_CLI_INSTANCES`] instâncias já estão
86    /// ativas e o limite de espera [`crate::constants::CLI_LOCK_DEFAULT_WAIT_SECS`] foi esgotado.
87    #[error(
88        "all {max} concurrency slots occupied after waiting {waited_secs}s (exit 75); \
89         use --max-concurrency or wait for other invocations to finish"
90    )]
91    AllSlotsFull { max: usize, waited_secs: u64 },
92
93    /// Memória disponível abaixo do mínimo para carregar o modelo. Maps to exit code `77`.
94    ///
95    /// Retornado quando `sysinfo` reporta memória disponível inferior a
96    /// [`crate::constants::MIN_AVAILABLE_MEMORY_MB`] MiB antes de iniciar o carregamento ONNX.
97    #[error(
98        "available memory ({available_mb}MB) below required minimum ({required_mb}MB) \
99         to load the model; abort other loads or use --skip-memory-guard (exit 77)"
100    )]
101    LowMemory { available_mb: u64, required_mb: u64 },
102}
103
104impl AppError {
105    /// Returns the deterministic process exit code for this error variant.
106    ///
107    /// The codes follow the contract documented in the README: `1` for
108    /// validation, `2` for duplicates, `3` for conflicts, `4` for missing
109    /// records, `5` for namespace errors, `6` for limit violations, `10`–`14`
110    /// for infrastructure failures, `13` for BatchPartialFailure (PRD 1822),
111    /// `15` for DbBusy (migrated from `13` in v2.0.0), `20` for internal errors,
112    /// `75` (EX_TEMPFAIL) when the advisory CLI lock is held or all concurrency
113    /// slots are exhausted, and `77` when available memory is insufficient to
114    /// load the embedding model.
115    ///
116    /// # Examples
117    ///
118    /// ```
119    /// use sqlite_graphrag::errors::AppError;
120    ///
121    /// assert_eq!(AppError::Validation("campo inválido".into()).exit_code(), 1);
122    /// assert_eq!(AppError::Duplicate("ns/mem".into()).exit_code(), 2);
123    /// assert_eq!(AppError::Conflict("ts mudou".into()).exit_code(), 3);
124    /// assert_eq!(AppError::NotFound("id 42".into()).exit_code(), 4);
125    /// assert_eq!(AppError::NamespaceError("sem marcador".into()).exit_code(), 5);
126    /// assert_eq!(AppError::LimitExceeded("corpo grande".into()).exit_code(), 6);
127    /// assert_eq!(AppError::Embedding("dim errada".into()).exit_code(), 11);
128    /// assert_eq!(AppError::DbBusy("retries esgotados".into()).exit_code(), 15);
129    /// assert_eq!(AppError::LockBusy("outra instância".into()).exit_code(), 75);
130    /// ```
131    pub fn exit_code(&self) -> i32 {
132        match self {
133            Self::Validation(_) => 1,
134            Self::Duplicate(_) => 2,
135            Self::Conflict(_) => 3,
136            Self::NotFound(_) => 4,
137            Self::NamespaceError(_) => 5,
138            Self::LimitExceeded(_) => 6,
139            Self::Database(_) => 10,
140            Self::Embedding(_) => 11,
141            Self::VecExtension(_) => 12,
142            Self::BatchPartialFailure { .. } => crate::constants::BATCH_PARTIAL_FAILURE_EXIT_CODE,
143            Self::DbBusy(_) => crate::constants::DB_BUSY_EXIT_CODE,
144            Self::Io(_) => 14,
145            Self::Internal(_) => 20,
146            Self::Json(_) => 20,
147            Self::LockBusy(_) => crate::constants::CLI_LOCK_EXIT_CODE,
148            Self::AllSlotsFull { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
149            Self::LowMemory { .. } => crate::constants::LOW_MEMORY_EXIT_CODE,
150        }
151    }
152
153    /// Retorna a mensagem de erro localizada no idioma ativo (`--lang` / `SQLITE_GRAPHRAG_LANG`).
154    ///
155    /// Em inglês, o texto é idêntico ao `Display` gerado por thiserror.
156    /// Em português, os prefixos e mensagens são traduzidos para PT-BR.
157    pub fn localized_message(&self) -> String {
158        self.localized_message_for(current())
159    }
160
161    /// Retorna a mensagem localizada para o idioma explicitamente fornecido.
162    /// Útil em testes que não podem depender do `OnceLock` global.
163    ///
164    /// # Examples
165    ///
166    /// ```
167    /// use sqlite_graphrag::errors::AppError;
168    /// use sqlite_graphrag::i18n::Language;
169    ///
170    /// let err = AppError::NotFound("mem-xyz".into());
171    ///
172    /// let en = err.localized_message_for(Language::English);
173    /// assert!(en.contains("not found"));
174    ///
175    /// let pt = err.localized_message_for(Language::Portugues);
176    /// assert!(pt.contains("não encontrado"));
177    /// ```
178    pub fn localized_message_for(&self, lang: Language) -> String {
179        match lang {
180            Language::English => self.to_string(),
181            Language::Portugues => self.to_string_pt(),
182        }
183    }
184
185    fn to_string_pt(&self) -> String {
186        match self {
187            Self::Validation(msg) => format!("erro de validação: {msg}"),
188            Self::Duplicate(msg) => format!("duplicata detectada: {msg}"),
189            Self::Conflict(msg) => format!("conflito: {msg}"),
190            Self::NotFound(msg) => format!("não encontrado: {msg}"),
191            Self::NamespaceError(msg) => format!("namespace não resolvido: {msg}"),
192            Self::LimitExceeded(msg) => format!("limite excedido: {msg}"),
193            Self::Database(e) => format!("erro de banco de dados: {e}"),
194            Self::Embedding(msg) => format!("erro de embedding: {msg}"),
195            Self::VecExtension(msg) => format!("extensão sqlite-vec falhou: {msg}"),
196            Self::DbBusy(msg) => format!("banco ocupado: {msg}"),
197            Self::BatchPartialFailure { total, failed } => {
198                format!("falha parcial em batch: {failed} de {total} itens falharam")
199            }
200            Self::Io(e) => format!("erro de I/O: {e}"),
201            Self::Internal(e) => format!("erro interno: {e}"),
202            Self::Json(e) => format!("erro de JSON: {e}"),
203            Self::LockBusy(msg) => format!("lock ocupado: {msg}"),
204            Self::AllSlotsFull { max, waited_secs } => format!(
205                "todos os {max} slots de concorrência ocupados após aguardar {waited_secs}s \
206                 (exit 75); use --max-concurrency ou aguarde outras invocações terminarem"
207            ),
208            Self::LowMemory {
209                available_mb,
210                required_mb,
211            } => format!(
212                "memória disponível ({available_mb}MB) abaixo do mínimo requerido ({required_mb}MB) \
213                 para carregar o modelo; aborte outras cargas ou use --skip-memory-guard (exit 77)"
214            ),
215        }
216    }
217}
218
219#[cfg(test)]
220mod testes {
221    use super::*;
222    use std::io;
223
224    #[test]
225    fn exit_code_validation_retorna_1() {
226        assert_eq!(AppError::Validation("campo inválido".into()).exit_code(), 1);
227    }
228
229    #[test]
230    fn exit_code_duplicate_retorna_2() {
231        assert_eq!(AppError::Duplicate("namespace/nome".into()).exit_code(), 2);
232    }
233
234    #[test]
235    fn exit_code_conflict_retorna_3() {
236        assert_eq!(AppError::Conflict("updated_at mudou".into()).exit_code(), 3);
237    }
238
239    #[test]
240    fn exit_code_not_found_retorna_4() {
241        assert_eq!(AppError::NotFound("memória ausente".into()).exit_code(), 4);
242    }
243
244    #[test]
245    fn exit_code_namespace_error_retorna_5() {
246        assert_eq!(
247            AppError::NamespaceError("não resolvido".into()).exit_code(),
248            5
249        );
250    }
251
252    #[test]
253    fn exit_code_limit_exceeded_retorna_6() {
254        assert_eq!(
255            AppError::LimitExceeded("corpo muito grande".into()).exit_code(),
256            6
257        );
258    }
259
260    #[test]
261    fn exit_code_embedding_retorna_11() {
262        assert_eq!(
263            AppError::Embedding("falha de modelo".into()).exit_code(),
264            11
265        );
266    }
267
268    #[test]
269    fn exit_code_vec_extension_retorna_12() {
270        assert_eq!(
271            AppError::VecExtension("extensão não carregou".into()).exit_code(),
272            12
273        );
274    }
275
276    #[test]
277    fn exit_code_db_busy_retorna_15() {
278        assert_eq!(AppError::DbBusy("esgotou retries".into()).exit_code(), 15);
279    }
280
281    #[test]
282    fn exit_code_batch_partial_failure_retorna_13() {
283        assert_eq!(
284            AppError::BatchPartialFailure {
285                total: 10,
286                failed: 3
287            }
288            .exit_code(),
289            13
290        );
291    }
292
293    #[test]
294    fn display_batch_partial_failure_inclui_contagens() {
295        let err = AppError::BatchPartialFailure {
296            total: 50,
297            failed: 7,
298        };
299        let msg = err.to_string();
300        assert!(msg.contains("7"));
301        assert!(msg.contains("50"));
302        // to_string() usa o #[error] em inglês; PT está em localized_message_for
303        assert!(msg.contains("batch partial failure"));
304    }
305
306    #[test]
307    fn exit_code_io_retorna_14() {
308        let io_err = io::Error::new(io::ErrorKind::NotFound, "arquivo ausente");
309        assert_eq!(AppError::Io(io_err).exit_code(), 14);
310    }
311
312    #[test]
313    fn exit_code_internal_retorna_20() {
314        let anyhow_err = anyhow::anyhow!("erro interno inesperado");
315        assert_eq!(AppError::Internal(anyhow_err).exit_code(), 20);
316    }
317
318    #[test]
319    fn exit_code_json_retorna_20() {
320        let json_err = serde_json::from_str::<serde_json::Value>("json inválido {{").unwrap_err();
321        assert_eq!(AppError::Json(json_err).exit_code(), 20);
322    }
323
324    #[test]
325    fn exit_code_lock_busy_retorna_75() {
326        assert_eq!(
327            AppError::LockBusy("outra instância ativa".into()).exit_code(),
328            75
329        );
330    }
331
332    #[test]
333    fn display_validation_inclui_mensagem() {
334        let err = AppError::Validation("cpf inválido".into());
335        assert!(err.to_string().contains("cpf inválido"));
336        assert!(err.to_string().contains("validation error"));
337    }
338
339    #[test]
340    fn display_duplicate_inclui_mensagem() {
341        let err = AppError::Duplicate("proj/mem".into());
342        assert!(err.to_string().contains("proj/mem"));
343        assert!(err.to_string().contains("duplicate detected"));
344    }
345
346    #[test]
347    fn display_not_found_inclui_mensagem() {
348        let err = AppError::NotFound("id 42".into());
349        assert!(err.to_string().contains("id 42"));
350        assert!(err.to_string().contains("not found"));
351    }
352
353    #[test]
354    fn display_embedding_inclui_mensagem() {
355        let err = AppError::Embedding("dimensão errada".into());
356        assert!(err.to_string().contains("dimensão errada"));
357        assert!(err.to_string().contains("embedding error"));
358    }
359
360    #[test]
361    fn display_lock_busy_inclui_mensagem() {
362        let err = AppError::LockBusy("pid 1234".into());
363        assert!(err.to_string().contains("pid 1234"));
364        assert!(err.to_string().contains("lock busy"));
365    }
366
367    #[test]
368    fn from_io_error_converte_corretamente() {
369        let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "sem permissão");
370        let app_err: AppError = io_err.into();
371        assert_eq!(app_err.exit_code(), 14);
372        assert!(app_err.to_string().contains("IO error"));
373    }
374
375    #[test]
376    fn from_anyhow_error_converte_corretamente() {
377        let anyhow_err = anyhow::anyhow!("detalhe interno");
378        let app_err: AppError = anyhow_err.into();
379        assert_eq!(app_err.exit_code(), 20);
380        assert!(app_err.to_string().contains("internal error"));
381    }
382
383    #[test]
384    fn from_serde_json_error_converte_corretamente() {
385        let json_err = serde_json::from_str::<serde_json::Value>("{campo_ruim}").unwrap_err();
386        let app_err: AppError = json_err.into();
387        assert_eq!(app_err.exit_code(), 20);
388        assert!(app_err.to_string().contains("json error"));
389    }
390
391    #[test]
392    fn exit_code_lock_busy_bate_com_constante() {
393        assert_eq!(
394            AppError::LockBusy("test".into()).exit_code(),
395            crate::constants::CLI_LOCK_EXIT_CODE
396        );
397    }
398
399    #[test]
400    fn localized_message_en_igual_to_string() {
401        let err = AppError::NotFound("mem-x".into());
402        assert_eq!(
403            err.localized_message_for(crate::i18n::Language::English),
404            err.to_string()
405        );
406    }
407
408    #[test]
409    fn localized_message_pt_not_found_em_portugues() {
410        let err = AppError::NotFound("mem-x".into());
411        let msg = err.localized_message_for(crate::i18n::Language::Portugues);
412        assert!(msg.contains("não encontrado"), "esperado PT, obtido: {msg}");
413        assert!(msg.contains("mem-x"));
414    }
415
416    #[test]
417    fn localized_message_pt_validation_em_portugues() {
418        let err = AppError::Validation("campo x".into());
419        let msg = err.localized_message_for(crate::i18n::Language::Portugues);
420        assert!(
421            msg.contains("erro de validação"),
422            "esperado PT, obtido: {msg}"
423        );
424    }
425
426    #[test]
427    fn localized_message_pt_duplicate_em_portugues() {
428        let err = AppError::Duplicate("ns/mem".into());
429        let msg = err.localized_message_for(crate::i18n::Language::Portugues);
430        assert!(
431            msg.contains("duplicata detectada"),
432            "esperado PT, obtido: {msg}"
433        );
434    }
435
436    #[test]
437    fn localized_message_pt_conflict_em_portugues() {
438        let err = AppError::Conflict("ts mudou".into());
439        let msg = err.localized_message_for(crate::i18n::Language::Portugues);
440        assert!(msg.contains("conflito"), "esperado PT, obtido: {msg}");
441    }
442
443    #[test]
444    fn localized_message_pt_namespace_em_portugues() {
445        let err = AppError::NamespaceError("sem marcador".into());
446        let msg = err.localized_message_for(crate::i18n::Language::Portugues);
447        assert!(
448            msg.contains("namespace não resolvido"),
449            "esperado PT, obtido: {msg}"
450        );
451    }
452
453    #[test]
454    fn localized_message_pt_limit_exceeded_em_portugues() {
455        let err = AppError::LimitExceeded("corpo enorme".into());
456        let msg = err.localized_message_for(crate::i18n::Language::Portugues);
457        assert!(
458            msg.contains("limite excedido"),
459            "esperado PT, obtido: {msg}"
460        );
461    }
462
463    #[test]
464    fn localized_message_pt_embedding_em_portugues() {
465        let err = AppError::Embedding("dim errada".into());
466        let msg = err.localized_message_for(crate::i18n::Language::Portugues);
467        assert!(
468            msg.contains("erro de embedding"),
469            "esperado PT, obtido: {msg}"
470        );
471    }
472
473    #[test]
474    fn localized_message_pt_db_busy_em_portugues() {
475        let err = AppError::DbBusy("retries esgotados".into());
476        let msg = err.localized_message_for(crate::i18n::Language::Portugues);
477        assert!(msg.contains("banco ocupado"), "esperado PT, obtido: {msg}");
478    }
479
480    #[test]
481    fn localized_message_pt_batch_partial_failure_em_portugues() {
482        let err = AppError::BatchPartialFailure {
483            total: 10,
484            failed: 3,
485        };
486        let msg = err.localized_message_for(crate::i18n::Language::Portugues);
487        assert!(msg.contains("falha parcial"), "esperado PT, obtido: {msg}");
488        assert!(msg.contains("3"));
489        assert!(msg.contains("10"));
490    }
491
492    #[test]
493    fn localized_message_pt_lock_busy_em_portugues() {
494        let err = AppError::LockBusy("pid 42".into());
495        let msg = err.localized_message_for(crate::i18n::Language::Portugues);
496        assert!(msg.contains("lock ocupado"), "esperado PT, obtido: {msg}");
497    }
498
499    #[test]
500    fn localized_message_pt_all_slots_full_em_portugues() {
501        let err = AppError::AllSlotsFull {
502            max: 4,
503            waited_secs: 60,
504        };
505        let msg = err.localized_message_for(crate::i18n::Language::Portugues);
506        assert!(
507            msg.contains("slots de concorrência"),
508            "esperado PT, obtido: {msg}"
509        );
510    }
511
512    #[test]
513    fn localized_message_pt_low_memory_em_portugues() {
514        let err = AppError::LowMemory {
515            available_mb: 100,
516            required_mb: 500,
517        };
518        let msg = err.localized_message_for(crate::i18n::Language::Portugues);
519        assert!(
520            msg.contains("memória disponível"),
521            "esperado PT, obtido: {msg}"
522        );
523    }
524}