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    /// External binary required for operation was not found in PATH. Maps to exit code `1`.
33    #[error("binary not found: {name} — ensure it is installed and in PATH")]
34    BinaryNotFound { name: String },
35
36    /// Remote service signaled rate limiting; caller should retry with backoff. Maps to exit code `1`.
37    #[error("rate limited: {detail}")]
38    RateLimited { detail: String },
39
40    /// Operation exceeded its time budget. Maps to exit code `1`.
41    #[error("timeout after {duration_secs}s: {operation}")]
42    Timeout {
43        operation: String,
44        duration_secs: u64,
45    },
46
47    /// A memory or entity with the same `(namespace, name)` already exists. Maps to exit code `9`.
48    #[error("duplicate detected: {0}")]
49    Duplicate(String),
50
51    /// Optimistic update lost the race because `updated_at` changed. Maps to exit code `3`.
52    #[error("conflict: {0}")]
53    Conflict(String),
54
55    /// The requested record does not exist or was soft-deleted. Maps to exit code `4`.
56    #[error("not found: {0}")]
57    NotFound(String),
58
59    /// Memory lookup by `(namespace, name)` returned no row. Maps to exit code `4`.
60    ///
61    /// G55 S2 (v1.0.80): structural variant that carries the requested identifier
62    /// and namespace, eliminating the "not found: unknown in namespace 'X'" class
63    /// of bugs that masked which lookup target failed. The display format matches
64    /// the legacy string-based `NotFound` so the i18n replace-chain and external
65    /// scripts that pattern-match on `memory not found: name='N' in namespace 'NS'`
66    /// keep working.
67    #[error("memory not found: name='{name}' in namespace '{namespace}'")]
68    MemoryNotFound { name: String, namespace: String },
69
70    /// Memory lookup by integer `id` returned no row. Maps to exit code `4`.
71    #[error("memory not found: id={id}")]
72    MemoryNotFoundById { id: i64 },
73
74    /// Namespace could not be resolved from flag, environment or markers. Maps to exit code `5`.
75    #[error("namespace not resolved: {0}")]
76    NamespaceError(String),
77
78    /// Payload exceeded one of the configured body, name or batch limits. Maps to exit code `6`.
79    #[error("limit exceeded: {0}")]
80    LimitExceeded(String),
81
82    /// Low-level SQLite error propagated from `rusqlite`. Maps to exit code `10`.
83    #[error("database error: {0}")]
84    Database(#[from] rusqlite::Error),
85
86    /// Embedding generation via `fastembed` failed or produced the wrong shape. Maps to exit code `11`.
87    #[error("embedding error: {0}")]
88    Embedding(String),
89
90    /// The `sqlite-vec` extension could not load or register its virtual table. Maps to exit code `12`.
91    #[error("sqlite-vec extension failed: {0}")]
92    VecExtension(String),
93
94    /// 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).
95    #[error("database busy: {0}")]
96    DbBusy(String),
97
98    /// Batch operation failed partially — N of M items failed. Maps to exit code `13` (PRD 1822).
99    ///
100    /// Reserved for use in `import`, `reindex` and batch stdin (BLOCK 3/4). Variant present
101    /// since v2.0.0 even if call-sites do not yet exist — stable exit code mapping.
102    #[error("batch partial failure: {failed} of {total} items failed")]
103    BatchPartialFailure { total: usize, failed: usize },
104
105    /// Filesystem I/O error while reading or writing the database or cache. Maps to exit code `14`.
106    #[error("IO error: {0}")]
107    Io(#[from] std::io::Error),
108
109    /// Unexpected internal error surfaced through `anyhow`. Maps to exit code `20`.
110    #[error(transparent)]
111    Internal(#[from] anyhow::Error),
112
113    /// JSON serialization or deserialization failure. Maps to exit code `20`.
114    #[error("json error: {0}")]
115    Json(#[from] serde_json::Error),
116
117    /// Another instance is already running and holds the advisory lock. Maps to exit code `75`.
118    ///
119    /// Use `--allow-parallel` to skip the lock or `--wait-lock SECONDS` to retry.
120    #[error("lock busy: {0}")]
121    LockBusy(String),
122
123    /// All concurrency slots are occupied after the wait timeout. Maps to exit code `75`.
124    ///
125    /// Occurs when [`crate::constants::MAX_CONCURRENT_CLI_INSTANCES`] instances are already
126    /// active and the wait limit [`crate::constants::CLI_LOCK_DEFAULT_WAIT_SECS`] is exhausted.
127    #[error(
128        "all {max} concurrency slots occupied after waiting {waited_secs}s (exit 75); \
129         use --max-concurrency or wait for other invocations to finish"
130    )]
131    AllSlotsFull { max: usize, waited_secs: u64 },
132
133    /// A heavy long-running job is already running for this job_type/namespace
134    /// pair. Maps to exit code `75` (the same `EX_TEMPFAIL` code used by the
135    /// CLI semaphore).
136    ///
137    /// G28-B (v1.0.68): ensures at most one `enrich`, `ingest --mode
138    /// claude-code`, or `ingest --mode codex` runs at a time per namespace.
139    /// Use `--wait-job-singleton <SECONDS>` (per-command) to poll until the
140    /// other invocation finishes.
141    #[error(
142        "job {job_type} for namespace '{namespace}' is already running (exit 75); \
143         wait for it to finish or pass --wait-job-singleton <SECONDS>"
144    )]
145    JobSingletonLocked { job_type: String, namespace: String },
146
147    /// G45: an LLM embedding operation is already running against the
148    /// same `(namespace, db)` pair in another process. Exit code 75
149    /// (retryable). The caller can pass `--wait-embed-singleton
150    /// <SECONDS>` to poll until the lock drops.
151    #[error(
152        "embedding singleton for namespace '{namespace}' is already held (exit 75); \
153         another CLI is calling the LLM on this database; pass --wait-embed-singleton <SECONDS> to wait"
154    )]
155    EmbeddingSingletonLocked { namespace: String },
156
157    /// Available memory is below the minimum required to load the model. Maps to exit code `77`.
158    ///
159    /// Returned when `sysinfo` reports available memory below
160    /// [`crate::constants::MIN_AVAILABLE_MEMORY_MB`] MiB before starting the ONNX model load.
161    #[error(
162        "available memory ({available_mb}MB) below required minimum ({required_mb}MB) \
163         to load the model; abort other loads or use --skip-memory-guard (exit 77)"
164    )]
165    LowMemory { available_mb: u64, required_mb: u64 },
166
167    /// v1.0.82 (GAP-002 final): shutdown was requested via SIGINT, SIGTERM or
168    /// SIGHUP before the current command completed. Maps to exit code
169    /// [`crate::constants::SHUTDOWN_EXIT_CODE`] (19).
170    ///
171    /// The signal name is preserved in the `signal` field so the JSON
172    /// envelope emitted before exit can route the operator to a
173    /// deterministic branch. Distinct from the legacy `128 + signal`
174    /// Unix convention (130/143/129) so LLM agents can match on a
175    /// single code for "cancelled by user".
176    #[error("shutdown signal received: {signal}")]
177    Shutdown { signal: String },
178}
179
180impl AppError {
181    /// Returns the deterministic process exit code for this error variant.
182    ///
183    /// The codes follow the contract documented in the README: `1` for
184    /// validation, `9` for duplicates (moved from `2` in v1.0.52), `3` for conflicts, `4` for missing
185    /// records, `5` for namespace errors, `6` for limit violations, `10`–`14`
186    /// for infrastructure failures, `13` for BatchPartialFailure (PRD 1822),
187    /// `15` for DbBusy (migrated from `13` in v2.0.0), `20` for internal errors,
188    /// `75` (EX_TEMPFAIL) when the advisory CLI lock is held or all concurrency
189    /// slots are exhausted, and `77` when available memory is insufficient to
190    /// load the embedding model.
191    ///
192    /// # Examples
193    ///
194    /// ```
195    /// use sqlite_graphrag::errors::AppError;
196    ///
197    /// assert_eq!(AppError::Validation("invalid field".into()).exit_code(), 1);
198    /// assert_eq!(AppError::Duplicate("ns/mem".into()).exit_code(), 9);
199    /// assert_eq!(AppError::Conflict("ts changed".into()).exit_code(), 3);
200    /// assert_eq!(AppError::NotFound("id 42".into()).exit_code(), 4);
201    /// assert_eq!(AppError::NamespaceError("no marker".into()).exit_code(), 5);
202    /// assert_eq!(AppError::LimitExceeded("body too large".into()).exit_code(), 6);
203    /// assert_eq!(AppError::Embedding("wrong dim".into()).exit_code(), 11);
204    /// assert_eq!(AppError::DbBusy("retries exhausted".into()).exit_code(), 15);
205    /// assert_eq!(AppError::LockBusy("another instance".into()).exit_code(), 75);
206    /// ```
207    #[inline]
208    #[must_use]
209    pub fn exit_code(&self) -> i32 {
210        match self {
211            Self::Validation(_) => 1,
212            Self::BinaryNotFound { .. } => 1,
213            Self::RateLimited { .. } => 1,
214            Self::Timeout { .. } => 1,
215            Self::Duplicate(_) => crate::constants::DUPLICATE_EXIT_CODE,
216            Self::Conflict(_) => 3,
217            Self::NotFound(_) => 4,
218            Self::MemoryNotFound { .. } => 4,
219            Self::MemoryNotFoundById { .. } => 4,
220            Self::NamespaceError(_) => 5,
221            Self::LimitExceeded(_) => 6,
222            Self::Database(_) => 10,
223            Self::Embedding(_) => 11,
224            Self::VecExtension(_) => 12,
225            Self::BatchPartialFailure { .. } => crate::constants::BATCH_PARTIAL_FAILURE_EXIT_CODE,
226            Self::DbBusy(_) => crate::constants::DB_BUSY_EXIT_CODE,
227            Self::Io(_) => 14,
228            Self::Internal(_) => 20,
229            Self::Json(_) => 20,
230            Self::LockBusy(_) => crate::constants::CLI_LOCK_EXIT_CODE,
231            Self::AllSlotsFull { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
232            Self::JobSingletonLocked { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
233            Self::EmbeddingSingletonLocked { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
234            Self::LowMemory { .. } => crate::constants::LOW_MEMORY_EXIT_CODE,
235            Self::Shutdown { .. } => crate::constants::SHUTDOWN_EXIT_CODE,
236        }
237    }
238
239    /// Returns `true` when the error is transient and the operation may
240    /// succeed on retry with backoff.
241    ///
242    /// # Examples
243    ///
244    /// ```
245    /// use sqlite_graphrag::errors::AppError;
246    ///
247    /// assert!(AppError::DbBusy("busy".into()).is_retryable());
248    /// assert!(AppError::LockBusy("held".into()).is_retryable());
249    /// assert!(!AppError::NotFound("x".into()).is_retryable());
250    /// assert!(!AppError::Validation("bad".into()).is_retryable());
251    /// ```
252    #[inline]
253    #[must_use]
254    pub fn is_retryable(&self) -> bool {
255        matches!(
256            self,
257            Self::DbBusy(_)
258                | Self::LockBusy(_)
259                | Self::AllSlotsFull { .. }
260                | Self::JobSingletonLocked { .. }
261                | Self::EmbeddingSingletonLocked { .. }
262                | Self::LowMemory { .. }
263                | Self::RateLimited { .. }
264                | Self::Timeout { .. }
265        )
266    }
267
268    /// Returns `true` when shutdown was requested by the user via signal.
269    ///
270    /// Distinct from `is_permanent` because shutdown is a USER intent, not
271    /// a state to retry against. The operation should be retried with
272    /// `--resume` (GAP-001) when the persisted staging row still exists.
273    ///
274    /// # Examples
275    ///
276    /// ```
277    /// use sqlite_graphrag::errors::AppError;
278    ///
279    /// assert!(AppError::Shutdown { signal: "SIGINT".into() }.is_shutdown());
280    /// assert!(!AppError::Validation("x".into()).is_shutdown());
281    /// ```
282    #[inline]
283    #[must_use]
284    pub fn is_shutdown(&self) -> bool {
285        matches!(self, Self::Shutdown { .. })
286    }
287
288    /// Returns `true` when the error is permanent and must NOT be retried.
289    ///
290    /// Complement to [`Self::is_retryable`]. Errors not classified by either
291    /// method (e.g. `Database`, `Io`, `Internal`) are ambiguous — the caller
292    /// decides based on context.
293    ///
294    /// # Examples
295    ///
296    /// ```
297    /// use sqlite_graphrag::errors::AppError;
298    ///
299    /// assert!(AppError::Validation("bad".into()).is_permanent());
300    /// assert!(!AppError::DbBusy("busy".into()).is_permanent());
301    /// ```
302    #[inline]
303    #[must_use]
304    pub fn is_permanent(&self) -> bool {
305        matches!(
306            self,
307            Self::Validation(_)
308                | Self::BinaryNotFound { .. }
309                | Self::Duplicate(_)
310                | Self::NotFound(_)
311                | Self::MemoryNotFound { .. }
312                | Self::MemoryNotFoundById { .. }
313                | Self::NamespaceError(_)
314                | Self::LimitExceeded(_)
315                | Self::VecExtension(_)
316        )
317    }
318
319    /// Returns the localized error message in the active language (`--lang` / `SQLITE_GRAPHRAG_LANG`).
320    ///
321    /// In English the text is identical to the `Display` generated by thiserror.
322    /// In Portuguese the prefixes and messages are translated to PT-BR.
323    pub fn localized_message(&self) -> String {
324        self.localized_message_for(current())
325    }
326
327    /// Returns the localized message for the explicitly provided language.
328    /// Useful in tests that cannot depend on the global `OnceLock`.
329    ///
330    /// # Examples
331    ///
332    /// ```
333    /// use sqlite_graphrag::errors::AppError;
334    /// use sqlite_graphrag::i18n::Language;
335    ///
336    /// let err = AppError::NotFound("mem-xyz".into());
337    ///
338    /// let en = err.localized_message_for(Language::English);
339    /// assert!(en.contains("not found"));
340    ///
341    /// let pt = err.localized_message_for(Language::Portuguese);
342    /// assert!(pt.contains("n\u{e3}o encontrado"));
343    /// ```
344    pub fn localized_message_for(&self, lang: Language) -> String {
345        match lang {
346            Language::English => self.to_string(),
347            Language::Portuguese => self.to_string_pt(),
348        }
349    }
350
351    fn to_string_pt(&self) -> String {
352        use crate::i18n::validation::app_error_pt as pt;
353        match self {
354            Self::Validation(msg) => pt::validation(msg),
355            Self::BinaryNotFound { name } => pt::binary_not_found(name),
356            Self::RateLimited { detail } => pt::rate_limited(detail),
357            Self::Timeout {
358                operation,
359                duration_secs,
360            } => pt::timeout(operation, *duration_secs),
361            Self::Duplicate(msg) => pt::duplicate(msg),
362            Self::Conflict(msg) => pt::conflict(msg),
363            Self::NotFound(msg) => pt::not_found(msg),
364            Self::MemoryNotFound { name, namespace } => pt::memory_not_found(name, namespace),
365            Self::MemoryNotFoundById { id } => pt::memory_not_found_by_id(*id),
366            Self::NamespaceError(msg) => pt::namespace_error(msg),
367            Self::LimitExceeded(msg) => pt::limit_exceeded(msg),
368            Self::Database(e) => pt::database(&e.to_string()),
369            Self::Embedding(msg) => pt::embedding(msg),
370            Self::VecExtension(msg) => pt::vec_extension(msg),
371            Self::DbBusy(msg) => pt::db_busy(msg),
372            Self::BatchPartialFailure { total, failed } => {
373                pt::batch_partial_failure(*total, *failed)
374            }
375            Self::Io(e) => pt::io(&e.to_string()),
376            Self::Internal(e) => pt::internal(&e.to_string()),
377            Self::Json(e) => pt::json(&e.to_string()),
378            Self::LockBusy(msg) => pt::lock_busy(msg),
379            Self::AllSlotsFull { max, waited_secs } => pt::all_slots_full(*max, *waited_secs),
380            Self::JobSingletonLocked {
381                job_type,
382                namespace,
383            } => pt::job_singleton_locked(job_type, namespace),
384            Self::EmbeddingSingletonLocked { namespace } => {
385                pt::embedding_singleton_locked(namespace)
386            }
387            Self::LowMemory {
388                available_mb,
389                required_mb,
390            } => pt::low_memory(*available_mb, *required_mb),
391            Self::Shutdown { signal } => pt::shutdown(signal),
392        }
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use std::io;
400
401    #[test]
402    fn exit_code_validation_returns_1() {
403        assert_eq!(AppError::Validation("invalid field".into()).exit_code(), 1);
404    }
405
406    #[test]
407    fn exit_code_duplicate_returns_9() {
408        assert_eq!(AppError::Duplicate("namespace/name".into()).exit_code(), 9);
409    }
410
411    #[test]
412    fn exit_code_conflict_returns_3() {
413        assert_eq!(
414            AppError::Conflict("updated_at changed".into()).exit_code(),
415            3
416        );
417    }
418
419    #[test]
420    fn exit_code_not_found_returns_4() {
421        assert_eq!(AppError::NotFound("memory missing".into()).exit_code(), 4);
422    }
423
424    #[test]
425    fn exit_code_namespace_error_returns_5() {
426        assert_eq!(
427            AppError::NamespaceError("not resolved".into()).exit_code(),
428            5
429        );
430    }
431
432    #[test]
433    fn exit_code_limit_exceeded_returns_6() {
434        assert_eq!(
435            AppError::LimitExceeded("body too large".into()).exit_code(),
436            6
437        );
438    }
439
440    #[test]
441    fn exit_code_embedding_returns_11() {
442        assert_eq!(AppError::Embedding("model failure".into()).exit_code(), 11);
443    }
444
445    #[test]
446    fn exit_code_vec_extension_returns_12() {
447        assert_eq!(
448            AppError::VecExtension("extension did not load".into()).exit_code(),
449            12
450        );
451    }
452
453    #[test]
454    fn exit_code_db_busy_returns_15() {
455        assert_eq!(AppError::DbBusy("retries exhausted".into()).exit_code(), 15);
456    }
457
458    #[test]
459    fn exit_code_batch_partial_failure_returns_13() {
460        assert_eq!(
461            AppError::BatchPartialFailure {
462                total: 10,
463                failed: 3
464            }
465            .exit_code(),
466            13
467        );
468    }
469
470    #[test]
471    fn display_batch_partial_failure_includes_counts() {
472        let err = AppError::BatchPartialFailure {
473            total: 50,
474            failed: 7,
475        };
476        let msg = err.to_string();
477        assert!(msg.contains("7"));
478        assert!(msg.contains("50"));
479        // to_string() uses the English #[error] attr; PT is in localized_message_for
480        assert!(msg.contains("batch partial failure"));
481    }
482
483    #[test]
484    fn exit_code_io_returns_14() {
485        let io_err = io::Error::new(io::ErrorKind::NotFound, "file missing");
486        assert_eq!(AppError::Io(io_err).exit_code(), 14);
487    }
488
489    #[test]
490    fn exit_code_internal_returns_20() {
491        let anyhow_err = anyhow::anyhow!("unexpected internal error");
492        assert_eq!(AppError::Internal(anyhow_err).exit_code(), 20);
493    }
494
495    #[test]
496    fn exit_code_json_returns_20() {
497        let json_err = serde_json::from_str::<serde_json::Value>("invalid json {{").unwrap_err();
498        assert_eq!(AppError::Json(json_err).exit_code(), 20);
499    }
500
501    #[test]
502    fn exit_code_lock_busy_returns_75() {
503        assert_eq!(
504            AppError::LockBusy("another active instance".into()).exit_code(),
505            75
506        );
507    }
508
509    #[test]
510    fn display_validation_includes_message() {
511        let err = AppError::Validation("invalid id".into());
512        assert!(err.to_string().contains("invalid id"));
513        assert!(err.to_string().contains("validation error"));
514    }
515
516    #[test]
517    fn display_duplicate_includes_message() {
518        let err = AppError::Duplicate("proj/mem".into());
519        assert!(err.to_string().contains("proj/mem"));
520        assert!(err.to_string().contains("duplicate detected"));
521    }
522
523    #[test]
524    fn display_not_found_includes_message() {
525        let err = AppError::NotFound("id 42".into());
526        assert!(err.to_string().contains("id 42"));
527        assert!(err.to_string().contains("not found"));
528    }
529
530    #[test]
531    fn display_embedding_includes_message() {
532        let err = AppError::Embedding("wrong dimension".into());
533        assert!(err.to_string().contains("wrong dimension"));
534        assert!(err.to_string().contains("embedding error"));
535    }
536
537    #[test]
538    fn display_lock_busy_includes_message() {
539        let err = AppError::LockBusy("pid 1234".into());
540        assert!(err.to_string().contains("pid 1234"));
541        assert!(err.to_string().contains("lock busy"));
542    }
543
544    #[test]
545    fn from_io_error_converts_correctly() {
546        let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "permission denied");
547        let app_err: AppError = io_err.into();
548        assert_eq!(app_err.exit_code(), 14);
549        assert!(app_err.to_string().contains("IO error"));
550    }
551
552    #[test]
553    fn from_anyhow_error_converts_correctly() {
554        let anyhow_err = anyhow::anyhow!("internal detail");
555        let app_err: AppError = anyhow_err.into();
556        assert_eq!(app_err.exit_code(), 20);
557        assert!(app_err.to_string().contains("internal detail"));
558    }
559
560    #[test]
561    fn from_serde_json_error_converts_correctly() {
562        let json_err = serde_json::from_str::<serde_json::Value>("{bad_field}").unwrap_err();
563        let app_err: AppError = json_err.into();
564        assert_eq!(app_err.exit_code(), 20);
565        assert!(app_err.to_string().contains("json error"));
566    }
567
568    #[test]
569    fn exit_code_lock_busy_matches_constant() {
570        assert_eq!(
571            AppError::LockBusy("test".into()).exit_code(),
572            crate::constants::CLI_LOCK_EXIT_CODE
573        );
574    }
575
576    #[test]
577    fn localized_message_en_equals_to_string() {
578        let err = AppError::NotFound("mem-x".into());
579        assert_eq!(
580            err.localized_message_for(crate::i18n::Language::English),
581            err.to_string()
582        );
583    }
584
585    // Detailed Portuguese-specific assertions live in `src/i18n.rs`
586    // (the bilingual module). Here we only verify that delegation is wired
587    // correctly, without embedding PT strings in this English-only file.
588
589    #[test]
590    fn localized_message_pt_differs_from_en() {
591        let err = AppError::NotFound("mem-x".into());
592        let en = err.localized_message_for(crate::i18n::Language::English);
593        let pt = err.localized_message_for(crate::i18n::Language::Portuguese);
594        assert_ne!(en, pt, "PT and EN must produce distinct messages");
595        assert!(pt.contains("mem-x"), "PT must include the variant payload");
596    }
597
598    #[test]
599    fn localized_message_pt_delegates_to_app_error_pt_helper() {
600        use crate::i18n::validation::app_error_pt as pt;
601
602        let cases: Vec<(AppError, String)> = vec![
603            (AppError::Validation("x".into()), pt::validation("x")),
604            (AppError::Duplicate("x".into()), pt::duplicate("x")),
605            (AppError::Conflict("x".into()), pt::conflict("x")),
606            (AppError::NotFound("x".into()), pt::not_found("x")),
607            (
608                AppError::NamespaceError("x".into()),
609                pt::namespace_error("x"),
610            ),
611            (AppError::LimitExceeded("x".into()), pt::limit_exceeded("x")),
612            (AppError::Embedding("x".into()), pt::embedding("x")),
613            (AppError::VecExtension("x".into()), pt::vec_extension("x")),
614            (AppError::DbBusy("x".into()), pt::db_busy("x")),
615            (
616                AppError::BatchPartialFailure {
617                    total: 10,
618                    failed: 3,
619                },
620                pt::batch_partial_failure(10, 3),
621            ),
622            (AppError::LockBusy("x".into()), pt::lock_busy("x")),
623            (
624                AppError::AllSlotsFull {
625                    max: 4,
626                    waited_secs: 60,
627                },
628                pt::all_slots_full(4, 60),
629            ),
630            (
631                AppError::LowMemory {
632                    available_mb: 100,
633                    required_mb: 500,
634                },
635                pt::low_memory(100, 500),
636            ),
637            (
638                AppError::BinaryNotFound {
639                    name: "claude".into(),
640                },
641                pt::binary_not_found("claude"),
642            ),
643            (
644                AppError::RateLimited {
645                    detail: "429".into(),
646                },
647                pt::rate_limited("429"),
648            ),
649            (
650                AppError::Timeout {
651                    operation: "op".into(),
652                    duration_secs: 30,
653                },
654                pt::timeout("op", 30),
655            ),
656        ];
657
658        for (err, expected) in cases {
659            let actual = err.localized_message_for(crate::i18n::Language::Portuguese);
660            assert_eq!(actual, expected, "delegation mismatch");
661        }
662    }
663
664    #[test]
665    fn is_retryable_transient_errors() {
666        assert!(AppError::DbBusy("x".into()).is_retryable());
667        assert!(AppError::LockBusy("x".into()).is_retryable());
668        assert!(AppError::AllSlotsFull {
669            max: 4,
670            waited_secs: 60
671        }
672        .is_retryable());
673        assert!(AppError::LowMemory {
674            available_mb: 100,
675            required_mb: 500
676        }
677        .is_retryable());
678        assert!(AppError::RateLimited {
679            detail: "429".into()
680        }
681        .is_retryable());
682        assert!(AppError::Timeout {
683            operation: "op".into(),
684            duration_secs: 30
685        }
686        .is_retryable());
687    }
688
689    #[test]
690    fn is_retryable_permanent_errors() {
691        assert!(!AppError::Validation("x".into()).is_retryable());
692        assert!(!AppError::NotFound("x".into()).is_retryable());
693        assert!(!AppError::Duplicate("x".into()).is_retryable());
694        assert!(!AppError::Conflict("x".into()).is_retryable());
695        assert!(!AppError::BinaryNotFound { name: "x".into() }.is_retryable());
696    }
697
698    #[test]
699    fn exit_code_new_variants() {
700        assert_eq!(AppError::BinaryNotFound { name: "x".into() }.exit_code(), 1);
701        assert_eq!(AppError::RateLimited { detail: "x".into() }.exit_code(), 1);
702        assert_eq!(
703            AppError::Timeout {
704                operation: "x".into(),
705                duration_secs: 5
706            }
707            .exit_code(),
708            1
709        );
710    }
711
712    #[test]
713    fn app_error_size_does_not_exceed_budget() {
714        let size = std::mem::size_of::<AppError>();
715        assert!(
716            size <= 128,
717            "AppError is {size} bytes — exceeds 128-byte budget; \
718             consider boxing large variants to reduce memcpy cost in Result propagation"
719        );
720    }
721}