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