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    #[error("limit exceeded: {0}")]
103    LimitExceeded(String),
104
105    /// Low-level SQLite error propagated from `rusqlite`. Maps to exit code `10`.
106    #[error("database error: {0}")]
107    Database(#[from] rusqlite::Error),
108
109    /// Embedding generation via `fastembed` failed or produced the wrong shape. Maps to exit code `11`.
110    #[error("embedding error: {0}")]
111    Embedding(String),
112
113    /// The `sqlite-vec` extension could not load or register its virtual table. Maps to exit code `12`.
114    #[error("sqlite-vec extension failed: {0}")]
115    VecExtension(String),
116
117    /// 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).
118    #[error("database busy: {0}")]
119    DbBusy(String),
120
121    /// Batch operation failed partially — N of M items failed. Maps to exit code `13` (PRD 1822).
122    ///
123    /// Reserved for use in `import`, `reindex` and batch stdin (BLOCK 3/4). Variant present
124    /// since v2.0.0 even if call-sites do not yet exist — stable exit code mapping.
125    #[error("batch partial failure: {failed} of {total} items failed")]
126    BatchPartialFailure { total: usize, failed: usize },
127
128    /// Filesystem I/O error while reading or writing the database or cache. Maps to exit code `14`.
129    #[error("IO error: {0}")]
130    Io(#[from] std::io::Error),
131
132    /// Unexpected internal error surfaced through `anyhow`. Maps to exit code `20`.
133    #[error(transparent)]
134    Internal(#[from] anyhow::Error),
135
136    /// JSON serialization or deserialization failure. Maps to exit code `20`.
137    #[error("json error: {0}")]
138    Json(#[from] serde_json::Error),
139
140    /// Another instance is already running and holds the advisory lock. Maps to exit code `75`.
141    ///
142    /// Use `--allow-parallel` to skip the lock or `--wait-lock SECONDS` to retry.
143    #[error("lock busy: {0}")]
144    LockBusy(String),
145
146    /// All concurrency slots are occupied after the wait timeout. Maps to exit code `75`.
147    ///
148    /// Occurs when [`crate::constants::MAX_CONCURRENT_CLI_INSTANCES`] instances are already
149    /// active and the wait limit [`crate::constants::CLI_LOCK_DEFAULT_WAIT_SECS`] is exhausted.
150    #[error(
151        "all {max} concurrency slots occupied after waiting {waited_secs}s (exit 75); \
152         use --max-concurrency or wait for other invocations to finish"
153    )]
154    AllSlotsFull { max: usize, waited_secs: u64 },
155
156    /// A heavy long-running job is already running for this job_type/namespace
157    /// pair. Maps to exit code `75` (the same `EX_TEMPFAIL` code used by the
158    /// CLI semaphore).
159    ///
160    /// G28-B (v1.0.68): ensures at most one `enrich`, `ingest --mode
161    /// claude-code`, or `ingest --mode codex` runs at a time per namespace.
162    /// Use `--wait-job-singleton <SECONDS>` (per-command) to poll until the
163    /// other invocation finishes.
164    #[error(
165        "job {job_type} for namespace '{namespace}' is already running (exit 75); \
166         wait for it to finish or pass --wait-job-singleton <SECONDS>"
167    )]
168    JobSingletonLocked { job_type: String, namespace: String },
169
170    /// G45: an LLM embedding operation is already running against the
171    /// same `(namespace, db)` pair in another process. Exit code 75
172    /// (retryable). The caller can pass `--wait-embed-singleton
173    /// <SECONDS>` to poll until the lock drops.
174    #[error(
175        "embedding singleton for namespace '{namespace}' is already held (exit 75); \
176         another CLI is calling the LLM on this database; pass --wait-embed-singleton <SECONDS> to wait"
177    )]
178    EmbeddingSingletonLocked { namespace: String },
179
180    /// Available memory is below the minimum required to load the model. Maps to exit code `77`.
181    ///
182    /// Returned when `sysinfo` reports available memory below
183    /// [`crate::constants::MIN_AVAILABLE_MEMORY_MB`] MiB before starting the ONNX model load.
184    #[error(
185        "available memory ({available_mb}MB) below required minimum ({required_mb}MB) \
186         to load the model; abort other loads or use --skip-memory-guard (exit 77)"
187    )]
188    LowMemory { available_mb: u64, required_mb: u64 },
189
190    /// v1.0.82 (GAP-002 final): shutdown was requested via SIGINT, SIGTERM or
191    /// SIGHUP before the current command completed. Maps to exit code
192    /// [`crate::constants::SHUTDOWN_EXIT_CODE`] (19).
193    ///
194    /// The signal name is preserved in the `signal` field so the JSON
195    /// envelope emitted before exit can route the operator to a
196    /// deterministic branch. Distinct from the legacy `128 + signal`
197    /// Unix convention (130/143/129) so LLM agents can match on a
198    /// single code for "cancelled by user".
199    #[error("shutdown signal received: {signal}")]
200    Shutdown { signal: String },
201
202    /// v1.0.87 (GAP-META-005, ADR-0045): pre-flight validation gate
203    /// rejected the spawn before fork. Maps to exit code `16`.
204    ///
205    /// The `source` field carries the structured [`PreFlightError`]
206    /// variant so callers and operators can route on the specific
207    /// failure class (BinaryNotFound, ArgvExceedsArgMax,
208    /// McpConfigInlineJsonRejected, McpConfigPathMissing,
209    /// McpConfigPathInvalidJson, WalkUpMcpJsonInvalid,
210    /// OutputBufferTooSmall, ClaudeConfigDirNotEmpty) instead of
211    /// parsing the legacy `detail: String` representation.
212    ///
213    /// This variant is **permanent** — retrying the same argv will fail
214    /// identically. Operators must fix the underlying condition (install
215    /// the binary, shorten the body, override `CLAUDE_CONFIG_DIR`,
216    /// substitute the inline `--mcp-config '{}'` for a tempfile path,
217    /// etc.) before retrying.
218    #[error("preflight validation failed: {source}")]
219    PreFlightFailed { source: Box<PreFlightError> },
220
221    /// v1.0.97 (GAP-SG-01/03): the OpenRouter provider returned a structured
222    /// error object (an `error` field carrying `code` and `message`), often
223    /// inside an HTTP 200 body (e.g. token/context-length overflow). Maps to
224    /// exit code `1`.
225    ///
226    /// Modelling the provider rejection as a typed variant — instead of the
227    /// generic `Embedding`/`Validation` string — stops the optimistic success
228    /// parse from masking the cause with a misleading missing-field error. The
229    /// `code` and `message` carry the REAL provider diagnostics.
230    ///
231    /// This variant is **permanent**: a structured provider error in a success
232    /// body is a content or configuration rejection that retrying the identical
233    /// request will not fix. Genuine rate limiting surfaces as HTTP 429 and is
234    /// retried inside the HTTP client (then exposed via `RateLimited` when
235    /// attempts are exhausted), so it never reaches callers as `ProviderError`.
236    #[error("provider error (code {code}): {message}")]
237    ProviderError { code: String, message: String },
238}
239
240/// Bridges the structured [`PreFlightError`] produced by the
241/// pre-flight validation gate (v1.0.87, ADR-0045) into the unified
242/// [`AppError`] envelope. Lets spawners use the `?` operator instead
243/// of hand-rolling `AppError::PreFlightFailed { source: ... }` at every
244/// call site, and keeps the variant alive as the canonical exit code 16
245/// path rather than the dead code it was at v1.0.87.
246impl From<PreFlightError> for AppError {
247    fn from(source: PreFlightError) -> Self {
248        AppError::PreFlightFailed {
249            source: Box::new(source),
250        }
251    }
252}
253
254impl AppError {
255    /// Returns the deterministic process exit code for this error variant.
256    ///
257    /// The codes follow the contract documented in the README: `1` for
258    /// validation, `9` for duplicates (moved from `2` in v1.0.52), `3` for conflicts, `4` for missing
259    /// records, `5` for namespace errors, `6` for limit violations, `10`–`14`
260    /// for infrastructure failures, `13` for BatchPartialFailure (PRD 1822),
261    /// `15` for DbBusy (migrated from `13` in v2.0.0), `20` for internal errors,
262    /// `75` (EX_TEMPFAIL) when the advisory CLI lock is held or all concurrency
263    /// slots are exhausted, and `77` when available memory is insufficient to
264    /// load the embedding model.
265    ///
266    /// # Examples
267    ///
268    /// ```
269    /// use sqlite_graphrag::errors::AppError;
270    ///
271    /// assert_eq!(AppError::Validation("invalid field".into()).exit_code(), 1);
272    /// assert_eq!(AppError::Duplicate("ns/mem".into()).exit_code(), 9);
273    /// assert_eq!(AppError::Conflict("ts changed".into()).exit_code(), 3);
274    /// assert_eq!(AppError::NotFound("id 42".into()).exit_code(), 4);
275    /// assert_eq!(AppError::NamespaceError("no marker".into()).exit_code(), 5);
276    /// assert_eq!(AppError::LimitExceeded("body too large".into()).exit_code(), 6);
277    /// assert_eq!(AppError::Embedding("wrong dim".into()).exit_code(), 11);
278    /// assert_eq!(AppError::DbBusy("retries exhausted".into()).exit_code(), 15);
279    /// assert_eq!(AppError::LockBusy("another instance".into()).exit_code(), 75);
280    /// ```
281    #[inline]
282    #[must_use]
283    pub fn exit_code(&self) -> i32 {
284        match self {
285            Self::Validation(_) => 1,
286            Self::BinaryNotFound { .. } => 1,
287            Self::RateLimited { .. } => 1,
288            Self::Timeout { .. } => 1,
289            Self::Duplicate(_) => crate::constants::DUPLICATE_EXIT_CODE,
290            Self::Conflict(_) => 3,
291            Self::NotFound(_) => 4,
292            Self::MemoryNotFound { .. } => 4,
293            Self::MemoryNotFoundById { .. } => 4,
294            Self::EntityNotYetMaterialized { .. } => 4,
295            Self::NamespaceError(_) => 5,
296            Self::LimitExceeded(_) => 6,
297            Self::Database(_) => 10,
298            Self::Embedding(_) => 11,
299            Self::VecExtension(_) => 12,
300            Self::BatchPartialFailure { .. } => crate::constants::BATCH_PARTIAL_FAILURE_EXIT_CODE,
301            Self::DbBusy(_) => crate::constants::DB_BUSY_EXIT_CODE,
302            Self::Io(_) => 14,
303            Self::Internal(_) => 20,
304            Self::Json(_) => 20,
305            Self::LockBusy(_) => crate::constants::CLI_LOCK_EXIT_CODE,
306            Self::AllSlotsFull { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
307            Self::JobSingletonLocked { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
308            Self::EmbeddingSingletonLocked { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
309            Self::LowMemory { .. } => crate::constants::LOW_MEMORY_EXIT_CODE,
310            Self::Shutdown { .. } => crate::constants::SHUTDOWN_EXIT_CODE,
311            Self::PreFlightFailed { .. } => 16,
312            Self::ProviderError { .. } => 1,
313        }
314    }
315
316    /// Returns `true` when the error is transient and the operation may
317    /// succeed on retry with backoff.
318    ///
319    /// # Examples
320    ///
321    /// ```
322    /// use sqlite_graphrag::errors::AppError;
323    ///
324    /// assert!(AppError::DbBusy("busy".into()).is_retryable());
325    /// assert!(AppError::LockBusy("held".into()).is_retryable());
326    /// assert!(!AppError::NotFound("x".into()).is_retryable());
327    /// assert!(!AppError::Validation("bad".into()).is_retryable());
328    /// ```
329    #[inline]
330    #[must_use]
331    pub fn is_retryable(&self) -> bool {
332        matches!(
333            self,
334            Self::DbBusy(_)
335                | Self::LockBusy(_)
336                | Self::AllSlotsFull { .. }
337                | Self::JobSingletonLocked { .. }
338                | Self::EmbeddingSingletonLocked { .. }
339                | Self::LowMemory { .. }
340                | Self::RateLimited { .. }
341                | Self::Timeout { .. }
342                | Self::EntityNotYetMaterialized { .. }
343        )
344    }
345
346    /// Returns `true` when shutdown was requested by the user via signal.
347    ///
348    /// Distinct from `is_permanent` because shutdown is a USER intent, not
349    /// a state to retry against. The operation should be retried with
350    /// `--resume` (GAP-001) when the persisted staging row still exists.
351    ///
352    /// # Examples
353    ///
354    /// ```
355    /// use sqlite_graphrag::errors::AppError;
356    ///
357    /// assert!(AppError::Shutdown { signal: "SIGINT".into() }.is_shutdown());
358    /// assert!(!AppError::Validation("x".into()).is_shutdown());
359    /// ```
360    #[inline]
361    #[must_use]
362    pub fn is_shutdown(&self) -> bool {
363        matches!(self, Self::Shutdown { .. })
364    }
365
366    /// Returns `true` when the error is permanent and must NOT be retried.
367    ///
368    /// Complement to [`Self::is_retryable`]. Errors not classified by either
369    /// method (e.g. `Database`, `Io`, `Internal`) are ambiguous — the caller
370    /// decides based on context.
371    ///
372    /// # Examples
373    ///
374    /// ```
375    /// use sqlite_graphrag::errors::AppError;
376    ///
377    /// assert!(AppError::Validation("bad".into()).is_permanent());
378    /// assert!(!AppError::DbBusy("busy".into()).is_permanent());
379    /// ```
380    #[inline]
381    #[must_use]
382    pub fn is_permanent(&self) -> bool {
383        matches!(
384            self,
385            Self::Validation(_)
386                | Self::BinaryNotFound { .. }
387                | Self::Duplicate(_)
388                | Self::NotFound(_)
389                | Self::MemoryNotFound { .. }
390                | Self::MemoryNotFoundById { .. }
391                | Self::NamespaceError(_)
392                | Self::LimitExceeded(_)
393                | Self::VecExtension(_)
394                | Self::PreFlightFailed { .. }
395                | Self::ProviderError { .. }
396        )
397    }
398
399    /// GAP-SG-39: returns an actionable remediation hint for the error, surfaced
400    /// in the stdout error envelope as the `suggestion` field. The hint tells the
401    /// operator HOW to recover instead of leaving an exit code without guidance —
402    /// this is what makes a write rejection (e.g. a malformed name) observable and
403    /// fixable. Returns `None` for variants whose own message is already
404    /// self-remediating.
405    #[must_use]
406    pub fn suggestion(&self) -> Option<&'static str> {
407        match self {
408            Self::Validation(_) => Some(
409                "review the input against the command's --help; names must be kebab-case (lowercase letters, digits, hyphens) and bodies non-empty",
410            ),
411            Self::Duplicate(_) => {
412                Some("pass --force-merge to update the existing memory instead of failing")
413            }
414            Self::Conflict(_) => Some(
415                "another writer changed the row; re-read with `read --name <n> --json` and retry with a fresh --expected-updated-at",
416            ),
417            Self::NotFound(_) | Self::MemoryNotFound { .. } | Self::MemoryNotFoundById { .. } => {
418                Some("verify the name/id and namespace with `list --json` or `read --name <n> --json`")
419            }
420            Self::NamespaceError(_) => {
421                Some("set --namespace or SQLITE_GRAPHRAG_NAMESPACE; inspect with `namespace-detect --json`")
422            }
423            Self::LimitExceeded(_) => {
424                Some("split the input into smaller memories or raise the documented cap before retrying")
425            }
426            Self::Embedding(_) => Some(
427                "verify the embedding backend and OPENROUTER_API_KEY; re-run `enrich --operation re-embed` once resolved",
428            ),
429            Self::Database(_) | Self::DbBusy(_) => {
430                Some("run `health --json` then `vacuum --json`; widen --wait-lock if the database is busy")
431            }
432            Self::Io(_) => Some("check the path exists and is writable, then retry"),
433            Self::RateLimited { .. } => {
434                Some("wait for the reported retry-after window, then retry")
435            }
436            Self::LockBusy(_) | Self::AllSlotsFull { .. } | Self::JobSingletonLocked { .. } => {
437                Some("wait for the other invocation to finish or pass --wait-lock / --wait-job-singleton")
438            }
439            _ => None,
440        }
441    }
442
443    /// Returns the localized error message in the active language (`--lang` / `SQLITE_GRAPHRAG_LANG`).
444    ///
445    /// In English the text is identical to the `Display` generated by thiserror.
446    /// In Portuguese the prefixes and messages are translated to PT-BR.
447    pub fn localized_message(&self) -> String {
448        self.localized_message_for(current())
449    }
450
451    /// Returns the localized message for the explicitly provided language.
452    /// Useful in tests that cannot depend on the global `OnceLock`.
453    ///
454    /// # Examples
455    ///
456    /// ```
457    /// use sqlite_graphrag::errors::AppError;
458    /// use sqlite_graphrag::i18n::Language;
459    ///
460    /// let err = AppError::NotFound("mem-xyz".into());
461    ///
462    /// let en = err.localized_message_for(Language::English);
463    /// assert!(en.contains("not found"));
464    ///
465    /// let pt = err.localized_message_for(Language::Portuguese);
466    /// assert!(pt.contains("n\u{e3}o encontrado"));
467    /// ```
468    pub fn localized_message_for(&self, lang: Language) -> String {
469        match lang {
470            Language::English => self.to_string(),
471            Language::Portuguese => self.to_string_pt(),
472        }
473    }
474
475    fn to_string_pt(&self) -> String {
476        use crate::i18n::validation::app_error_pt as pt;
477        match self {
478            Self::Validation(msg) => pt::validation(msg),
479            Self::BinaryNotFound { name } => pt::binary_not_found(name),
480            Self::RateLimited { detail } => pt::rate_limited(detail),
481            Self::Timeout {
482                operation,
483                duration_secs,
484            } => pt::timeout(operation, *duration_secs),
485            Self::Duplicate(msg) => pt::duplicate(msg),
486            Self::Conflict(msg) => pt::conflict(msg),
487            Self::NotFound(msg) => pt::not_found(msg),
488            Self::MemoryNotFound { name, namespace } => pt::memory_not_found(name, namespace),
489            Self::MemoryNotFoundById { id } => pt::memory_not_found_by_id(*id),
490            Self::EntityNotYetMaterialized { name, namespace } => {
491                pt::entity_not_yet_materialized(name, namespace)
492            }
493            Self::NamespaceError(msg) => pt::namespace_error(msg),
494            Self::LimitExceeded(msg) => pt::limit_exceeded(msg),
495            Self::Database(e) => pt::database(&e.to_string()),
496            Self::Embedding(msg) => pt::embedding(msg),
497            Self::VecExtension(msg) => pt::vec_extension(msg),
498            Self::DbBusy(msg) => pt::db_busy(msg),
499            Self::BatchPartialFailure { total, failed } => {
500                pt::batch_partial_failure(*total, *failed)
501            }
502            Self::Io(e) => pt::io(&e.to_string()),
503            Self::Internal(e) => pt::internal(&e.to_string()),
504            Self::Json(e) => pt::json(&e.to_string()),
505            Self::LockBusy(msg) => pt::lock_busy(msg),
506            Self::AllSlotsFull { max, waited_secs } => pt::all_slots_full(*max, *waited_secs),
507            Self::JobSingletonLocked {
508                job_type,
509                namespace,
510            } => pt::job_singleton_locked(job_type, namespace),
511            Self::EmbeddingSingletonLocked { namespace } => {
512                pt::embedding_singleton_locked(namespace)
513            }
514            Self::LowMemory {
515                available_mb,
516                required_mb,
517            } => pt::low_memory(*available_mb, *required_mb),
518            Self::Shutdown { signal } => pt::shutdown(signal),
519            Self::PreFlightFailed { source } => pt::preflight_failed(&source.to_string()),
520            Self::ProviderError { code, message } => pt::provider_error(code, message),
521        }
522    }
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528    use std::io;
529
530    #[test]
531    fn exit_code_validation_returns_1() {
532        assert_eq!(AppError::Validation("invalid field".into()).exit_code(), 1);
533    }
534
535    // GAP-SG-39: actionable errors carry a remediation suggestion.
536    #[test]
537    fn suggestion_present_for_actionable_variants() {
538        assert!(AppError::Validation("bad name".into())
539            .suggestion()
540            .is_some());
541        let dup = AppError::Duplicate("global/x".into());
542        assert!(dup.suggestion().unwrap().contains("--force-merge"));
543        let nf = AppError::MemoryNotFound {
544            name: "x".into(),
545            namespace: "global".into(),
546        };
547        assert!(nf.suggestion().is_some());
548    }
549
550    #[test]
551    fn exit_code_duplicate_returns_9() {
552        assert_eq!(AppError::Duplicate("namespace/name".into()).exit_code(), 9);
553    }
554
555    #[test]
556    fn exit_code_conflict_returns_3() {
557        assert_eq!(
558            AppError::Conflict("updated_at changed".into()).exit_code(),
559            3
560        );
561    }
562
563    #[test]
564    fn exit_code_not_found_returns_4() {
565        assert_eq!(AppError::NotFound("memory missing".into()).exit_code(), 4);
566    }
567
568    #[test]
569    fn exit_code_namespace_error_returns_5() {
570        assert_eq!(
571            AppError::NamespaceError("not resolved".into()).exit_code(),
572            5
573        );
574    }
575
576    #[test]
577    fn exit_code_limit_exceeded_returns_6() {
578        assert_eq!(
579            AppError::LimitExceeded("body too large".into()).exit_code(),
580            6
581        );
582    }
583
584    #[test]
585    fn exit_code_embedding_returns_11() {
586        assert_eq!(AppError::Embedding("model failure".into()).exit_code(), 11);
587    }
588
589    #[test]
590    fn exit_code_vec_extension_returns_12() {
591        assert_eq!(
592            AppError::VecExtension("extension did not load".into()).exit_code(),
593            12
594        );
595    }
596
597    #[test]
598    fn exit_code_db_busy_returns_15() {
599        assert_eq!(AppError::DbBusy("retries exhausted".into()).exit_code(), 15);
600    }
601
602    #[test]
603    fn exit_code_batch_partial_failure_returns_13() {
604        assert_eq!(
605            AppError::BatchPartialFailure {
606                total: 10,
607                failed: 3
608            }
609            .exit_code(),
610            13
611        );
612    }
613
614    #[test]
615    fn display_batch_partial_failure_includes_counts() {
616        let err = AppError::BatchPartialFailure {
617            total: 50,
618            failed: 7,
619        };
620        let msg = err.to_string();
621        assert!(msg.contains("7"));
622        assert!(msg.contains("50"));
623        // to_string() uses the English #[error] attr; PT is in localized_message_for
624        assert!(msg.contains("batch partial failure"));
625    }
626
627    #[test]
628    fn exit_code_io_returns_14() {
629        let io_err = io::Error::new(io::ErrorKind::NotFound, "file missing");
630        assert_eq!(AppError::Io(io_err).exit_code(), 14);
631    }
632
633    #[test]
634    fn exit_code_internal_returns_20() {
635        let anyhow_err = anyhow::anyhow!("unexpected internal error");
636        assert_eq!(AppError::Internal(anyhow_err).exit_code(), 20);
637    }
638
639    #[test]
640    fn exit_code_json_returns_20() {
641        let json_err = serde_json::from_str::<serde_json::Value>("invalid json {{").unwrap_err();
642        assert_eq!(AppError::Json(json_err).exit_code(), 20);
643    }
644
645    #[test]
646    fn exit_code_lock_busy_returns_75() {
647        assert_eq!(
648            AppError::LockBusy("another active instance".into()).exit_code(),
649            75
650        );
651    }
652
653    #[test]
654    fn display_validation_includes_message() {
655        let err = AppError::Validation("invalid id".into());
656        assert!(err.to_string().contains("invalid id"));
657        assert!(err.to_string().contains("validation error"));
658    }
659
660    #[test]
661    fn display_duplicate_includes_message() {
662        let err = AppError::Duplicate("proj/mem".into());
663        assert!(err.to_string().contains("proj/mem"));
664        assert!(err.to_string().contains("duplicate detected"));
665    }
666
667    #[test]
668    fn display_not_found_includes_message() {
669        let err = AppError::NotFound("id 42".into());
670        assert!(err.to_string().contains("id 42"));
671        assert!(err.to_string().contains("not found"));
672    }
673
674    #[test]
675    fn display_embedding_includes_message() {
676        let err = AppError::Embedding("wrong dimension".into());
677        assert!(err.to_string().contains("wrong dimension"));
678        assert!(err.to_string().contains("embedding error"));
679    }
680
681    #[test]
682    fn display_lock_busy_includes_message() {
683        let err = AppError::LockBusy("pid 1234".into());
684        assert!(err.to_string().contains("pid 1234"));
685        assert!(err.to_string().contains("lock busy"));
686    }
687
688    #[test]
689    fn from_io_error_converts_correctly() {
690        let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "permission denied");
691        let app_err: AppError = io_err.into();
692        assert_eq!(app_err.exit_code(), 14);
693        assert!(app_err.to_string().contains("IO error"));
694    }
695
696    #[test]
697    fn from_anyhow_error_converts_correctly() {
698        let anyhow_err = anyhow::anyhow!("internal detail");
699        let app_err: AppError = anyhow_err.into();
700        assert_eq!(app_err.exit_code(), 20);
701        assert!(app_err.to_string().contains("internal detail"));
702    }
703
704    #[test]
705    fn from_serde_json_error_converts_correctly() {
706        let json_err = serde_json::from_str::<serde_json::Value>("{bad_field}").unwrap_err();
707        let app_err: AppError = json_err.into();
708        assert_eq!(app_err.exit_code(), 20);
709        assert!(app_err.to_string().contains("json error"));
710    }
711
712    #[test]
713    fn exit_code_lock_busy_matches_constant() {
714        assert_eq!(
715            AppError::LockBusy("test".into()).exit_code(),
716            crate::constants::CLI_LOCK_EXIT_CODE
717        );
718    }
719
720    #[test]
721    fn localized_message_en_equals_to_string() {
722        let err = AppError::NotFound("mem-x".into());
723        assert_eq!(
724            err.localized_message_for(crate::i18n::Language::English),
725            err.to_string()
726        );
727    }
728
729    // Detailed Portuguese-specific assertions live in `src/i18n.rs`
730    // (the bilingual module). Here we only verify that delegation is wired
731    // correctly, without embedding PT strings in this English-only file.
732
733    #[test]
734    fn localized_message_pt_differs_from_en() {
735        let err = AppError::NotFound("mem-x".into());
736        let en = err.localized_message_for(crate::i18n::Language::English);
737        let pt = err.localized_message_for(crate::i18n::Language::Portuguese);
738        assert_ne!(en, pt, "PT and EN must produce distinct messages");
739        assert!(pt.contains("mem-x"), "PT must include the variant payload");
740    }
741
742    #[test]
743    fn localized_message_pt_delegates_to_app_error_pt_helper() {
744        use crate::i18n::validation::app_error_pt as pt;
745
746        let cases: Vec<(AppError, String)> = vec![
747            (AppError::Validation("x".into()), pt::validation("x")),
748            (AppError::Duplicate("x".into()), pt::duplicate("x")),
749            (AppError::Conflict("x".into()), pt::conflict("x")),
750            (AppError::NotFound("x".into()), pt::not_found("x")),
751            (
752                AppError::NamespaceError("x".into()),
753                pt::namespace_error("x"),
754            ),
755            (AppError::LimitExceeded("x".into()), pt::limit_exceeded("x")),
756            (AppError::Embedding("x".into()), pt::embedding("x")),
757            (AppError::VecExtension("x".into()), pt::vec_extension("x")),
758            (AppError::DbBusy("x".into()), pt::db_busy("x")),
759            (
760                AppError::BatchPartialFailure {
761                    total: 10,
762                    failed: 3,
763                },
764                pt::batch_partial_failure(10, 3),
765            ),
766            (AppError::LockBusy("x".into()), pt::lock_busy("x")),
767            (
768                AppError::AllSlotsFull {
769                    max: 4,
770                    waited_secs: 60,
771                },
772                pt::all_slots_full(4, 60),
773            ),
774            (
775                AppError::LowMemory {
776                    available_mb: 100,
777                    required_mb: 500,
778                },
779                pt::low_memory(100, 500),
780            ),
781            (
782                AppError::BinaryNotFound {
783                    name: "claude".into(),
784                },
785                pt::binary_not_found("claude"),
786            ),
787            (
788                AppError::RateLimited {
789                    detail: "429".into(),
790                },
791                pt::rate_limited("429"),
792            ),
793            (
794                AppError::Timeout {
795                    operation: "op".into(),
796                    duration_secs: 30,
797                },
798                pt::timeout("op", 30),
799            ),
800        ];
801
802        for (err, expected) in cases {
803            let actual = err.localized_message_for(crate::i18n::Language::Portuguese);
804            assert_eq!(actual, expected, "delegation mismatch");
805        }
806    }
807
808    #[test]
809    fn is_retryable_transient_errors() {
810        assert!(AppError::DbBusy("x".into()).is_retryable());
811        assert!(AppError::LockBusy("x".into()).is_retryable());
812        assert!(AppError::AllSlotsFull {
813            max: 4,
814            waited_secs: 60
815        }
816        .is_retryable());
817        assert!(AppError::LowMemory {
818            available_mb: 100,
819            required_mb: 500
820        }
821        .is_retryable());
822        assert!(AppError::RateLimited {
823            detail: "429".into()
824        }
825        .is_retryable());
826        assert!(AppError::Timeout {
827            operation: "op".into(),
828            duration_secs: 30
829        }
830        .is_retryable());
831    }
832
833    #[test]
834    fn is_retryable_permanent_errors() {
835        assert!(!AppError::Validation("x".into()).is_retryable());
836        assert!(!AppError::NotFound("x".into()).is_retryable());
837        assert!(!AppError::Duplicate("x".into()).is_retryable());
838        assert!(!AppError::Conflict("x".into()).is_retryable());
839        assert!(!AppError::BinaryNotFound { name: "x".into() }.is_retryable());
840    }
841
842    #[test]
843    fn exit_code_new_variants() {
844        assert_eq!(AppError::BinaryNotFound { name: "x".into() }.exit_code(), 1);
845        assert_eq!(AppError::RateLimited { detail: "x".into() }.exit_code(), 1);
846        assert_eq!(
847            AppError::Timeout {
848                operation: "x".into(),
849                duration_secs: 5
850            }
851            .exit_code(),
852            1
853        );
854    }
855
856    // GAP-SG-78: EntityNotYetMaterialized is a transitory absence (the entity is
857    // materialized on a later enrich pass), NOT a terminal not-found.
858    #[test]
859    fn entity_not_yet_materialized_exit_code_is_4() {
860        let e = AppError::EntityNotYetMaterialized {
861            name: "acme".into(),
862            namespace: "global".into(),
863        };
864        assert_eq!(e.exit_code(), 4);
865    }
866
867    #[test]
868    fn entity_not_yet_materialized_is_retryable_not_permanent() {
869        let e = AppError::EntityNotYetMaterialized {
870            name: "acme".into(),
871            namespace: "global".into(),
872        };
873        assert!(e.is_retryable());
874        assert!(!e.is_permanent());
875    }
876
877    #[test]
878    fn entity_not_yet_materialized_user_message_non_empty() {
879        let e = AppError::EntityNotYetMaterialized {
880            name: "acme".into(),
881            namespace: "global".into(),
882        };
883        assert!(!e
884            .localized_message_for(crate::i18n::Language::English)
885            .is_empty());
886        assert!(!e
887            .localized_message_for(crate::i18n::Language::Portuguese)
888            .is_empty());
889    }
890
891    #[test]
892    fn app_error_size_does_not_exceed_budget() {
893        let size = std::mem::size_of::<AppError>();
894        assert!(
895            size <= 128,
896            "AppError is {size} bytes — exceeds 128-byte budget; \
897             consider boxing large variants to reduce memcpy cost in Result propagation"
898        );
899    }
900}