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 crate::spawn::preflight::PreFlightError;
10use thiserror::Error;
11
12/// Unified error type for all CLI and library operations.
13///
14/// Each variant corresponds to a distinct failure category. The
15/// [`AppError::exit_code`] method converts a variant into a stable numeric
16/// code so that shell callers and LLM agents can route on it.
17///
18/// # SemVer Policy
19///
20/// This enum is `#[non_exhaustive]`. New variants may be added in minor
21/// releases without breaking downstream match arms (use a wildcard `_`).
22#[derive(Error, Debug)]
23#[non_exhaustive]
24pub enum AppError {
25    /// Input failed schema, length or format validation. Maps to exit code `1`.
26    ///
27    /// This variant groups multiple validation failure causes. Callers that need
28    /// programmatic retry decisions should use [`AppError::is_retryable`] instead
29    /// of parsing the message string.
30    #[error("validation error: {0}")]
31    Validation(String),
32
33    /// External binary required for operation was not found in PATH. Maps to exit code `1`.
34    #[error("binary not found: {name} — ensure it is installed and in PATH")]
35    BinaryNotFound { name: String },
36
37    /// Remote service signaled rate limiting; caller should retry with backoff. Maps to exit code `1`.
38    #[error("rate limited: {detail}")]
39    RateLimited { detail: String },
40
41    /// Operation exceeded its time budget. Maps to exit code `1`.
42    #[error("timeout after {duration_secs}s: {operation}")]
43    Timeout {
44        operation: String,
45        duration_secs: u64,
46    },
47
48    /// A memory or entity with the same `(namespace, name)` already exists. Maps to exit code `9`.
49    #[error("duplicate detected: {0}")]
50    Duplicate(String),
51
52    /// Optimistic update lost the race because `updated_at` changed. Maps to exit code `3`.
53    #[error("conflict: {0}")]
54    Conflict(String),
55
56    /// The requested record does not exist or was soft-deleted. Maps to exit code `4`.
57    #[error("not found: {0}")]
58    NotFound(String),
59
60    /// Memory lookup by `(namespace, name)` returned no row. Maps to exit code `4`.
61    ///
62    /// G55 S2 (v1.0.80): structural variant that carries the requested identifier
63    /// and namespace, eliminating the "not found: unknown in namespace 'X'" class
64    /// of bugs that masked which lookup target failed. The display format matches
65    /// the legacy string-based `NotFound` so the i18n replace-chain and external
66    /// scripts that pattern-match on `memory not found: name='N' in namespace 'NS'`
67    /// keep working.
68    #[error("memory not found: name='{name}' in namespace '{namespace}'")]
69    MemoryNotFound { name: String, namespace: String },
70
71    /// Memory lookup by integer `id` returned no row. Maps to exit code `4`.
72    #[error("memory not found: id={id}")]
73    MemoryNotFoundById { id: i64 },
74
75    /// GAP-SG-78: an entity referenced by a queued enrich item does not yet
76    /// exist in `entities`. Maps to exit code `4`.
77    ///
78    /// # Cause
79    ///
80    /// Distinct from the terminal [`Self::NotFound`] / [`Self::MemoryNotFound`]
81    /// cases (a memory that was deleted or renamed, permanently gone). An
82    /// entity can be referenced by a queue row BEFORE it is materialized: a
83    /// later enrich pass creates the entity, so its absence now is TRANSITORY,
84    /// not terminal. Collapsing both into a single `NotFound` string sent every
85    /// such item to the dead-letter on the first failure.
86    ///
87    /// # When it occurs
88    ///
89    /// Raised by the entity call-sites of `enrich` — `entity-descriptions`
90    /// (`call_entity_description`) and `entity-type-validate`
91    /// (`call_entity_type_validate`) — when the `(namespace, name)` lookup
92    /// returns no row. Classified as [`Self::is_retryable`] so the item is
93    /// rescheduled until `--max-attempts` is exhausted.
94    #[error("entity '{name}' not yet materialized in namespace '{namespace}'")]
95    EntityNotYetMaterialized { name: String, namespace: String },
96
97    /// Namespace could not be resolved from flag, environment or markers. Maps to exit code `5`.
98    #[error("namespace not resolved: {0}")]
99    NamespaceError(String),
100
101    /// Payload exceeded one of the configured body, name or batch limits. Maps to exit code `6`.
102    ///
103    /// v1.1.1 (P11): kept for caps other than the body-bytes and chunk-count
104    /// ceilings, which now have the typed [`Self::BodyTooLarge`] and
105    /// [`Self::TooManyChunks`] variants so the operator can tell WHICH cap
106    /// fired without parsing the message.
107    #[error("limit exceeded: {0}")]
108    LimitExceeded(String),
109
110    /// Body payload exceeded [`crate::constants::MAX_MEMORY_BODY_LEN`] bytes.
111    /// Maps to exit code `6` (same contract as [`Self::LimitExceeded`]).
112    ///
113    /// v1.1.1 (P11): the two independent write ceilings — body bytes and chunk
114    /// count — used to collapse into the generic `LimitExceeded` string, so an
115    /// operator hitting exit 6 could not tell WHICH cap fired. This variant
116    /// carries the measured size and the cap, and the message names the
117    /// constant, so both the stderr line and the JSON envelope identify the
118    /// ceiling deterministically (never by substring matching).
119    #[error(
120        "limit exceeded: body is {bytes} bytes, above the {limit}-byte cap \
121         (MAX_MEMORY_BODY_LEN); split the content into multiple memories"
122    )]
123    BodyTooLarge { bytes: u64, limit: u64 },
124
125    /// Chunking produced more chunks than
126    /// [`crate::constants::REMEMBER_MAX_SAFE_MULTI_CHUNKS`]. Maps to exit
127    /// code `6` (same contract as [`Self::LimitExceeded`]).
128    ///
129    /// v1.1.1 (P11): counterpart of [`Self::BodyTooLarge`] for the chunk-count
130    /// ceiling. Carries the measured chunk count and the cap so the operator
131    /// can distinguish a chunk overflow from a byte overflow on exit 6.
132    #[error(
133        "limit exceeded: document produces {chunks} chunks, above the \
134         {limit}-chunk cap (REMEMBER_MAX_SAFE_MULTI_CHUNKS); split the \
135         document before writing"
136    )]
137    TooManyChunks { chunks: usize, limit: usize },
138
139    /// Body exceeded [`crate::constants::EMBEDDING_REQUEST_MAX_TOKENS`] tokens
140    /// (conservative cl100k proxy for the `qwen/qwen3-embedding-8b` window).
141    /// Maps to exit code `6` (same contract as [`Self::LimitExceeded`]).
142    ///
143    /// v1.1.2 (Gap 2): third typed payload ceiling alongside
144    /// [`Self::BodyTooLarge`] (bytes) and [`Self::TooManyChunks`] (chunks).
145    /// The token cap used to surface as a generic `Validation` (exit 1) deep
146    /// inside the REST embedding client; it now fires at the write-command
147    /// boundary with the estimated token count and the cap, so the operator
148    /// can tell WHICH ceiling fired without substring matching (GAP-SG-73).
149    #[error(
150        "limit exceeded: body is {tokens} tokens (estimated), above the \
151         {limit}-token cap (EMBEDDING_REQUEST_MAX_TOKENS); split the content \
152         into multiple memories"
153    )]
154    TooManyTokens { tokens: u64, limit: u64 },
155
156    /// Low-level SQLite error propagated from `rusqlite`. Maps to exit code `10`.
157    #[error("database error: {0}")]
158    Database(#[from] rusqlite::Error),
159
160    /// Embedding generation via `fastembed` failed or produced the wrong shape. Maps to exit code `11`.
161    #[error("embedding error: {0}")]
162    Embedding(String),
163
164    /// The `sqlite-vec` extension could not load or register its virtual table. Maps to exit code `12`.
165    #[error("sqlite-vec extension failed: {0}")]
166    VecExtension(String),
167
168    /// SQLite returned `SQLITE_BUSY` after exhausting retries. Maps to exit code `15` (was `13` before v2.0.0; relocated to free `13` for BatchPartialFailure per PRD).
169    #[error("database busy: {0}")]
170    DbBusy(String),
171
172    /// Batch operation failed partially — N of M items failed. Maps to exit code `13` (PRD 1822).
173    ///
174    /// Reserved for use in `import`, `reindex` and batch stdin (BLOCK 3/4). Variant present
175    /// since v2.0.0 even if call-sites do not yet exist — stable exit code mapping.
176    #[error("batch partial failure: {failed} of {total} items failed")]
177    BatchPartialFailure { total: usize, failed: usize },
178
179    /// Filesystem I/O error while reading or writing the database or cache. Maps to exit code `14`.
180    #[error("IO error: {0}")]
181    Io(#[from] std::io::Error),
182
183    /// Unexpected internal error surfaced through `anyhow`. Maps to exit code `20`.
184    #[error(transparent)]
185    Internal(#[from] anyhow::Error),
186
187    /// JSON serialization or deserialization failure. Maps to exit code `20`.
188    #[error("json error: {0}")]
189    Json(#[from] serde_json::Error),
190
191    /// Another instance is already running and holds the advisory lock. Maps to exit code `75`.
192    ///
193    /// Use `--allow-parallel` to skip the lock or `--wait-lock SECONDS` to retry.
194    #[error("lock busy: {0}")]
195    LockBusy(String),
196
197    /// All concurrency slots are occupied after the wait timeout. Maps to exit code `75`.
198    ///
199    /// Occurs when [`crate::constants::MAX_CONCURRENT_CLI_INSTANCES`] instances are already
200    /// active and the wait limit [`crate::constants::CLI_LOCK_DEFAULT_WAIT_SECS`] is exhausted.
201    #[error(
202        "all {max} concurrency slots occupied after waiting {waited_secs}s (exit 75); \
203         use --max-concurrency or wait for other invocations to finish"
204    )]
205    AllSlotsFull { max: usize, waited_secs: u64 },
206
207    /// A heavy long-running job is already running for this job_type/namespace
208    /// pair. Maps to exit code `75` (the same `EX_TEMPFAIL` code used by the
209    /// CLI semaphore).
210    ///
211    /// G28-B (v1.0.68): ensures at most one `enrich`, `ingest --mode
212    /// claude-code`, or `ingest --mode codex` runs at a time per namespace.
213    /// Use `--wait-job-singleton <SECONDS>` (per-command) to poll until the
214    /// other invocation finishes.
215    #[error(
216        "job {job_type} for namespace '{namespace}' is already running (exit 75); \
217         wait for it to finish or pass --wait-job-singleton <SECONDS>"
218    )]
219    JobSingletonLocked { job_type: String, namespace: String },
220
221    /// G45: an LLM embedding operation is already running against the
222    /// same `(namespace, db)` pair in another process. Exit code 75
223    /// (retryable). The caller can pass `--wait-embed-singleton
224    /// <SECONDS>` to poll until the lock drops.
225    #[error(
226        "embedding singleton for namespace '{namespace}' is already held (exit 75); \
227         another CLI is calling the LLM on this database; pass --wait-embed-singleton <SECONDS> to wait"
228    )]
229    EmbeddingSingletonLocked { namespace: String },
230
231    /// Available memory is below the minimum required to load the model. Maps to exit code `77`.
232    ///
233    /// Returned when `sysinfo` reports available memory below
234    /// [`crate::constants::MIN_AVAILABLE_MEMORY_MB`] MiB before starting the ONNX model load.
235    #[error(
236        "available memory ({available_mb}MB) below required minimum ({required_mb}MB) \
237         to load the model; abort other loads or use --skip-memory-guard (exit 77)"
238    )]
239    LowMemory { available_mb: u64, required_mb: u64 },
240
241    /// v1.0.82 (GAP-002 final): shutdown was requested via SIGINT, SIGTERM or
242    /// SIGHUP before the current command completed. Maps to exit code
243    /// [`crate::constants::SHUTDOWN_EXIT_CODE`] (19).
244    ///
245    /// The signal name is preserved in the `signal` field so the JSON
246    /// envelope emitted before exit can route the operator to a
247    /// deterministic branch. Distinct from the legacy `128 + signal`
248    /// Unix convention (130/143/129) so LLM agents can match on a
249    /// single code for "cancelled by user".
250    #[error("shutdown signal received: {signal}")]
251    Shutdown { signal: String },
252
253    /// v1.0.87 (GAP-META-005, ADR-0045): pre-flight validation gate
254    /// rejected the spawn before fork. Maps to exit code `16`.
255    ///
256    /// The `source` field carries the structured [`PreFlightError`]
257    /// variant so callers and operators can route on the specific
258    /// failure class (BinaryNotFound, ArgvExceedsArgMax,
259    /// McpConfigInlineJsonRejected, McpConfigPathMissing,
260    /// McpConfigPathInvalidJson, WalkUpMcpJsonInvalid,
261    /// OutputBufferTooSmall, ClaudeConfigDirNotEmpty) instead of
262    /// parsing the legacy `detail: String` representation.
263    ///
264    /// This variant is **permanent** — retrying the same argv will fail
265    /// identically. Operators must fix the underlying condition (install
266    /// the binary, shorten the body, override `CLAUDE_CONFIG_DIR`,
267    /// substitute the inline `--mcp-config '{}'` for a tempfile path,
268    /// etc.) before retrying.
269    #[error("preflight validation failed: {source}")]
270    PreFlightFailed { source: Box<PreFlightError> },
271
272    /// v1.0.97 (GAP-SG-01/03): the OpenRouter provider returned a structured
273    /// error object (an `error` field carrying `code` and `message`), often
274    /// inside an HTTP 200 body (e.g. token/context-length overflow). Maps to
275    /// exit code `1`.
276    ///
277    /// Modelling the provider rejection as a typed variant — instead of the
278    /// generic `Embedding`/`Validation` string — stops the optimistic success
279    /// parse from masking the cause with a misleading missing-field error. The
280    /// `code` and `message` carry the REAL provider diagnostics.
281    ///
282    /// This variant is **permanent**: a structured provider error in a success
283    /// body is a content or configuration rejection that retrying the identical
284    /// request will not fix. Genuine rate limiting surfaces as HTTP 429 and is
285    /// retried inside the HTTP client (then exposed via `RateLimited` when
286    /// attempts are exhausted), so it never reaches callers as `ProviderError`.
287    #[error("provider error (code {code}): {message}")]
288    ProviderError { code: String, message: String },
289}
290
291/// Bridges the structured [`PreFlightError`] produced by the
292/// pre-flight validation gate (v1.0.87, ADR-0045) into the unified
293/// [`AppError`] envelope. Lets spawners use the `?` operator instead
294/// of hand-rolling `AppError::PreFlightFailed { source: ... }` at every
295/// call site, and keeps the variant alive as the canonical exit code 16
296/// path rather than the dead code it was at v1.0.87.
297impl From<PreFlightError> for AppError {
298    fn from(source: PreFlightError) -> Self {
299        AppError::PreFlightFailed {
300            source: Box::new(source),
301        }
302    }
303}
304
305impl AppError {
306    /// Returns the deterministic process exit code for this error variant.
307    ///
308    /// The codes follow the contract documented in the README: `1` for
309    /// validation, `9` for duplicates (moved from `2` in v1.0.52), `3` for conflicts, `4` for missing
310    /// records, `5` for namespace errors, `6` for limit violations, `10`–`14`
311    /// for infrastructure failures, `13` for BatchPartialFailure (PRD 1822),
312    /// `15` for DbBusy (migrated from `13` in v2.0.0), `20` for internal errors,
313    /// `75` (EX_TEMPFAIL) when the advisory CLI lock is held or all concurrency
314    /// slots are exhausted, and `77` when available memory is insufficient to
315    /// load the embedding model.
316    ///
317    /// # Examples
318    ///
319    /// ```
320    /// use sqlite_graphrag::errors::AppError;
321    ///
322    /// assert_eq!(AppError::Validation("invalid field".into()).exit_code(), 1);
323    /// assert_eq!(AppError::Duplicate("ns/mem".into()).exit_code(), 9);
324    /// assert_eq!(AppError::Conflict("ts changed".into()).exit_code(), 3);
325    /// assert_eq!(AppError::NotFound("id 42".into()).exit_code(), 4);
326    /// assert_eq!(AppError::NamespaceError("no marker".into()).exit_code(), 5);
327    /// assert_eq!(AppError::LimitExceeded("body too large".into()).exit_code(), 6);
328    /// assert_eq!(AppError::Embedding("wrong dim".into()).exit_code(), 11);
329    /// assert_eq!(AppError::DbBusy("retries exhausted".into()).exit_code(), 15);
330    /// assert_eq!(AppError::LockBusy("another instance".into()).exit_code(), 75);
331    /// ```
332    #[inline]
333    #[must_use]
334    pub fn exit_code(&self) -> i32 {
335        match self {
336            Self::Validation(_) => 1,
337            Self::BinaryNotFound { .. } => 1,
338            Self::RateLimited { .. } => 1,
339            Self::Timeout { .. } => 1,
340            Self::Duplicate(_) => crate::constants::DUPLICATE_EXIT_CODE,
341            Self::Conflict(_) => 3,
342            Self::NotFound(_) => 4,
343            Self::MemoryNotFound { .. } => 4,
344            Self::MemoryNotFoundById { .. } => 4,
345            Self::EntityNotYetMaterialized { .. } => 4,
346            Self::NamespaceError(_) => 5,
347            Self::LimitExceeded(_) => 6,
348            Self::BodyTooLarge { .. } => 6,
349            Self::TooManyChunks { .. } => 6,
350            Self::TooManyTokens { .. } => 6,
351            Self::Database(_) => 10,
352            Self::Embedding(_) => 11,
353            Self::VecExtension(_) => 12,
354            Self::BatchPartialFailure { .. } => crate::constants::BATCH_PARTIAL_FAILURE_EXIT_CODE,
355            Self::DbBusy(_) => crate::constants::DB_BUSY_EXIT_CODE,
356            Self::Io(_) => 14,
357            Self::Internal(_) => 20,
358            Self::Json(_) => 20,
359            Self::LockBusy(_) => crate::constants::CLI_LOCK_EXIT_CODE,
360            Self::AllSlotsFull { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
361            Self::JobSingletonLocked { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
362            Self::EmbeddingSingletonLocked { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
363            Self::LowMemory { .. } => crate::constants::LOW_MEMORY_EXIT_CODE,
364            Self::Shutdown { .. } => crate::constants::SHUTDOWN_EXIT_CODE,
365            Self::PreFlightFailed { .. } => 16,
366            Self::ProviderError { .. } => 1,
367        }
368    }
369
370    /// Returns `true` when the error is transient and the operation may
371    /// succeed on retry with backoff.
372    ///
373    /// # Examples
374    ///
375    /// ```
376    /// use sqlite_graphrag::errors::AppError;
377    ///
378    /// assert!(AppError::DbBusy("busy".into()).is_retryable());
379    /// assert!(AppError::LockBusy("held".into()).is_retryable());
380    /// assert!(!AppError::NotFound("x".into()).is_retryable());
381    /// assert!(!AppError::Validation("bad".into()).is_retryable());
382    /// ```
383    #[inline]
384    #[must_use]
385    pub fn is_retryable(&self) -> bool {
386        matches!(
387            self,
388            Self::DbBusy(_)
389                | Self::LockBusy(_)
390                | Self::AllSlotsFull { .. }
391                | Self::JobSingletonLocked { .. }
392                | Self::EmbeddingSingletonLocked { .. }
393                | Self::LowMemory { .. }
394                | Self::RateLimited { .. }
395                | Self::Timeout { .. }
396                | Self::EntityNotYetMaterialized { .. }
397        )
398    }
399
400    /// Returns `true` when shutdown was requested by the user via signal.
401    ///
402    /// Distinct from `is_permanent` because shutdown is a USER intent, not
403    /// a state to retry against. The operation should be retried with
404    /// `--resume` (GAP-001) when the persisted staging row still exists.
405    ///
406    /// # Examples
407    ///
408    /// ```
409    /// use sqlite_graphrag::errors::AppError;
410    ///
411    /// assert!(AppError::Shutdown { signal: "SIGINT".into() }.is_shutdown());
412    /// assert!(!AppError::Validation("x".into()).is_shutdown());
413    /// ```
414    #[inline]
415    #[must_use]
416    pub fn is_shutdown(&self) -> bool {
417        matches!(self, Self::Shutdown { .. })
418    }
419
420    /// Returns `true` when the error is permanent and must NOT be retried.
421    ///
422    /// Complement to [`Self::is_retryable`]. Errors not classified by either
423    /// method (e.g. `Database`, `Io`, `Internal`) are ambiguous — the caller
424    /// decides based on context.
425    ///
426    /// # Examples
427    ///
428    /// ```
429    /// use sqlite_graphrag::errors::AppError;
430    ///
431    /// assert!(AppError::Validation("bad".into()).is_permanent());
432    /// assert!(!AppError::DbBusy("busy".into()).is_permanent());
433    /// ```
434    #[inline]
435    #[must_use]
436    pub fn is_permanent(&self) -> bool {
437        matches!(
438            self,
439            Self::Validation(_)
440                | Self::BinaryNotFound { .. }
441                | Self::Duplicate(_)
442                | Self::NotFound(_)
443                | Self::MemoryNotFound { .. }
444                | Self::MemoryNotFoundById { .. }
445                | Self::NamespaceError(_)
446                | Self::LimitExceeded(_)
447                | Self::BodyTooLarge { .. }
448                | Self::TooManyChunks { .. }
449                | Self::TooManyTokens { .. }
450                | Self::VecExtension(_)
451                | Self::PreFlightFailed { .. }
452                | Self::ProviderError { .. }
453        )
454    }
455
456    /// GAP-SG-39: returns an actionable remediation hint for the error, surfaced
457    /// in the stdout error envelope as the `suggestion` field. The hint tells the
458    /// operator HOW to recover instead of leaving an exit code without guidance —
459    /// this is what makes a write rejection (e.g. a malformed name) observable and
460    /// fixable. Returns `None` for variants whose own message is already
461    /// self-remediating.
462    #[must_use]
463    pub fn suggestion(&self) -> Option<&'static str> {
464        match self {
465            Self::Validation(_) => Some(
466                "review the input against the command's --help; names must be kebab-case (lowercase letters, digits, hyphens) and bodies non-empty",
467            ),
468            Self::Duplicate(_) => {
469                Some("pass --force-merge to update the existing memory instead of failing")
470            }
471            Self::Conflict(_) => Some(
472                "another writer changed the row; re-read with `read --name <n> --json` and retry with a fresh --expected-updated-at",
473            ),
474            Self::NotFound(_) | Self::MemoryNotFound { .. } | Self::MemoryNotFoundById { .. } => {
475                Some("verify the name/id and namespace with `list --json` or `read --name <n> --json`")
476            }
477            Self::NamespaceError(_) => {
478                Some("set --namespace or SQLITE_GRAPHRAG_NAMESPACE; inspect with `namespace-detect --json`")
479            }
480            Self::LimitExceeded(_) => {
481                Some("split the input into smaller memories or raise the documented cap before retrying")
482            }
483            Self::BodyTooLarge { .. } => {
484                Some("the body-bytes cap (MAX_MEMORY_BODY_LEN) fired; split the content into multiple memories or use --body-file")
485            }
486            Self::TooManyChunks { .. } => {
487                Some("the chunk-count cap (REMEMBER_MAX_SAFE_MULTI_CHUNKS) fired; split the document into smaller memories before writing")
488            }
489            Self::TooManyTokens { .. } => {
490                Some("the token cap (EMBEDDING_REQUEST_MAX_TOKENS) fired; split the content into multiple memories, keeping each under ~25000 tokens")
491            }
492            Self::Embedding(_) => Some(
493                "verify the embedding backend and OPENROUTER_API_KEY; re-run `enrich --operation re-embed` once resolved",
494            ),
495            Self::Database(_) | Self::DbBusy(_) => {
496                Some("run `health --json` then `vacuum --json`; widen --wait-lock if the database is busy")
497            }
498            Self::Io(_) => Some("check the path exists and is writable, then retry"),
499            Self::RateLimited { .. } => {
500                Some("wait for the reported retry-after window, then retry")
501            }
502            Self::LockBusy(_) | Self::AllSlotsFull { .. } | Self::JobSingletonLocked { .. } => {
503                Some("wait for the other invocation to finish or pass --wait-lock / --wait-job-singleton")
504            }
505            _ => None,
506        }
507    }
508
509    /// Returns the localized error message in the active language (`--lang` / `SQLITE_GRAPHRAG_LANG`).
510    ///
511    /// In English the text is identical to the `Display` generated by thiserror.
512    /// In Portuguese the prefixes and messages are translated to PT-BR.
513    pub fn localized_message(&self) -> String {
514        self.localized_message_for(current())
515    }
516
517    /// Returns the localized message for the explicitly provided language.
518    /// Useful in tests that cannot depend on the global `OnceLock`.
519    ///
520    /// # Examples
521    ///
522    /// ```
523    /// use sqlite_graphrag::errors::AppError;
524    /// use sqlite_graphrag::i18n::Language;
525    ///
526    /// let err = AppError::NotFound("mem-xyz".into());
527    ///
528    /// let en = err.localized_message_for(Language::English);
529    /// assert!(en.contains("not found"));
530    ///
531    /// let pt = err.localized_message_for(Language::Portuguese);
532    /// assert!(pt.contains("n\u{e3}o encontrado"));
533    /// ```
534    pub fn localized_message_for(&self, lang: Language) -> String {
535        match lang {
536            Language::English => self.to_string(),
537            Language::Portuguese => self.to_string_pt(),
538        }
539    }
540
541    fn to_string_pt(&self) -> String {
542        use crate::i18n::validation::app_error_pt as pt;
543        match self {
544            Self::Validation(msg) => pt::validation(msg),
545            Self::BinaryNotFound { name } => pt::binary_not_found(name),
546            Self::RateLimited { detail } => pt::rate_limited(detail),
547            Self::Timeout {
548                operation,
549                duration_secs,
550            } => pt::timeout(operation, *duration_secs),
551            Self::Duplicate(msg) => pt::duplicate(msg),
552            Self::Conflict(msg) => pt::conflict(msg),
553            Self::NotFound(msg) => pt::not_found(msg),
554            Self::MemoryNotFound { name, namespace } => pt::memory_not_found(name, namespace),
555            Self::MemoryNotFoundById { id } => pt::memory_not_found_by_id(*id),
556            Self::EntityNotYetMaterialized { name, namespace } => {
557                pt::entity_not_yet_materialized(name, namespace)
558            }
559            Self::NamespaceError(msg) => pt::namespace_error(msg),
560            Self::LimitExceeded(msg) => pt::limit_exceeded(msg),
561            Self::BodyTooLarge { bytes, limit } => pt::body_too_large(*bytes, *limit),
562            Self::TooManyChunks { chunks, limit } => pt::too_many_chunks(*chunks, *limit),
563            Self::TooManyTokens { tokens, limit } => pt::too_many_tokens(*tokens, *limit),
564            Self::Database(e) => pt::database(&e.to_string()),
565            Self::Embedding(msg) => pt::embedding(msg),
566            Self::VecExtension(msg) => pt::vec_extension(msg),
567            Self::DbBusy(msg) => pt::db_busy(msg),
568            Self::BatchPartialFailure { total, failed } => {
569                pt::batch_partial_failure(*total, *failed)
570            }
571            Self::Io(e) => pt::io(&e.to_string()),
572            Self::Internal(e) => pt::internal(&e.to_string()),
573            Self::Json(e) => pt::json(&e.to_string()),
574            Self::LockBusy(msg) => pt::lock_busy(msg),
575            Self::AllSlotsFull { max, waited_secs } => pt::all_slots_full(*max, *waited_secs),
576            Self::JobSingletonLocked {
577                job_type,
578                namespace,
579            } => pt::job_singleton_locked(job_type, namespace),
580            Self::EmbeddingSingletonLocked { namespace } => {
581                pt::embedding_singleton_locked(namespace)
582            }
583            Self::LowMemory {
584                available_mb,
585                required_mb,
586            } => pt::low_memory(*available_mb, *required_mb),
587            Self::Shutdown { signal } => pt::shutdown(signal),
588            Self::PreFlightFailed { source } => pt::preflight_failed(&source.to_string()),
589            Self::ProviderError { code, message } => pt::provider_error(code, message),
590        }
591    }
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597    use std::io;
598
599    #[test]
600    fn exit_code_validation_returns_1() {
601        assert_eq!(AppError::Validation("invalid field".into()).exit_code(), 1);
602    }
603
604    // GAP-SG-39: actionable errors carry a remediation suggestion.
605    #[test]
606    fn suggestion_present_for_actionable_variants() {
607        assert!(AppError::Validation("bad name".into())
608            .suggestion()
609            .is_some());
610        let dup = AppError::Duplicate("global/x".into());
611        assert!(dup.suggestion().unwrap().contains("--force-merge"));
612        let nf = AppError::MemoryNotFound {
613            name: "x".into(),
614            namespace: "global".into(),
615        };
616        assert!(nf.suggestion().is_some());
617    }
618
619    #[test]
620    fn exit_code_duplicate_returns_9() {
621        assert_eq!(AppError::Duplicate("namespace/name".into()).exit_code(), 9);
622    }
623
624    #[test]
625    fn exit_code_conflict_returns_3() {
626        assert_eq!(
627            AppError::Conflict("updated_at changed".into()).exit_code(),
628            3
629        );
630    }
631
632    #[test]
633    fn exit_code_not_found_returns_4() {
634        assert_eq!(AppError::NotFound("memory missing".into()).exit_code(), 4);
635    }
636
637    #[test]
638    fn exit_code_namespace_error_returns_5() {
639        assert_eq!(
640            AppError::NamespaceError("not resolved".into()).exit_code(),
641            5
642        );
643    }
644
645    #[test]
646    fn exit_code_limit_exceeded_returns_6() {
647        assert_eq!(
648            AppError::LimitExceeded("body too large".into()).exit_code(),
649            6
650        );
651    }
652
653    // v1.1.1 (P11): both typed ceiling variants keep the exit 6 contract.
654    #[test]
655    fn exit_code_body_too_large_and_too_many_chunks_return_6() {
656        assert_eq!(
657            AppError::BodyTooLarge {
658                bytes: 600_000,
659                limit: 512_000
660            }
661            .exit_code(),
662            6
663        );
664        assert_eq!(
665            AppError::TooManyChunks {
666                chunks: 700,
667                limit: 512
668            }
669            .exit_code(),
670            6
671        );
672        assert_eq!(
673            AppError::TooManyTokens {
674                tokens: 35_000,
675                limit: 30_000
676            }
677            .exit_code(),
678            6
679        );
680    }
681
682    // v1.1.2 (Gap 2): the token-cap message carries the estimated count and
683    // the limit, naming the constant — the third ceiling is distinguishable
684    // from bytes and chunks on exit 6 without substring classification.
685    #[test]
686    fn too_many_tokens_message_identifies_token_cap() {
687        let err = AppError::TooManyTokens {
688            tokens: 35_000,
689            limit: 30_000,
690        };
691        let msg = err.to_string();
692        assert!(msg.contains("limit exceeded"), "obtido: {msg}");
693        assert!(msg.contains("35000 tokens"), "obtido: {msg}");
694        assert!(msg.contains("30000-token cap"), "obtido: {msg}");
695        assert!(
696            msg.contains("EMBEDDING_REQUEST_MAX_TOKENS"),
697            "obtido: {msg}"
698        );
699        assert!(!msg.contains("byte cap"), "obtido: {msg}");
700        assert!(!msg.contains("chunk"), "obtido: {msg}");
701    }
702
703    // v1.1.1 (P11): the message identifies WHICH cap fired, with the measured
704    // value and the limit — bytes vs chunks are distinguishable without
705    // substring classification of a generic string.
706    #[test]
707    fn body_too_large_message_identifies_bytes_cap() {
708        let err = AppError::BodyTooLarge {
709            bytes: 600_000,
710            limit: 512_000,
711        };
712        let msg = err.to_string();
713        assert!(msg.contains("limit exceeded"), "obtido: {msg}");
714        assert!(msg.contains("600000 bytes"), "obtido: {msg}");
715        assert!(msg.contains("512000-byte cap"), "obtido: {msg}");
716        assert!(msg.contains("MAX_MEMORY_BODY_LEN"), "obtido: {msg}");
717        assert!(!msg.contains("chunk"), "obtido: {msg}");
718    }
719
720    #[test]
721    fn too_many_chunks_message_identifies_chunk_cap() {
722        let err = AppError::TooManyChunks {
723            chunks: 700,
724            limit: 512,
725        };
726        let msg = err.to_string();
727        assert!(msg.contains("limit exceeded"), "obtido: {msg}");
728        assert!(msg.contains("700 chunks"), "obtido: {msg}");
729        assert!(msg.contains("512-chunk cap"), "obtido: {msg}");
730        assert!(
731            msg.contains("REMEMBER_MAX_SAFE_MULTI_CHUNKS"),
732            "obtido: {msg}"
733        );
734        assert!(!msg.contains("byte cap"), "obtido: {msg}");
735    }
736
737    #[test]
738    fn typed_limit_variants_are_permanent_with_suggestion() {
739        let body = AppError::BodyTooLarge { bytes: 1, limit: 1 };
740        let chunks = AppError::TooManyChunks {
741            chunks: 1,
742            limit: 1,
743        };
744        assert!(body.is_permanent());
745        assert!(chunks.is_permanent());
746        assert!(!body.is_retryable());
747        assert!(!chunks.is_retryable());
748        assert!(body.suggestion().unwrap().contains("MAX_MEMORY_BODY_LEN"));
749        assert!(chunks
750            .suggestion()
751            .unwrap()
752            .contains("REMEMBER_MAX_SAFE_MULTI_CHUNKS"));
753        let tokens = AppError::TooManyTokens {
754            tokens: 1,
755            limit: 1,
756        };
757        assert!(tokens.is_permanent());
758        assert!(!tokens.is_retryable());
759        assert!(tokens
760            .suggestion()
761            .unwrap()
762            .contains("EMBEDDING_REQUEST_MAX_TOKENS"));
763    }
764
765    #[test]
766    fn typed_limit_variants_localize_to_pt() {
767        let body = AppError::BodyTooLarge {
768            bytes: 600_000,
769            limit: 512_000,
770        };
771        let pt = body.localized_message_for(crate::i18n::Language::Portuguese);
772        assert!(pt.contains("limite excedido"), "obtido: {pt}");
773        assert!(pt.contains("600000"), "obtido: {pt}");
774        let chunks = AppError::TooManyChunks {
775            chunks: 700,
776            limit: 512,
777        };
778        let pt = chunks.localized_message_for(crate::i18n::Language::Portuguese);
779        assert!(pt.contains("limite excedido"), "obtido: {pt}");
780        assert!(pt.contains("700"), "obtido: {pt}");
781        let tokens = AppError::TooManyTokens {
782            tokens: 35_000,
783            limit: 30_000,
784        };
785        let pt = tokens.localized_message_for(crate::i18n::Language::Portuguese);
786        assert!(pt.contains("limite excedido"), "obtido: {pt}");
787        assert!(pt.contains("35000"), "obtido: {pt}");
788        assert!(pt.contains("EMBEDDING_REQUEST_MAX_TOKENS"), "obtido: {pt}");
789    }
790
791    #[test]
792    fn exit_code_embedding_returns_11() {
793        assert_eq!(AppError::Embedding("model failure".into()).exit_code(), 11);
794    }
795
796    #[test]
797    fn exit_code_vec_extension_returns_12() {
798        assert_eq!(
799            AppError::VecExtension("extension did not load".into()).exit_code(),
800            12
801        );
802    }
803
804    #[test]
805    fn exit_code_db_busy_returns_15() {
806        assert_eq!(AppError::DbBusy("retries exhausted".into()).exit_code(), 15);
807    }
808
809    #[test]
810    fn exit_code_batch_partial_failure_returns_13() {
811        assert_eq!(
812            AppError::BatchPartialFailure {
813                total: 10,
814                failed: 3
815            }
816            .exit_code(),
817            13
818        );
819    }
820
821    #[test]
822    fn display_batch_partial_failure_includes_counts() {
823        let err = AppError::BatchPartialFailure {
824            total: 50,
825            failed: 7,
826        };
827        let msg = err.to_string();
828        assert!(msg.contains("7"));
829        assert!(msg.contains("50"));
830        // to_string() uses the English #[error] attr; PT is in localized_message_for
831        assert!(msg.contains("batch partial failure"));
832    }
833
834    #[test]
835    fn exit_code_io_returns_14() {
836        let io_err = io::Error::new(io::ErrorKind::NotFound, "file missing");
837        assert_eq!(AppError::Io(io_err).exit_code(), 14);
838    }
839
840    #[test]
841    fn exit_code_internal_returns_20() {
842        let anyhow_err = anyhow::anyhow!("unexpected internal error");
843        assert_eq!(AppError::Internal(anyhow_err).exit_code(), 20);
844    }
845
846    #[test]
847    fn exit_code_json_returns_20() {
848        let json_err = serde_json::from_str::<serde_json::Value>("invalid json {{").unwrap_err();
849        assert_eq!(AppError::Json(json_err).exit_code(), 20);
850    }
851
852    #[test]
853    fn exit_code_lock_busy_returns_75() {
854        assert_eq!(
855            AppError::LockBusy("another active instance".into()).exit_code(),
856            75
857        );
858    }
859
860    #[test]
861    fn display_validation_includes_message() {
862        let err = AppError::Validation("invalid id".into());
863        assert!(err.to_string().contains("invalid id"));
864        assert!(err.to_string().contains("validation error"));
865    }
866
867    #[test]
868    fn display_duplicate_includes_message() {
869        let err = AppError::Duplicate("proj/mem".into());
870        assert!(err.to_string().contains("proj/mem"));
871        assert!(err.to_string().contains("duplicate detected"));
872    }
873
874    #[test]
875    fn display_not_found_includes_message() {
876        let err = AppError::NotFound("id 42".into());
877        assert!(err.to_string().contains("id 42"));
878        assert!(err.to_string().contains("not found"));
879    }
880
881    #[test]
882    fn display_embedding_includes_message() {
883        let err = AppError::Embedding("wrong dimension".into());
884        assert!(err.to_string().contains("wrong dimension"));
885        assert!(err.to_string().contains("embedding error"));
886    }
887
888    #[test]
889    fn display_lock_busy_includes_message() {
890        let err = AppError::LockBusy("pid 1234".into());
891        assert!(err.to_string().contains("pid 1234"));
892        assert!(err.to_string().contains("lock busy"));
893    }
894
895    #[test]
896    fn from_io_error_converts_correctly() {
897        let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "permission denied");
898        let app_err: AppError = io_err.into();
899        assert_eq!(app_err.exit_code(), 14);
900        assert!(app_err.to_string().contains("IO error"));
901    }
902
903    #[test]
904    fn from_anyhow_error_converts_correctly() {
905        let anyhow_err = anyhow::anyhow!("internal detail");
906        let app_err: AppError = anyhow_err.into();
907        assert_eq!(app_err.exit_code(), 20);
908        assert!(app_err.to_string().contains("internal detail"));
909    }
910
911    #[test]
912    fn from_serde_json_error_converts_correctly() {
913        let json_err = serde_json::from_str::<serde_json::Value>("{bad_field}").unwrap_err();
914        let app_err: AppError = json_err.into();
915        assert_eq!(app_err.exit_code(), 20);
916        assert!(app_err.to_string().contains("json error"));
917    }
918
919    #[test]
920    fn exit_code_lock_busy_matches_constant() {
921        assert_eq!(
922            AppError::LockBusy("test".into()).exit_code(),
923            crate::constants::CLI_LOCK_EXIT_CODE
924        );
925    }
926
927    #[test]
928    fn localized_message_en_equals_to_string() {
929        let err = AppError::NotFound("mem-x".into());
930        assert_eq!(
931            err.localized_message_for(crate::i18n::Language::English),
932            err.to_string()
933        );
934    }
935
936    // Detailed Portuguese-specific assertions live in `src/i18n.rs`
937    // (the bilingual module). Here we only verify that delegation is wired
938    // correctly, without embedding PT strings in this English-only file.
939
940    #[test]
941    fn localized_message_pt_differs_from_en() {
942        let err = AppError::NotFound("mem-x".into());
943        let en = err.localized_message_for(crate::i18n::Language::English);
944        let pt = err.localized_message_for(crate::i18n::Language::Portuguese);
945        assert_ne!(en, pt, "PT and EN must produce distinct messages");
946        assert!(pt.contains("mem-x"), "PT must include the variant payload");
947    }
948
949    #[test]
950    fn localized_message_pt_delegates_to_app_error_pt_helper() {
951        use crate::i18n::validation::app_error_pt as pt;
952
953        let cases: Vec<(AppError, String)> = vec![
954            (AppError::Validation("x".into()), pt::validation("x")),
955            (AppError::Duplicate("x".into()), pt::duplicate("x")),
956            (AppError::Conflict("x".into()), pt::conflict("x")),
957            (AppError::NotFound("x".into()), pt::not_found("x")),
958            (
959                AppError::NamespaceError("x".into()),
960                pt::namespace_error("x"),
961            ),
962            (AppError::LimitExceeded("x".into()), pt::limit_exceeded("x")),
963            (AppError::Embedding("x".into()), pt::embedding("x")),
964            (AppError::VecExtension("x".into()), pt::vec_extension("x")),
965            (AppError::DbBusy("x".into()), pt::db_busy("x")),
966            (
967                AppError::BatchPartialFailure {
968                    total: 10,
969                    failed: 3,
970                },
971                pt::batch_partial_failure(10, 3),
972            ),
973            (AppError::LockBusy("x".into()), pt::lock_busy("x")),
974            (
975                AppError::AllSlotsFull {
976                    max: 4,
977                    waited_secs: 60,
978                },
979                pt::all_slots_full(4, 60),
980            ),
981            (
982                AppError::LowMemory {
983                    available_mb: 100,
984                    required_mb: 500,
985                },
986                pt::low_memory(100, 500),
987            ),
988            (
989                AppError::BinaryNotFound {
990                    name: "claude".into(),
991                },
992                pt::binary_not_found("claude"),
993            ),
994            (
995                AppError::RateLimited {
996                    detail: "429".into(),
997                },
998                pt::rate_limited("429"),
999            ),
1000            (
1001                AppError::Timeout {
1002                    operation: "op".into(),
1003                    duration_secs: 30,
1004                },
1005                pt::timeout("op", 30),
1006            ),
1007        ];
1008
1009        for (err, expected) in cases {
1010            let actual = err.localized_message_for(crate::i18n::Language::Portuguese);
1011            assert_eq!(actual, expected, "delegation mismatch");
1012        }
1013    }
1014
1015    #[test]
1016    fn is_retryable_transient_errors() {
1017        assert!(AppError::DbBusy("x".into()).is_retryable());
1018        assert!(AppError::LockBusy("x".into()).is_retryable());
1019        assert!(AppError::AllSlotsFull {
1020            max: 4,
1021            waited_secs: 60
1022        }
1023        .is_retryable());
1024        assert!(AppError::LowMemory {
1025            available_mb: 100,
1026            required_mb: 500
1027        }
1028        .is_retryable());
1029        assert!(AppError::RateLimited {
1030            detail: "429".into()
1031        }
1032        .is_retryable());
1033        assert!(AppError::Timeout {
1034            operation: "op".into(),
1035            duration_secs: 30
1036        }
1037        .is_retryable());
1038    }
1039
1040    #[test]
1041    fn is_retryable_permanent_errors() {
1042        assert!(!AppError::Validation("x".into()).is_retryable());
1043        assert!(!AppError::NotFound("x".into()).is_retryable());
1044        assert!(!AppError::Duplicate("x".into()).is_retryable());
1045        assert!(!AppError::Conflict("x".into()).is_retryable());
1046        assert!(!AppError::BinaryNotFound { name: "x".into() }.is_retryable());
1047    }
1048
1049    #[test]
1050    fn exit_code_new_variants() {
1051        assert_eq!(AppError::BinaryNotFound { name: "x".into() }.exit_code(), 1);
1052        assert_eq!(AppError::RateLimited { detail: "x".into() }.exit_code(), 1);
1053        assert_eq!(
1054            AppError::Timeout {
1055                operation: "x".into(),
1056                duration_secs: 5
1057            }
1058            .exit_code(),
1059            1
1060        );
1061    }
1062
1063    // GAP-SG-78: EntityNotYetMaterialized is a transitory absence (the entity is
1064    // materialized on a later enrich pass), NOT a terminal not-found.
1065    #[test]
1066    fn entity_not_yet_materialized_exit_code_is_4() {
1067        let e = AppError::EntityNotYetMaterialized {
1068            name: "acme".into(),
1069            namespace: "global".into(),
1070        };
1071        assert_eq!(e.exit_code(), 4);
1072    }
1073
1074    #[test]
1075    fn entity_not_yet_materialized_is_retryable_not_permanent() {
1076        let e = AppError::EntityNotYetMaterialized {
1077            name: "acme".into(),
1078            namespace: "global".into(),
1079        };
1080        assert!(e.is_retryable());
1081        assert!(!e.is_permanent());
1082    }
1083
1084    #[test]
1085    fn entity_not_yet_materialized_user_message_non_empty() {
1086        let e = AppError::EntityNotYetMaterialized {
1087            name: "acme".into(),
1088            namespace: "global".into(),
1089        };
1090        assert!(!e
1091            .localized_message_for(crate::i18n::Language::English)
1092            .is_empty());
1093        assert!(!e
1094            .localized_message_for(crate::i18n::Language::Portuguese)
1095            .is_empty());
1096    }
1097
1098    #[test]
1099    fn app_error_size_does_not_exceed_budget() {
1100        let size = std::mem::size_of::<AppError>();
1101        assert!(
1102            size <= 128,
1103            "AppError is {size} bytes — exceeds 128-byte budget; \
1104             consider boxing large variants to reduce memcpy cost in Result propagation"
1105        );
1106    }
1107}