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///
17/// # SemVer Policy
18///
19/// This enum is `#[non_exhaustive]`. New variants may be added in minor
20/// releases without breaking downstream match arms (use a wildcard `_`).
21#[derive(Error, Debug)]
22#[non_exhaustive]
23pub enum AppError {
24    /// Input failed schema, length or format validation. Maps to exit code `1`.
25    ///
26    /// This variant groups multiple validation failure causes. Callers that need
27    /// programmatic retry decisions should use [`AppError::is_retryable`] instead
28    /// of parsing the message string.
29    #[error("validation error: {0}")]
30    Validation(String),
31
32    /// The argv parsed but asks for something impossible. Maps to exit code `2`.
33    ///
34    /// Distinct from [`AppError::Validation`] on purpose. `Validation` means the
35    /// DATA the caller supplied is wrong and exits `1`; this means the REQUEST
36    /// itself is incoherent and exits [`crate::constants::USAGE_EXIT_CODE`], the
37    /// same code clap returns for a rejected flag. An agent therefore branches on
38    /// one number for "fix the command line" whether the parser or the
39    /// agent-native surface caught it.
40    ///
41    /// Introduced in v1.2.6 for the refusals the surface can only make once it
42    /// has the envelope: a projection or predicate key that exists in no result
43    /// element, a predicate aimed at a collection the caller never named, and a
44    /// filter evaluated over a page the query had already truncated.
45    ///
46    /// Carries the flags the request declared and the binary could not honour.
47    /// `rules-rust-cli-stdin-stdout-silent-discard` requires the error JSON to
48    /// name them in a field: leaving them inside the prose would force the
49    /// caller to parse a sentence — in one of two languages — to learn which of
50    /// its own arguments were dropped. Empty for refusals that discard nothing,
51    /// such as a predicate judged over a truncated page.
52    #[error("usage error: {message}")]
53    Usage {
54        /// The localized refusal, already carrying its corrective action.
55        message: String,
56        /// Flags the caller passed that this invocation could not apply.
57        discarded_flags: Vec<String>,
58    },
59
60    /// External binary required for operation was not found in PATH. Maps to exit code `1`.
61    #[error("binary not found: {name} — ensure it is installed and in PATH")]
62    BinaryNotFound {
63        /// Name associated with this error.
64        name: String,
65    },
66
67    /// Remote service signaled rate limiting; caller should retry with backoff. Maps to exit code `1`.
68    #[error("rate limited: {detail}")]
69    RateLimited {
70        /// Human-readable detail message.
71        detail: String,
72    },
73
74    /// Operation exceeded its time budget. Maps to exit code `1`.
75    #[error("timeout after {duration_secs}s: {operation}")]
76    Timeout {
77        /// Operation.
78        operation: String,
79        /// Duration secs.
80        duration_secs: u64,
81    },
82
83    /// A memory or entity with the same `(namespace, name)` already exists. Maps to exit code `9`.
84    #[error("duplicate detected: {0}")]
85    Duplicate(String),
86
87    /// Optimistic update lost the race because `updated_at` changed. Maps to exit code `3`.
88    #[error("conflict: {0}")]
89    Conflict(String),
90
91    /// The requested record does not exist or was soft-deleted. Maps to exit code `4`.
92    #[error("not found: {0}")]
93    NotFound(String),
94
95    /// Memory lookup by `(namespace, name)` returned no row. Maps to exit code `4`.
96    ///
97    /// G55 S2 (v1.0.80): structural variant that carries the requested identifier
98    /// and namespace, eliminating the "not found: unknown in namespace 'X'" class
99    /// of bugs that masked which lookup target failed. The display format matches
100    /// the legacy string-based `NotFound` so the i18n replace-chain and external
101    /// scripts that pattern-match on `memory not found: name='N' in namespace 'NS'`
102    /// keep working.
103    #[error("memory not found: name='{name}' in namespace '{namespace}'")]
104    MemoryNotFound {
105        /// Name associated with this error.
106        name: String,
107        /// Namespace scope.
108        namespace: String,
109    },
110
111    /// Memory lookup by integer `id` returned no row. Maps to exit code `4`.
112    #[error("memory not found: id={id}")]
113    MemoryNotFoundById {
114        /// Numeric identifier.
115        id: i64,
116    },
117
118    /// GAP-SG-78: an entity referenced by a queued enrich item does not yet
119    /// exist in `entities`. Maps to exit code `4`.
120    ///
121    /// # Cause
122    ///
123    /// Distinct from the terminal [`Self::NotFound`] / [`Self::MemoryNotFound`]
124    /// cases (a memory that was deleted or renamed, permanently gone). An
125    /// entity can be referenced by a queue row BEFORE it is materialized: a
126    /// later enrich pass creates the entity, so its absence now is TRANSITORY,
127    /// not terminal. Collapsing both into a single `NotFound` string sent every
128    /// such item to the dead-letter on the first failure.
129    ///
130    /// # When it occurs
131    ///
132    /// Raised by the entity call-sites of `enrich` — `entity-descriptions`
133    /// (`call_entity_description`) and `entity-type-validate`
134    /// (`call_entity_type_validate`) — when the `(namespace, name)` lookup
135    /// returns no row. Classified as [`Self::is_retryable`] so the item is
136    /// rescheduled until `--max-attempts` is exhausted.
137    #[error("entity '{name}' not yet materialized in namespace '{namespace}'")]
138    EntityNotYetMaterialized {
139        /// Name associated with this error.
140        name: String,
141        /// Namespace scope.
142        namespace: String,
143    },
144
145    /// Namespace could not be resolved from flag, environment or markers. Maps to exit code `5`.
146    #[error("namespace not resolved: {0}")]
147    NamespaceError(String),
148
149    /// Payload exceeded one of the configured body, name or batch limits. Maps to exit code `6`.
150    ///
151    /// v1.1.1 (P11): kept for caps other than the body-bytes and chunk-count
152    /// ceilings, which now have the typed [`Self::BodyTooLarge`] and
153    /// [`Self::TooManyChunks`] variants so the operator can tell WHICH cap
154    /// fired without parsing the message.
155    #[error("limit exceeded: {0}")]
156    LimitExceeded(String),
157
158    /// Body payload exceeded [`crate::constants::MAX_MEMORY_BODY_LEN`] bytes.
159    /// Maps to exit code `6` (same contract as [`Self::LimitExceeded`]).
160    ///
161    /// v1.1.1 (P11): the two independent write ceilings — body bytes and chunk
162    /// count — used to collapse into the generic `LimitExceeded` string, so an
163    /// operator hitting exit 6 could not tell WHICH cap fired. This variant
164    /// carries the measured size and the cap, and the message names the
165    /// constant, so both the stderr line and the JSON envelope identify the
166    /// ceiling deterministically (never by substring matching).
167    #[error(
168        "limit exceeded: body is {bytes} bytes, above the {limit}-byte cap \
169         (MAX_MEMORY_BODY_LEN); split the content into multiple memories"
170    )]
171    /// Body too large.
172    BodyTooLarge {
173        /// Observed size in bytes.
174        bytes: u64,
175        /// Configured limit.
176        limit: u64,
177    },
178
179    /// Chunking produced more chunks than
180    /// [`crate::constants::REMEMBER_MAX_SAFE_MULTI_CHUNKS`]. Maps to exit
181    /// code `6` (same contract as [`Self::LimitExceeded`]).
182    ///
183    /// v1.1.1 (P11): counterpart of [`Self::BodyTooLarge`] for the chunk-count
184    /// ceiling. Carries the measured chunk count and the cap so the operator
185    /// can distinguish a chunk overflow from a byte overflow on exit 6.
186    #[error(
187        "limit exceeded: document produces {chunks} chunks, above the \
188         {limit}-chunk cap (REMEMBER_MAX_SAFE_MULTI_CHUNKS); split the \
189         document before writing"
190    )]
191    /// Too many chunks.
192    TooManyChunks {
193        /// Observed chunk count.
194        chunks: usize,
195        /// Configured limit.
196        limit: usize,
197    },
198
199    /// Body exceeded [`crate::constants::EMBEDDING_REQUEST_MAX_TOKENS`] tokens
200    /// (conservative cl100k proxy for the `qwen/qwen3-embedding-8b` window).
201    /// Maps to exit code `6` (same contract as [`Self::LimitExceeded`]).
202    ///
203    /// v1.1.2 (Gap 2): third typed payload ceiling alongside
204    /// [`Self::BodyTooLarge`] (bytes) and [`Self::TooManyChunks`] (chunks).
205    /// The token cap used to surface as a generic `Validation` (exit 1) deep
206    /// inside the REST embedding client; it now fires at the write-command
207    /// boundary with the estimated token count and the cap, so the operator
208    /// can tell WHICH ceiling fired without substring matching (GAP-SG-73).
209    #[error(
210        "limit exceeded: body is {tokens} tokens (estimated), above the \
211         {limit}-token cap (EMBEDDING_REQUEST_MAX_TOKENS); split the content \
212         into multiple memories"
213    )]
214    /// Too many tokens.
215    TooManyTokens {
216        /// Observed token count.
217        tokens: u64,
218        /// Configured limit.
219        limit: u64,
220    },
221
222    /// Low-level SQLite error propagated from `rusqlite`. Maps to exit code `10`.
223    #[error("database error: {0}")]
224    Database(#[from] rusqlite::Error),
225
226    /// Embedding generation via `fastembed` failed or produced the wrong shape. Maps to exit code `11`.
227    #[error("embedding error: {0}")]
228    Embedding(String),
229
230    /// GAP-SG-270: an embedding failure that still CARRIES the retry verdict
231    /// `EmbedError` computed at its origin (exact HTTP status / provider code).
232    /// Exit code `11` and `Display` text are identical to [`Self::Embedding`],
233    /// so no operator-facing contract changes; only the enrich queue reads the
234    /// extra field, via `queue_ops::classify_enrich_outcome`. Without it every
235    /// embedding failure landed in the untyped [`Self::Embedding`] bucket and a
236    /// PERMANENT one burned every `--max-attempts` retry. Deliberately absent
237    /// from [`Self::is_retryable`], which never covered [`Self::Embedding`].
238    #[error("embedding error: {message}")]
239    EmbeddingClassified {
240        /// Failure detail, identical to the payload of [`Self::Embedding`].
241        message: String,
242        /// Retry verdict computed where the failure originated (HTTP status /
243        /// provider code), never inferred from `message`.
244        retry_class: crate::retry::AttemptOutcome,
245    },
246
247    /// The `sqlite-vec` extension could not load or register its virtual table. Maps to exit code `12`.
248    #[error("sqlite-vec extension failed: {0}")]
249    VecExtension(String),
250
251    /// 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).
252    #[error("database busy: {0}")]
253    DbBusy(String),
254
255    /// Batch operation failed partially — N of M items failed. Maps to exit code `13` (PRD 1822).
256    ///
257    /// Reserved for use in `import`, `reindex` and batch stdin (BLOCK 3/4). Variant present
258    /// since v2.0.0 even if call-sites do not yet exist — stable exit code mapping.
259    #[error("batch partial failure: {failed} of {total} items failed")]
260    BatchPartialFailure {
261        /// Total items processed.
262        total: usize,
263        /// Number of failed items.
264        failed: usize,
265    },
266
267    /// Filesystem I/O error while reading or writing the database or cache. Maps to exit code `14`.
268    #[error("IO error: {0}")]
269    Io(#[from] std::io::Error),
270
271    /// Unexpected internal error surfaced through `anyhow`. Maps to exit code `20`.
272    #[error(transparent)]
273    Internal(#[from] anyhow::Error),
274
275    /// JSON serialization or deserialization failure. Maps to exit code `20`.
276    #[error("json error: {0}")]
277    Json(#[from] serde_json::Error),
278
279    /// Another instance is already running and holds the advisory lock. Maps to exit code `75`.
280    ///
281    /// Use `--wait-lock <SECONDS>` to poll until the lock drops.
282    #[error("lock busy: {0}")]
283    LockBusy(String),
284
285    /// All concurrency slots are occupied after the wait timeout. Maps to exit code `75`.
286    ///
287    /// Occurs when [`crate::constants::MAX_CONCURRENT_CLI_INSTANCES`] instances are already
288    /// active and the wait limit [`crate::constants::CLI_LOCK_DEFAULT_WAIT_SECS`] is exhausted.
289    #[error(
290        "all {max} concurrency slots occupied after waiting {waited_secs}s (exit 75); \
291         use --max-concurrency or wait for other invocations to finish"
292    )]
293    /// All slots full.
294    AllSlotsFull {
295        /// Maximum allowed value.
296        max: usize,
297        /// Seconds spent waiting.
298        waited_secs: u64,
299    },
300
301    /// A heavy long-running job is already running for this job_type/namespace
302    /// pair. Maps to exit code `75` (the same `EX_TEMPFAIL` code used by the
303    /// CLI semaphore).
304    ///
305    /// G28-B (v1.0.68): ensures at most one `enrich`
306    /// or `ingest` runs at a time per namespace. The guard predates v1.2.0 and
307    /// once also covered `ingest --mode claude-code` / `--mode codex`.
308    /// Use `--wait-job-singleton <SECONDS>` (per-command) to poll until the
309    /// other invocation finishes.
310    #[error(
311        "job {job_type} for namespace '{namespace}' is already running (exit 75); \
312         wait for it to finish or pass --wait-job-singleton <SECONDS>"
313    )]
314    /// Job singleton locked.
315    JobSingletonLocked {
316        /// Job type identifier.
317        job_type: String,
318        /// Namespace scope.
319        namespace: String,
320    },
321
322    /// G45: an LLM embedding operation is already running against the
323    /// same `(namespace, db)` pair in another process. Exit code 75
324    /// (retryable). The caller can pass `--wait-lock <SECONDS>` to poll until
325    /// the lock drops. Until v1.2.8 this message named the wait flag removed in
326    /// v1.2.0, so obeying the refusal returned exit 2 (GAP-SG-303).
327    #[error(
328        "embedding singleton for namespace '{namespace}' is already held (exit 75); \
329         another CLI is calling the LLM on this database; pass --wait-lock <SECONDS> to wait"
330    )]
331    /// Embedding singleton locked.
332    EmbeddingSingletonLocked {
333        /// Namespace scope.
334        namespace: String,
335    },
336
337    /// Available memory is below the minimum required to load the model. Maps to exit code `77`.
338    ///
339    /// Returned when `sysinfo` reports available memory below
340    /// [`crate::constants::MIN_AVAILABLE_MEMORY_MB`] MiB before starting the ONNX model load.
341    #[error(
342        "available memory ({available_mb}MB) below required minimum ({required_mb}MB) \
343         to load the model; abort other loads or use --skip-memory-guard (exit 77)"
344    )]
345    /// Low memory.
346    LowMemory {
347        /// Available memory in megabytes.
348        available_mb: u64,
349        /// Required memory in megabytes.
350        required_mb: u64,
351    },
352
353    /// v1.0.82 (GAP-002 final): shutdown was requested via SIGINT, SIGTERM or
354    /// SIGHUP before the current command completed. Maps to exit code
355    /// [`crate::constants::SHUTDOWN_EXIT_CODE`] (19).
356    ///
357    /// The signal name is preserved in the `signal` field so the JSON
358    /// envelope emitted before exit can route the operator to a
359    /// deterministic branch. Distinct from the legacy `128 + signal`
360    /// Unix convention (130/143/129) so LLM agents can match on a
361    /// single code for "cancelled by user".
362    #[error("shutdown signal received: {signal}")]
363    Shutdown {
364        /// Signal that triggered shutdown.
365        signal: String,
366    },
367
368    /// v1.0.97 (GAP-SG-01/03): the OpenRouter provider returned a structured
369    /// error object (an `error` field carrying `code` and `message`), often
370    /// inside an HTTP 200 body (e.g. token/context-length overflow). Maps to
371    /// exit code `1`.
372    ///
373    /// Modelling the provider rejection as a typed variant — instead of the
374    /// generic `Embedding`/`Validation` string — stops the optimistic success
375    /// parse from masking the cause with a misleading missing-field error. The
376    /// `code` and `message` carry the REAL provider diagnostics.
377    ///
378    /// This variant is **permanent**: a structured provider error in a success
379    /// body is a content or configuration rejection that retrying the identical
380    /// request will not fix. Genuine rate limiting surfaces as HTTP 429 and is
381    /// retried inside the HTTP client (then exposed via `RateLimited` when
382    /// attempts are exhausted), so it never reaches callers as `ProviderError`.
383    #[error("provider error (code {code}): {message}")]
384    ProviderError {
385        /// Provider error code.
386        code: String,
387        /// Provider error message.
388        message: String,
389    },
390}
391
392impl AppError {
393    /// Returns the deterministic process exit code for this error variant.
394    ///
395    /// The codes follow the contract documented in the README: `1` for
396    /// validation, `9` for duplicates (moved from `2` in v1.0.52), `3` for conflicts, `4` for missing
397    /// records, `5` for namespace errors, `6` for limit violations, `10`–`14`
398    /// for infrastructure failures, `13` for BatchPartialFailure (PRD 1822),
399    /// `15` for DbBusy (migrated from `13` in v2.0.0), `20` for internal errors,
400    /// `75` (EX_TEMPFAIL) when the advisory CLI lock is held or all concurrency
401    /// slots are exhausted, and `77` when available memory is insufficient to
402    /// load the embedding model.
403    ///
404    /// # Examples
405    ///
406    /// ```
407    /// use sqlite_graphrag::errors::AppError;
408    ///
409    /// assert_eq!(AppError::Validation("invalid field".into()).exit_code(), 1);
410    /// assert_eq!(AppError::Duplicate("ns/mem".into()).exit_code(), 9);
411    /// assert_eq!(AppError::Conflict("ts changed".into()).exit_code(), 3);
412    /// assert_eq!(AppError::NotFound("id 42".into()).exit_code(), 4);
413    /// assert_eq!(AppError::NamespaceError("no marker".into()).exit_code(), 5);
414    /// assert_eq!(AppError::LimitExceeded("body too large".into()).exit_code(), 6);
415    /// assert_eq!(AppError::Embedding("wrong dim".into()).exit_code(), 11);
416    /// assert_eq!(AppError::DbBusy("retries exhausted".into()).exit_code(), 15);
417    /// assert_eq!(AppError::LockBusy("another instance".into()).exit_code(), 75);
418    /// ```
419    #[inline]
420    #[must_use]
421    pub fn exit_code(&self) -> i32 {
422        match self {
423            Self::Validation(_) => 1,
424            Self::Usage { .. } => crate::constants::USAGE_EXIT_CODE,
425            Self::BinaryNotFound { .. } => 1,
426            Self::RateLimited { .. } => 1,
427            Self::Timeout { .. } => 1,
428            Self::Duplicate(_) => crate::constants::DUPLICATE_EXIT_CODE,
429            Self::Conflict(_) => 3,
430            Self::NotFound(_) => 4,
431            Self::MemoryNotFound { .. } => 4,
432            Self::MemoryNotFoundById { .. } => 4,
433            Self::EntityNotYetMaterialized { .. } => 4,
434            Self::NamespaceError(_) => 5,
435            Self::LimitExceeded(_) => 6,
436            Self::BodyTooLarge { .. } => 6,
437            Self::TooManyChunks { .. } => 6,
438            Self::TooManyTokens { .. } => 6,
439            Self::Database(_) => 10,
440            Self::Embedding(_) => 11,
441            Self::EmbeddingClassified { .. } => 11,
442            Self::VecExtension(_) => 12,
443            Self::BatchPartialFailure { .. } => crate::constants::BATCH_PARTIAL_FAILURE_EXIT_CODE,
444            Self::DbBusy(_) => crate::constants::DB_BUSY_EXIT_CODE,
445            Self::Io(_) => 14,
446            Self::Internal(_) => 20,
447            Self::Json(_) => 20,
448            Self::LockBusy(_) => crate::constants::CLI_LOCK_EXIT_CODE,
449            Self::AllSlotsFull { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
450            Self::JobSingletonLocked { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
451            Self::EmbeddingSingletonLocked { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
452            Self::LowMemory { .. } => crate::constants::LOW_MEMORY_EXIT_CODE,
453            Self::Shutdown { .. } => crate::constants::SHUTDOWN_EXIT_CODE,
454            Self::ProviderError { .. } => 1,
455        }
456    }
457
458    /// Flags the caller passed that this invocation could not honour.
459    ///
460    /// Empty for every variant that discards nothing, so a consumer reads one
461    /// field instead of branching on the variant it happened to receive.
462    #[inline]
463    #[must_use]
464    pub fn discarded_flags(&self) -> &[String] {
465        match self {
466            Self::Usage {
467                discarded_flags, ..
468            } => discarded_flags,
469            _ => &[],
470        }
471    }
472
473    /// Returns `true` when the error is transient and the operation may
474    /// succeed on retry with backoff.
475    ///
476    /// # Examples
477    ///
478    /// ```
479    /// use sqlite_graphrag::errors::AppError;
480    ///
481    /// assert!(AppError::DbBusy("busy".into()).is_retryable());
482    /// assert!(AppError::LockBusy("held".into()).is_retryable());
483    /// assert!(!AppError::NotFound("x".into()).is_retryable());
484    /// assert!(!AppError::Validation("bad".into()).is_retryable());
485    /// ```
486    #[inline]
487    #[must_use]
488    pub fn is_retryable(&self) -> bool {
489        matches!(
490            self,
491            Self::DbBusy(_)
492                | Self::LockBusy(_)
493                | Self::AllSlotsFull { .. }
494                | Self::JobSingletonLocked { .. }
495                | Self::EmbeddingSingletonLocked { .. }
496                | Self::LowMemory { .. }
497                | Self::RateLimited { .. }
498                | Self::Timeout { .. }
499                | Self::EntityNotYetMaterialized { .. }
500        )
501    }
502
503    /// Returns `true` when shutdown was requested by the user via signal.
504    ///
505    /// Distinct from `is_permanent` because shutdown is a USER intent, not
506    /// a state to retry against. The operation should be retried with
507    /// `--resume` (GAP-001) when the persisted staging row still exists.
508    ///
509    /// # Examples
510    ///
511    /// ```
512    /// use sqlite_graphrag::errors::AppError;
513    ///
514    /// assert!(AppError::Shutdown { signal: "SIGINT".into() }.is_shutdown());
515    /// assert!(!AppError::Validation("x".into()).is_shutdown());
516    /// ```
517    #[inline]
518    #[must_use]
519    pub fn is_shutdown(&self) -> bool {
520        matches!(self, Self::Shutdown { .. })
521    }
522
523    /// Returns `true` when the error is permanent and must NOT be retried.
524    ///
525    /// Complement to [`Self::is_retryable`]. Errors not classified by either
526    /// method (e.g. `Database`, `Io`, `Internal`) are ambiguous — the caller
527    /// decides based on context.
528    ///
529    /// # Examples
530    ///
531    /// ```
532    /// use sqlite_graphrag::errors::AppError;
533    ///
534    /// assert!(AppError::Validation("bad".into()).is_permanent());
535    /// assert!(!AppError::DbBusy("busy".into()).is_permanent());
536    /// ```
537    #[inline]
538    #[must_use]
539    pub fn is_permanent(&self) -> bool {
540        matches!(
541            self,
542            Self::Validation(_)
543                | Self::Usage { .. }
544                | Self::BinaryNotFound { .. }
545                | Self::Duplicate(_)
546                | Self::NotFound(_)
547                | Self::MemoryNotFound { .. }
548                | Self::MemoryNotFoundById { .. }
549                | Self::NamespaceError(_)
550                | Self::LimitExceeded(_)
551                | Self::BodyTooLarge { .. }
552                | Self::TooManyChunks { .. }
553                | Self::TooManyTokens { .. }
554                | Self::VecExtension(_)
555                | Self::ProviderError { .. }
556        )
557    }
558
559    /// Stable retry classification carried by the stdout error envelope as
560    /// `error_class`.
561    ///
562    /// An agent that reads only `code` cannot tell a busy lock from a malformed
563    /// name, so it either retries what will never succeed or gives up on what
564    /// would. This field answers that question without the agent knowing a
565    /// single exit code by heart.
566    ///
567    /// The vocabulary matches the one the enrich queue already persists in
568    /// `queue.error_class`, plus `ambiguous` for the third state
569    /// [`Self::is_permanent`] documents: errors classified by neither predicate,
570    /// where the caller decides from context.
571    ///
572    /// # Examples
573    ///
574    /// ```
575    /// use sqlite_graphrag::errors::AppError;
576    ///
577    /// assert_eq!(AppError::DbBusy("busy".into()).error_class(), "transient");
578    /// assert_eq!(AppError::Validation("bad".into()).error_class(), "permanent");
579    /// assert_eq!(
580    ///     AppError::Internal(anyhow::anyhow!("x")).error_class(),
581    ///     "ambiguous"
582    /// );
583    /// ```
584    #[inline]
585    #[must_use]
586    pub fn error_class(&self) -> &'static str {
587        if self.is_retryable() {
588            "transient"
589        } else if self.is_permanent() {
590            "permanent"
591        } else {
592            "ambiguous"
593        }
594    }
595
596    /// GAP-SG-39: returns an actionable remediation hint for the error, surfaced
597    /// in the stdout error envelope as the `suggestion` field. The hint tells the
598    /// operator HOW to recover instead of leaving an exit code without guidance —
599    /// this is what makes a write rejection (e.g. a malformed name) observable and
600    /// fixable. Returns `None` for variants whose own message is already
601    /// self-remediating.
602    ///
603    /// Localized through [`Self::suggestion_for`], so a `pt-BR` operator never
604    /// receives a Portuguese `message` beside an English `suggestion` in the same
605    /// envelope.
606    #[must_use]
607    pub fn suggestion(&self) -> Option<&'static str> {
608        self.suggestion_for(current())
609    }
610
611    /// Returns the remediation hint in the explicitly provided language.
612    ///
613    /// Mirrors [`Self::localized_message_for`] so tests can assert both languages
614    /// without depending on the global `OnceLock`.
615    ///
616    /// # Examples
617    ///
618    /// ```
619    /// use sqlite_graphrag::errors::AppError;
620    /// use sqlite_graphrag::i18n::Language;
621    ///
622    /// let err = AppError::Duplicate("mem-xyz".into());
623    /// assert!(err.suggestion_for(Language::English).unwrap().contains("pass"));
624    /// assert!(err.suggestion_for(Language::Portuguese).unwrap().contains("passe"));
625    /// ```
626    #[must_use]
627    pub fn suggestion_for(&self, lang: Language) -> Option<&'static str> {
628        self.suggestion_pair().map(|(en, pt)| match lang {
629            Language::English => en,
630            Language::Portuguese => pt,
631        })
632    }
633
634    /// Both renderings of the hint, side by side.
635    ///
636    /// Keeping the pair in one arm is what makes a missing translation
637    /// impossible to introduce: adding a variant without its Portuguese text
638    /// does not compile.
639    fn suggestion_pair(&self) -> Option<(&'static str, &'static str)> {
640        match self {
641            Self::Validation(_) => Some((
642                "review the input against the command's --help; names must be kebab-case (lowercase letters, digits, hyphens) and bodies non-empty",
643                "revise a entrada contra o --help do comando; nomes devem ser kebab-case (minúsculas, dígitos, hifens) e corpos não podem ser vazios",
644            )),
645            Self::Usage { .. } => Some((
646                "the command line asks for something impossible; read `agent_surface.unresolved_keys` / `filter_scope` in the error envelope and correct the flag, or declare the narrower intent with --filter-scope page or --allow-unknown-keys",
647                "a linha de comando pede algo impossível; leia `agent_surface.unresolved_keys` / `filter_scope` no envelope de erro e corrija a flag, ou declare a intenção mais estreita com --filter-scope page ou --allow-unknown-keys",
648            )),
649            Self::Duplicate(_) => Some((
650                "pass --force-merge to update the existing memory instead of failing",
651                "passe --force-merge para atualizar a memória existente em vez de falhar",
652            )),
653            Self::Conflict(_) => Some((
654                "another writer changed the row; re-read with `read --name <n> --json` and retry with a fresh --expected-updated-at",
655                "outro escritor alterou a linha; releia com `read --name <n> --json` e repita com um --expected-updated-at novo",
656            )),
657            Self::NotFound(_) | Self::MemoryNotFound { .. } | Self::MemoryNotFoundById { .. } => Some((
658                "verify the name/id and namespace with `list --json` or `read --name <n> --json`",
659                "verifique o nome/id e o namespace com `list --json` ou `read --name <n> --json`",
660            )),
661            Self::NamespaceError(_) => Some((
662                // GAP-SG-103: product env is not read (G-T-XDG-04). Point operators
663                // at the real channels: CLI flag and XDG `namespace.default`.
664                "set --namespace or `config set namespace.default <name>`; inspect with `namespace-detect --json`",
665                "defina --namespace ou `config set namespace.default <nome>`; inspecione com `namespace-detect --json`",
666            )),
667            Self::LimitExceeded(_) => Some((
668                "split the input into smaller memories or raise the documented cap before retrying",
669                "divida a entrada em memórias menores ou eleve o teto documentado antes de repetir",
670            )),
671            Self::BodyTooLarge { .. } => Some((
672                "the body-bytes cap (MAX_MEMORY_BODY_LEN) fired; split the content into multiple memories or use --body-file",
673                "o teto de bytes do corpo (MAX_MEMORY_BODY_LEN) disparou; divida o conteúdo em várias memórias ou use --body-file",
674            )),
675            Self::TooManyChunks { .. } => Some((
676                "the chunk-count cap (REMEMBER_MAX_SAFE_MULTI_CHUNKS) fired; split the document into smaller memories before writing",
677                "o teto de chunks (REMEMBER_MAX_SAFE_MULTI_CHUNKS) disparou; divida o documento em memórias menores antes de gravar",
678            )),
679            Self::TooManyTokens { .. } => Some((
680                "the token cap (EMBEDDING_REQUEST_MAX_TOKENS) fired; split the content into multiple memories, keeping each under ~25000 tokens",
681                "o teto de tokens (EMBEDDING_REQUEST_MAX_TOKENS) disparou; divida o conteúdo em várias memórias, cada uma abaixo de ~25000 tokens",
682            )),
683            Self::Embedding(_) | Self::EmbeddingClassified { .. } => Some((
684                // The product never reads an API key from the environment
685                // (G-T-XDG-04), so naming one here would send the operator down a
686                // channel that cannot work.
687                "store the key with `config add-key --provider openrouter --from-stdin` or pass --openrouter-api-key; re-run `enrich --operation re-embed` once resolved",
688                "grave a chave com `config add-key --provider openrouter --from-stdin` ou passe --openrouter-api-key; re-execute `enrich --operation re-embed` depois de resolver",
689            )),
690            Self::Database(_) | Self::DbBusy(_) => Some((
691                "run `health --json` then `vacuum --json`; widen --wait-lock if the database is busy",
692                "rode `health --json` e depois `vacuum --json`; amplie --wait-lock se o banco estiver ocupado",
693            )),
694            Self::Io(_) => Some((
695                "check the path exists and is writable, then retry",
696                "confira se o caminho existe e é gravável, depois repita",
697            )),
698            Self::RateLimited { .. } => Some((
699                "wait for the reported retry-after window, then retry",
700                "aguarde a janela de retry-after informada, depois repita",
701            )),
702            Self::LockBusy(_) | Self::AllSlotsFull { .. } | Self::JobSingletonLocked { .. } => Some((
703                "wait for the other invocation to finish or pass --wait-lock / --wait-job-singleton",
704                "aguarde a outra invocação terminar ou passe --wait-lock / --wait-job-singleton",
705            )),
706            _ => None,
707        }
708    }
709
710    /// Returns the localized error message in the active language (`--lang` / XDG `i18n.lang`).
711    ///
712    /// In English the text is identical to the `Display` generated by thiserror.
713    /// In Portuguese the prefixes and messages are translated to PT-BR.
714    pub fn localized_message(&self) -> String {
715        self.localized_message_for(current())
716    }
717
718    /// Returns the localized message for the explicitly provided language.
719    /// Useful in tests that cannot depend on the global `OnceLock`.
720    ///
721    /// # Examples
722    ///
723    /// ```
724    /// use sqlite_graphrag::errors::AppError;
725    /// use sqlite_graphrag::i18n::Language;
726    ///
727    /// let err = AppError::NotFound("mem-xyz".into());
728    ///
729    /// let en = err.localized_message_for(Language::English);
730    /// assert!(en.contains("not found"));
731    ///
732    /// let pt = err.localized_message_for(Language::Portuguese);
733    /// assert!(pt.contains("n\u{e3}o encontrado"));
734    /// ```
735    pub fn localized_message_for(&self, lang: Language) -> String {
736        match lang {
737            Language::English => self.to_string(),
738            Language::Portuguese => self.to_string_pt(),
739        }
740    }
741
742    fn to_string_pt(&self) -> String {
743        use crate::i18n::validation::app_error_pt as pt;
744        match self {
745            Self::Validation(msg) => pt::validation(msg),
746            Self::Usage { message, .. } => pt::usage(message),
747            Self::BinaryNotFound { name } => pt::binary_not_found(name),
748            Self::RateLimited { detail } => pt::rate_limited(detail),
749            Self::Timeout {
750                operation,
751                duration_secs,
752            } => pt::timeout(operation, *duration_secs),
753            Self::Duplicate(msg) => pt::duplicate(msg),
754            Self::Conflict(msg) => pt::conflict(msg),
755            Self::NotFound(msg) => pt::not_found(msg),
756            Self::MemoryNotFound { name, namespace } => pt::memory_not_found(name, namespace),
757            Self::MemoryNotFoundById { id } => pt::memory_not_found_by_id(*id),
758            Self::EntityNotYetMaterialized { name, namespace } => {
759                pt::entity_not_yet_materialized(name, namespace)
760            }
761            Self::NamespaceError(msg) => pt::namespace_error(msg),
762            Self::LimitExceeded(msg) => pt::limit_exceeded(msg),
763            Self::BodyTooLarge { bytes, limit } => pt::body_too_large(*bytes, *limit),
764            Self::TooManyChunks { chunks, limit } => pt::too_many_chunks(*chunks, *limit),
765            Self::TooManyTokens { tokens, limit } => pt::too_many_tokens(*tokens, *limit),
766            Self::Database(e) => pt::database(&e.to_string()),
767            Self::Embedding(msg) => pt::embedding(msg),
768            // Same PT text as `Embedding`: the retry verdict is machine-facing
769            // and never shown to the operator.
770            Self::EmbeddingClassified { message, .. } => pt::embedding(message),
771            Self::VecExtension(msg) => pt::vec_extension(msg),
772            Self::DbBusy(msg) => pt::db_busy(msg),
773            Self::BatchPartialFailure { total, failed } => {
774                pt::batch_partial_failure(*total, *failed)
775            }
776            Self::Io(e) => pt::io(&e.to_string()),
777            Self::Internal(e) => pt::internal(&e.to_string()),
778            Self::Json(e) => pt::json(&e.to_string()),
779            Self::LockBusy(msg) => pt::lock_busy(msg),
780            Self::AllSlotsFull { max, waited_secs } => pt::all_slots_full(*max, *waited_secs),
781            Self::JobSingletonLocked {
782                job_type,
783                namespace,
784            } => pt::job_singleton_locked(job_type, namespace),
785            Self::EmbeddingSingletonLocked { namespace } => {
786                pt::embedding_singleton_locked(namespace)
787            }
788            Self::LowMemory {
789                available_mb,
790                required_mb,
791            } => pt::low_memory(*available_mb, *required_mb),
792            Self::Shutdown { signal } => pt::shutdown(signal),
793            Self::ProviderError { code, message } => pt::provider_error(code, message),
794        }
795    }
796}
797#[cfg(test)]
798#[path = "errors_tests.rs"]
799mod tests;