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 {
36        /// Name associated with this error.
37        name: String,
38    },
39
40    /// Remote service signaled rate limiting; caller should retry with backoff. Maps to exit code `1`.
41    #[error("rate limited: {detail}")]
42    RateLimited {
43        /// Human-readable detail message.
44        detail: String,
45    },
46
47    /// Operation exceeded its time budget. Maps to exit code `1`.
48    #[error("timeout after {duration_secs}s: {operation}")]
49    Timeout {
50        /// Operation.
51        operation: String,
52        /// Duration secs.
53        duration_secs: u64,
54    },
55
56    /// A memory or entity with the same `(namespace, name)` already exists. Maps to exit code `9`.
57    #[error("duplicate detected: {0}")]
58    Duplicate(String),
59
60    /// Optimistic update lost the race because `updated_at` changed. Maps to exit code `3`.
61    #[error("conflict: {0}")]
62    Conflict(String),
63
64    /// The requested record does not exist or was soft-deleted. Maps to exit code `4`.
65    #[error("not found: {0}")]
66    NotFound(String),
67
68    /// Memory lookup by `(namespace, name)` returned no row. Maps to exit code `4`.
69    ///
70    /// G55 S2 (v1.0.80): structural variant that carries the requested identifier
71    /// and namespace, eliminating the "not found: unknown in namespace 'X'" class
72    /// of bugs that masked which lookup target failed. The display format matches
73    /// the legacy string-based `NotFound` so the i18n replace-chain and external
74    /// scripts that pattern-match on `memory not found: name='N' in namespace 'NS'`
75    /// keep working.
76    #[error("memory not found: name='{name}' in namespace '{namespace}'")]
77    MemoryNotFound {
78        /// Name associated with this error.
79        name: String,
80        /// Namespace scope.
81        namespace: String,
82    },
83
84    /// Memory lookup by integer `id` returned no row. Maps to exit code `4`.
85    #[error("memory not found: id={id}")]
86    MemoryNotFoundById {
87        /// Numeric identifier.
88        id: i64,
89    },
90
91    /// GAP-SG-78: an entity referenced by a queued enrich item does not yet
92    /// exist in `entities`. Maps to exit code `4`.
93    ///
94    /// # Cause
95    ///
96    /// Distinct from the terminal [`Self::NotFound`] / [`Self::MemoryNotFound`]
97    /// cases (a memory that was deleted or renamed, permanently gone). An
98    /// entity can be referenced by a queue row BEFORE it is materialized: a
99    /// later enrich pass creates the entity, so its absence now is TRANSITORY,
100    /// not terminal. Collapsing both into a single `NotFound` string sent every
101    /// such item to the dead-letter on the first failure.
102    ///
103    /// # When it occurs
104    ///
105    /// Raised by the entity call-sites of `enrich` — `entity-descriptions`
106    /// (`call_entity_description`) and `entity-type-validate`
107    /// (`call_entity_type_validate`) — when the `(namespace, name)` lookup
108    /// returns no row. Classified as [`Self::is_retryable`] so the item is
109    /// rescheduled until `--max-attempts` is exhausted.
110    #[error("entity '{name}' not yet materialized in namespace '{namespace}'")]
111    EntityNotYetMaterialized {
112        /// Name associated with this error.
113        name: String,
114        /// Namespace scope.
115        namespace: String,
116    },
117
118    /// Namespace could not be resolved from flag, environment or markers. Maps to exit code `5`.
119    #[error("namespace not resolved: {0}")]
120    NamespaceError(String),
121
122    /// Payload exceeded one of the configured body, name or batch limits. Maps to exit code `6`.
123    ///
124    /// v1.1.1 (P11): kept for caps other than the body-bytes and chunk-count
125    /// ceilings, which now have the typed [`Self::BodyTooLarge`] and
126    /// [`Self::TooManyChunks`] variants so the operator can tell WHICH cap
127    /// fired without parsing the message.
128    #[error("limit exceeded: {0}")]
129    LimitExceeded(String),
130
131    /// Body payload exceeded [`crate::constants::MAX_MEMORY_BODY_LEN`] bytes.
132    /// Maps to exit code `6` (same contract as [`Self::LimitExceeded`]).
133    ///
134    /// v1.1.1 (P11): the two independent write ceilings — body bytes and chunk
135    /// count — used to collapse into the generic `LimitExceeded` string, so an
136    /// operator hitting exit 6 could not tell WHICH cap fired. This variant
137    /// carries the measured size and the cap, and the message names the
138    /// constant, so both the stderr line and the JSON envelope identify the
139    /// ceiling deterministically (never by substring matching).
140    #[error(
141        "limit exceeded: body is {bytes} bytes, above the {limit}-byte cap \
142         (MAX_MEMORY_BODY_LEN); split the content into multiple memories"
143    )]
144    /// Body too large.
145    BodyTooLarge {
146        /// Observed size in bytes.
147        bytes: u64,
148        /// Configured limit.
149        limit: u64,
150    },
151
152    /// Chunking produced more chunks than
153    /// [`crate::constants::REMEMBER_MAX_SAFE_MULTI_CHUNKS`]. Maps to exit
154    /// code `6` (same contract as [`Self::LimitExceeded`]).
155    ///
156    /// v1.1.1 (P11): counterpart of [`Self::BodyTooLarge`] for the chunk-count
157    /// ceiling. Carries the measured chunk count and the cap so the operator
158    /// can distinguish a chunk overflow from a byte overflow on exit 6.
159    #[error(
160        "limit exceeded: document produces {chunks} chunks, above the \
161         {limit}-chunk cap (REMEMBER_MAX_SAFE_MULTI_CHUNKS); split the \
162         document before writing"
163    )]
164    /// Too many chunks.
165    TooManyChunks {
166        /// Observed chunk count.
167        chunks: usize,
168        /// Configured limit.
169        limit: usize,
170    },
171
172    /// Body exceeded [`crate::constants::EMBEDDING_REQUEST_MAX_TOKENS`] tokens
173    /// (conservative cl100k proxy for the `qwen/qwen3-embedding-8b` window).
174    /// Maps to exit code `6` (same contract as [`Self::LimitExceeded`]).
175    ///
176    /// v1.1.2 (Gap 2): third typed payload ceiling alongside
177    /// [`Self::BodyTooLarge`] (bytes) and [`Self::TooManyChunks`] (chunks).
178    /// The token cap used to surface as a generic `Validation` (exit 1) deep
179    /// inside the REST embedding client; it now fires at the write-command
180    /// boundary with the estimated token count and the cap, so the operator
181    /// can tell WHICH ceiling fired without substring matching (GAP-SG-73).
182    #[error(
183        "limit exceeded: body is {tokens} tokens (estimated), above the \
184         {limit}-token cap (EMBEDDING_REQUEST_MAX_TOKENS); split the content \
185         into multiple memories"
186    )]
187    /// Too many tokens.
188    TooManyTokens {
189        /// Observed token count.
190        tokens: u64,
191        /// Configured limit.
192        limit: u64,
193    },
194
195    /// Low-level SQLite error propagated from `rusqlite`. Maps to exit code `10`.
196    #[error("database error: {0}")]
197    Database(#[from] rusqlite::Error),
198
199    /// Embedding generation via `fastembed` failed or produced the wrong shape. Maps to exit code `11`.
200    #[error("embedding error: {0}")]
201    Embedding(String),
202
203    /// The `sqlite-vec` extension could not load or register its virtual table. Maps to exit code `12`.
204    #[error("sqlite-vec extension failed: {0}")]
205    VecExtension(String),
206
207    /// 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).
208    #[error("database busy: {0}")]
209    DbBusy(String),
210
211    /// Batch operation failed partially — N of M items failed. Maps to exit code `13` (PRD 1822).
212    ///
213    /// Reserved for use in `import`, `reindex` and batch stdin (BLOCK 3/4). Variant present
214    /// since v2.0.0 even if call-sites do not yet exist — stable exit code mapping.
215    #[error("batch partial failure: {failed} of {total} items failed")]
216    BatchPartialFailure {
217        /// Total items processed.
218        total: usize,
219        /// Number of failed items.
220        failed: usize,
221    },
222
223    /// Filesystem I/O error while reading or writing the database or cache. Maps to exit code `14`.
224    #[error("IO error: {0}")]
225    Io(#[from] std::io::Error),
226
227    /// Unexpected internal error surfaced through `anyhow`. Maps to exit code `20`.
228    #[error(transparent)]
229    Internal(#[from] anyhow::Error),
230
231    /// JSON serialization or deserialization failure. Maps to exit code `20`.
232    #[error("json error: {0}")]
233    Json(#[from] serde_json::Error),
234
235    /// Another instance is already running and holds the advisory lock. Maps to exit code `75`.
236    ///
237    /// Use `--allow-parallel` to skip the lock or `--wait-lock SECONDS` to retry.
238    #[error("lock busy: {0}")]
239    LockBusy(String),
240
241    /// All concurrency slots are occupied after the wait timeout. Maps to exit code `75`.
242    ///
243    /// Occurs when [`crate::constants::MAX_CONCURRENT_CLI_INSTANCES`] instances are already
244    /// active and the wait limit [`crate::constants::CLI_LOCK_DEFAULT_WAIT_SECS`] is exhausted.
245    #[error(
246        "all {max} concurrency slots occupied after waiting {waited_secs}s (exit 75); \
247         use --max-concurrency or wait for other invocations to finish"
248    )]
249    /// All slots full.
250    AllSlotsFull {
251        /// Maximum allowed value.
252        max: usize,
253        /// Seconds spent waiting.
254        waited_secs: u64,
255    },
256
257    /// A heavy long-running job is already running for this job_type/namespace
258    /// pair. Maps to exit code `75` (the same `EX_TEMPFAIL` code used by the
259    /// CLI semaphore).
260    ///
261    /// G28-B (v1.0.68): ensures at most one `enrich`, `ingest --mode
262    /// claude-code`, or `ingest --mode codex` runs at a time per namespace.
263    /// Use `--wait-job-singleton <SECONDS>` (per-command) to poll until the
264    /// other invocation finishes.
265    #[error(
266        "job {job_type} for namespace '{namespace}' is already running (exit 75); \
267         wait for it to finish or pass --wait-job-singleton <SECONDS>"
268    )]
269    /// Job singleton locked.
270    JobSingletonLocked {
271        /// Job type identifier.
272        job_type: String,
273        /// Namespace scope.
274        namespace: String,
275    },
276
277    /// G45: an LLM embedding operation is already running against the
278    /// same `(namespace, db)` pair in another process. Exit code 75
279    /// (retryable). The caller can pass `--wait-embed-singleton
280    /// <SECONDS>` to poll until the lock drops.
281    #[error(
282        "embedding singleton for namespace '{namespace}' is already held (exit 75); \
283         another CLI is calling the LLM on this database; pass --wait-embed-singleton <SECONDS> to wait"
284    )]
285    /// Embedding singleton locked.
286    EmbeddingSingletonLocked {
287        /// Namespace scope.
288        namespace: String,
289    },
290
291    /// Available memory is below the minimum required to load the model. Maps to exit code `77`.
292    ///
293    /// Returned when `sysinfo` reports available memory below
294    /// [`crate::constants::MIN_AVAILABLE_MEMORY_MB`] MiB before starting the ONNX model load.
295    #[error(
296        "available memory ({available_mb}MB) below required minimum ({required_mb}MB) \
297         to load the model; abort other loads or use --skip-memory-guard (exit 77)"
298    )]
299    /// Low memory.
300    LowMemory {
301        /// Available memory in megabytes.
302        available_mb: u64,
303        /// Required memory in megabytes.
304        required_mb: u64,
305    },
306
307    /// v1.0.82 (GAP-002 final): shutdown was requested via SIGINT, SIGTERM or
308    /// SIGHUP before the current command completed. Maps to exit code
309    /// [`crate::constants::SHUTDOWN_EXIT_CODE`] (19).
310    ///
311    /// The signal name is preserved in the `signal` field so the JSON
312    /// envelope emitted before exit can route the operator to a
313    /// deterministic branch. Distinct from the legacy `128 + signal`
314    /// Unix convention (130/143/129) so LLM agents can match on a
315    /// single code for "cancelled by user".
316    #[error("shutdown signal received: {signal}")]
317    Shutdown {
318        /// Signal that triggered shutdown.
319        signal: String,
320    },
321
322    /// v1.0.87 (GAP-META-005, ADR-0045): pre-flight validation gate
323    /// rejected the spawn before fork. Maps to exit code `16`.
324    ///
325    /// The `source` field carries the structured [`PreFlightError`]
326    /// variant so callers and operators can route on the specific
327    /// failure class (BinaryNotFound, ArgvExceedsArgMax,
328    /// McpConfigInlineJsonRejected, McpConfigPathMissing,
329    /// McpConfigPathInvalidJson, WalkUpMcpJsonInvalid,
330    /// OutputBufferTooSmall, ClaudeConfigDirNotEmpty) instead of
331    /// parsing the legacy `detail: String` representation.
332    ///
333    /// This variant is **permanent** — retrying the same argv will fail
334    /// identically. Operators must fix the underlying condition (install
335    /// the binary, shorten the body, override `CLAUDE_CONFIG_DIR`,
336    /// substitute the inline `--mcp-config '{}'` for a tempfile path,
337    /// etc.) before retrying.
338    #[error("preflight validation failed: {source}")]
339    PreFlightFailed {
340        /// Underlying preflight error.
341        source: Box<PreFlightError>,
342    },
343
344    /// v1.0.97 (GAP-SG-01/03): the OpenRouter provider returned a structured
345    /// error object (an `error` field carrying `code` and `message`), often
346    /// inside an HTTP 200 body (e.g. token/context-length overflow). Maps to
347    /// exit code `1`.
348    ///
349    /// Modelling the provider rejection as a typed variant — instead of the
350    /// generic `Embedding`/`Validation` string — stops the optimistic success
351    /// parse from masking the cause with a misleading missing-field error. The
352    /// `code` and `message` carry the REAL provider diagnostics.
353    ///
354    /// This variant is **permanent**: a structured provider error in a success
355    /// body is a content or configuration rejection that retrying the identical
356    /// request will not fix. Genuine rate limiting surfaces as HTTP 429 and is
357    /// retried inside the HTTP client (then exposed via `RateLimited` when
358    /// attempts are exhausted), so it never reaches callers as `ProviderError`.
359    #[error("provider error (code {code}): {message}")]
360    ProviderError {
361        /// Provider error code.
362        code: String,
363        /// Provider error message.
364        message: String,
365    },
366}
367
368/// Bridges the structured [`PreFlightError`] produced by the
369/// pre-flight validation gate (v1.0.87, ADR-0045) into the unified
370/// [`AppError`] envelope. Lets spawners use the `?` operator instead
371/// of hand-rolling `AppError::PreFlightFailed { source: ... }` at every
372/// call site, and keeps the variant alive as the canonical exit code 16
373/// path rather than the dead code it was at v1.0.87.
374impl From<PreFlightError> for AppError {
375    fn from(source: PreFlightError) -> Self {
376        AppError::PreFlightFailed {
377            source: Box::new(source),
378        }
379    }
380}
381
382impl AppError {
383    /// Returns the deterministic process exit code for this error variant.
384    ///
385    /// The codes follow the contract documented in the README: `1` for
386    /// validation, `9` for duplicates (moved from `2` in v1.0.52), `3` for conflicts, `4` for missing
387    /// records, `5` for namespace errors, `6` for limit violations, `10`–`14`
388    /// for infrastructure failures, `13` for BatchPartialFailure (PRD 1822),
389    /// `15` for DbBusy (migrated from `13` in v2.0.0), `20` for internal errors,
390    /// `75` (EX_TEMPFAIL) when the advisory CLI lock is held or all concurrency
391    /// slots are exhausted, and `77` when available memory is insufficient to
392    /// load the embedding model.
393    ///
394    /// # Examples
395    ///
396    /// ```
397    /// use sqlite_graphrag::errors::AppError;
398    ///
399    /// assert_eq!(AppError::Validation("invalid field".into()).exit_code(), 1);
400    /// assert_eq!(AppError::Duplicate("ns/mem".into()).exit_code(), 9);
401    /// assert_eq!(AppError::Conflict("ts changed".into()).exit_code(), 3);
402    /// assert_eq!(AppError::NotFound("id 42".into()).exit_code(), 4);
403    /// assert_eq!(AppError::NamespaceError("no marker".into()).exit_code(), 5);
404    /// assert_eq!(AppError::LimitExceeded("body too large".into()).exit_code(), 6);
405    /// assert_eq!(AppError::Embedding("wrong dim".into()).exit_code(), 11);
406    /// assert_eq!(AppError::DbBusy("retries exhausted".into()).exit_code(), 15);
407    /// assert_eq!(AppError::LockBusy("another instance".into()).exit_code(), 75);
408    /// ```
409    #[inline]
410    #[must_use]
411    pub fn exit_code(&self) -> i32 {
412        match self {
413            Self::Validation(_) => 1,
414            Self::BinaryNotFound { .. } => 1,
415            Self::RateLimited { .. } => 1,
416            Self::Timeout { .. } => 1,
417            Self::Duplicate(_) => crate::constants::DUPLICATE_EXIT_CODE,
418            Self::Conflict(_) => 3,
419            Self::NotFound(_) => 4,
420            Self::MemoryNotFound { .. } => 4,
421            Self::MemoryNotFoundById { .. } => 4,
422            Self::EntityNotYetMaterialized { .. } => 4,
423            Self::NamespaceError(_) => 5,
424            Self::LimitExceeded(_) => 6,
425            Self::BodyTooLarge { .. } => 6,
426            Self::TooManyChunks { .. } => 6,
427            Self::TooManyTokens { .. } => 6,
428            Self::Database(_) => 10,
429            Self::Embedding(_) => 11,
430            Self::VecExtension(_) => 12,
431            Self::BatchPartialFailure { .. } => crate::constants::BATCH_PARTIAL_FAILURE_EXIT_CODE,
432            Self::DbBusy(_) => crate::constants::DB_BUSY_EXIT_CODE,
433            Self::Io(_) => 14,
434            Self::Internal(_) => 20,
435            Self::Json(_) => 20,
436            Self::LockBusy(_) => crate::constants::CLI_LOCK_EXIT_CODE,
437            Self::AllSlotsFull { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
438            Self::JobSingletonLocked { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
439            Self::EmbeddingSingletonLocked { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
440            Self::LowMemory { .. } => crate::constants::LOW_MEMORY_EXIT_CODE,
441            Self::Shutdown { .. } => crate::constants::SHUTDOWN_EXIT_CODE,
442            Self::PreFlightFailed { .. } => 16,
443            Self::ProviderError { .. } => 1,
444        }
445    }
446
447    /// Returns `true` when the error is transient and the operation may
448    /// succeed on retry with backoff.
449    ///
450    /// # Examples
451    ///
452    /// ```
453    /// use sqlite_graphrag::errors::AppError;
454    ///
455    /// assert!(AppError::DbBusy("busy".into()).is_retryable());
456    /// assert!(AppError::LockBusy("held".into()).is_retryable());
457    /// assert!(!AppError::NotFound("x".into()).is_retryable());
458    /// assert!(!AppError::Validation("bad".into()).is_retryable());
459    /// ```
460    #[inline]
461    #[must_use]
462    pub fn is_retryable(&self) -> bool {
463        matches!(
464            self,
465            Self::DbBusy(_)
466                | Self::LockBusy(_)
467                | Self::AllSlotsFull { .. }
468                | Self::JobSingletonLocked { .. }
469                | Self::EmbeddingSingletonLocked { .. }
470                | Self::LowMemory { .. }
471                | Self::RateLimited { .. }
472                | Self::Timeout { .. }
473                | Self::EntityNotYetMaterialized { .. }
474        )
475    }
476
477    /// Returns `true` when shutdown was requested by the user via signal.
478    ///
479    /// Distinct from `is_permanent` because shutdown is a USER intent, not
480    /// a state to retry against. The operation should be retried with
481    /// `--resume` (GAP-001) when the persisted staging row still exists.
482    ///
483    /// # Examples
484    ///
485    /// ```
486    /// use sqlite_graphrag::errors::AppError;
487    ///
488    /// assert!(AppError::Shutdown { signal: "SIGINT".into() }.is_shutdown());
489    /// assert!(!AppError::Validation("x".into()).is_shutdown());
490    /// ```
491    #[inline]
492    #[must_use]
493    pub fn is_shutdown(&self) -> bool {
494        matches!(self, Self::Shutdown { .. })
495    }
496
497    /// Returns `true` when the error is permanent and must NOT be retried.
498    ///
499    /// Complement to [`Self::is_retryable`]. Errors not classified by either
500    /// method (e.g. `Database`, `Io`, `Internal`) are ambiguous — the caller
501    /// decides based on context.
502    ///
503    /// # Examples
504    ///
505    /// ```
506    /// use sqlite_graphrag::errors::AppError;
507    ///
508    /// assert!(AppError::Validation("bad".into()).is_permanent());
509    /// assert!(!AppError::DbBusy("busy".into()).is_permanent());
510    /// ```
511    #[inline]
512    #[must_use]
513    pub fn is_permanent(&self) -> bool {
514        matches!(
515            self,
516            Self::Validation(_)
517                | Self::BinaryNotFound { .. }
518                | Self::Duplicate(_)
519                | Self::NotFound(_)
520                | Self::MemoryNotFound { .. }
521                | Self::MemoryNotFoundById { .. }
522                | Self::NamespaceError(_)
523                | Self::LimitExceeded(_)
524                | Self::BodyTooLarge { .. }
525                | Self::TooManyChunks { .. }
526                | Self::TooManyTokens { .. }
527                | Self::VecExtension(_)
528                | Self::PreFlightFailed { .. }
529                | Self::ProviderError { .. }
530        )
531    }
532
533    /// GAP-SG-39: returns an actionable remediation hint for the error, surfaced
534    /// in the stdout error envelope as the `suggestion` field. The hint tells the
535    /// operator HOW to recover instead of leaving an exit code without guidance —
536    /// this is what makes a write rejection (e.g. a malformed name) observable and
537    /// fixable. Returns `None` for variants whose own message is already
538    /// self-remediating.
539    #[must_use]
540    pub fn suggestion(&self) -> Option<&'static str> {
541        match self {
542            Self::Validation(_) => Some(
543                "review the input against the command's --help; names must be kebab-case (lowercase letters, digits, hyphens) and bodies non-empty",
544            ),
545            Self::Duplicate(_) => {
546                Some("pass --force-merge to update the existing memory instead of failing")
547            }
548            Self::Conflict(_) => Some(
549                "another writer changed the row; re-read with `read --name <n> --json` and retry with a fresh --expected-updated-at",
550            ),
551            Self::NotFound(_) | Self::MemoryNotFound { .. } | Self::MemoryNotFoundById { .. } => {
552                Some("verify the name/id and namespace with `list --json` or `read --name <n> --json`")
553            }
554            Self::NamespaceError(_) => {
555                // GAP-SG-103: product env is not read (G-T-XDG-04). Point operators
556                // at the real channels: CLI flag and XDG `namespace.default`.
557                Some("set --namespace or `config set namespace.default <name>`; inspect with `namespace-detect --json`")
558            }
559            Self::LimitExceeded(_) => {
560                Some("split the input into smaller memories or raise the documented cap before retrying")
561            }
562            Self::BodyTooLarge { .. } => {
563                Some("the body-bytes cap (MAX_MEMORY_BODY_LEN) fired; split the content into multiple memories or use --body-file")
564            }
565            Self::TooManyChunks { .. } => {
566                Some("the chunk-count cap (REMEMBER_MAX_SAFE_MULTI_CHUNKS) fired; split the document into smaller memories before writing")
567            }
568            Self::TooManyTokens { .. } => {
569                Some("the token cap (EMBEDDING_REQUEST_MAX_TOKENS) fired; split the content into multiple memories, keeping each under ~25000 tokens")
570            }
571            Self::Embedding(_) => Some(
572                "verify the embedding backend and OPENROUTER_API_KEY; re-run `enrich --operation re-embed` once resolved",
573            ),
574            Self::Database(_) | Self::DbBusy(_) => {
575                Some("run `health --json` then `vacuum --json`; widen --wait-lock if the database is busy")
576            }
577            Self::Io(_) => Some("check the path exists and is writable, then retry"),
578            Self::RateLimited { .. } => {
579                Some("wait for the reported retry-after window, then retry")
580            }
581            Self::LockBusy(_) | Self::AllSlotsFull { .. } | Self::JobSingletonLocked { .. } => {
582                Some("wait for the other invocation to finish or pass --wait-lock / --wait-job-singleton")
583            }
584            _ => None,
585        }
586    }
587
588    /// Returns the localized error message in the active language (`--lang` / XDG `i18n.lang`).
589    ///
590    /// In English the text is identical to the `Display` generated by thiserror.
591    /// In Portuguese the prefixes and messages are translated to PT-BR.
592    pub fn localized_message(&self) -> String {
593        self.localized_message_for(current())
594    }
595
596    /// Returns the localized message for the explicitly provided language.
597    /// Useful in tests that cannot depend on the global `OnceLock`.
598    ///
599    /// # Examples
600    ///
601    /// ```
602    /// use sqlite_graphrag::errors::AppError;
603    /// use sqlite_graphrag::i18n::Language;
604    ///
605    /// let err = AppError::NotFound("mem-xyz".into());
606    ///
607    /// let en = err.localized_message_for(Language::English);
608    /// assert!(en.contains("not found"));
609    ///
610    /// let pt = err.localized_message_for(Language::Portuguese);
611    /// assert!(pt.contains("n\u{e3}o encontrado"));
612    /// ```
613    pub fn localized_message_for(&self, lang: Language) -> String {
614        match lang {
615            Language::English => self.to_string(),
616            Language::Portuguese => self.to_string_pt(),
617        }
618    }
619
620    fn to_string_pt(&self) -> String {
621        use crate::i18n::validation::app_error_pt as pt;
622        match self {
623            Self::Validation(msg) => pt::validation(msg),
624            Self::BinaryNotFound { name } => pt::binary_not_found(name),
625            Self::RateLimited { detail } => pt::rate_limited(detail),
626            Self::Timeout {
627                operation,
628                duration_secs,
629            } => pt::timeout(operation, *duration_secs),
630            Self::Duplicate(msg) => pt::duplicate(msg),
631            Self::Conflict(msg) => pt::conflict(msg),
632            Self::NotFound(msg) => pt::not_found(msg),
633            Self::MemoryNotFound { name, namespace } => pt::memory_not_found(name, namespace),
634            Self::MemoryNotFoundById { id } => pt::memory_not_found_by_id(*id),
635            Self::EntityNotYetMaterialized { name, namespace } => {
636                pt::entity_not_yet_materialized(name, namespace)
637            }
638            Self::NamespaceError(msg) => pt::namespace_error(msg),
639            Self::LimitExceeded(msg) => pt::limit_exceeded(msg),
640            Self::BodyTooLarge { bytes, limit } => pt::body_too_large(*bytes, *limit),
641            Self::TooManyChunks { chunks, limit } => pt::too_many_chunks(*chunks, *limit),
642            Self::TooManyTokens { tokens, limit } => pt::too_many_tokens(*tokens, *limit),
643            Self::Database(e) => pt::database(&e.to_string()),
644            Self::Embedding(msg) => pt::embedding(msg),
645            Self::VecExtension(msg) => pt::vec_extension(msg),
646            Self::DbBusy(msg) => pt::db_busy(msg),
647            Self::BatchPartialFailure { total, failed } => {
648                pt::batch_partial_failure(*total, *failed)
649            }
650            Self::Io(e) => pt::io(&e.to_string()),
651            Self::Internal(e) => pt::internal(&e.to_string()),
652            Self::Json(e) => pt::json(&e.to_string()),
653            Self::LockBusy(msg) => pt::lock_busy(msg),
654            Self::AllSlotsFull { max, waited_secs } => pt::all_slots_full(*max, *waited_secs),
655            Self::JobSingletonLocked {
656                job_type,
657                namespace,
658            } => pt::job_singleton_locked(job_type, namespace),
659            Self::EmbeddingSingletonLocked { namespace } => {
660                pt::embedding_singleton_locked(namespace)
661            }
662            Self::LowMemory {
663                available_mb,
664                required_mb,
665            } => pt::low_memory(*available_mb, *required_mb),
666            Self::Shutdown { signal } => pt::shutdown(signal),
667            Self::PreFlightFailed { source } => pt::preflight_failed(&source.to_string()),
668            Self::ProviderError { code, message } => pt::provider_error(code, message),
669        }
670    }
671}
672#[cfg(test)]
673#[path = "errors_tests.rs"]
674mod tests;