1use crate::i18n::{current, Language};
9use thiserror::Error;
10
11#[derive(Error, Debug)]
22#[non_exhaustive]
23pub enum AppError {
24 #[error("validation error: {0}")]
30 Validation(String),
31
32 #[error("binary not found: {name} — ensure it is installed and in PATH")]
34 BinaryNotFound { name: String },
35
36 #[error("rate limited: {detail}")]
38 RateLimited { detail: String },
39
40 #[error("timeout after {duration_secs}s: {operation}")]
42 Timeout {
43 operation: String,
44 duration_secs: u64,
45 },
46
47 #[error("duplicate detected: {0}")]
49 Duplicate(String),
50
51 #[error("conflict: {0}")]
53 Conflict(String),
54
55 #[error("not found: {0}")]
57 NotFound(String),
58
59 #[error("memory not found: name='{name}' in namespace '{namespace}'")]
68 MemoryNotFound { name: String, namespace: String },
69
70 #[error("memory not found: id={id}")]
72 MemoryNotFoundById { id: i64 },
73
74 #[error("namespace not resolved: {0}")]
76 NamespaceError(String),
77
78 #[error("limit exceeded: {0}")]
80 LimitExceeded(String),
81
82 #[error("database error: {0}")]
84 Database(#[from] rusqlite::Error),
85
86 #[error("embedding error: {0}")]
88 Embedding(String),
89
90 #[error("sqlite-vec extension failed: {0}")]
92 VecExtension(String),
93
94 #[error("database busy: {0}")]
96 DbBusy(String),
97
98 #[error("batch partial failure: {failed} of {total} items failed")]
103 BatchPartialFailure { total: usize, failed: usize },
104
105 #[error("IO error: {0}")]
107 Io(#[from] std::io::Error),
108
109 #[error(transparent)]
111 Internal(#[from] anyhow::Error),
112
113 #[error("json error: {0}")]
115 Json(#[from] serde_json::Error),
116
117 #[error("lock busy: {0}")]
121 LockBusy(String),
122
123 #[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 #[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 #[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 #[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
168impl AppError {
169 #[inline]
196 #[must_use]
197 pub fn exit_code(&self) -> i32 {
198 match self {
199 Self::Validation(_) => 1,
200 Self::BinaryNotFound { .. } => 1,
201 Self::RateLimited { .. } => 1,
202 Self::Timeout { .. } => 1,
203 Self::Duplicate(_) => crate::constants::DUPLICATE_EXIT_CODE,
204 Self::Conflict(_) => 3,
205 Self::NotFound(_) => 4,
206 Self::MemoryNotFound { .. } => 4,
207 Self::MemoryNotFoundById { .. } => 4,
208 Self::NamespaceError(_) => 5,
209 Self::LimitExceeded(_) => 6,
210 Self::Database(_) => 10,
211 Self::Embedding(_) => 11,
212 Self::VecExtension(_) => 12,
213 Self::BatchPartialFailure { .. } => crate::constants::BATCH_PARTIAL_FAILURE_EXIT_CODE,
214 Self::DbBusy(_) => crate::constants::DB_BUSY_EXIT_CODE,
215 Self::Io(_) => 14,
216 Self::Internal(_) => 20,
217 Self::Json(_) => 20,
218 Self::LockBusy(_) => crate::constants::CLI_LOCK_EXIT_CODE,
219 Self::AllSlotsFull { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
220 Self::JobSingletonLocked { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
221 Self::EmbeddingSingletonLocked { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
222 Self::LowMemory { .. } => crate::constants::LOW_MEMORY_EXIT_CODE,
223 }
224 }
225
226 #[inline]
240 #[must_use]
241 pub fn is_retryable(&self) -> bool {
242 matches!(
243 self,
244 Self::DbBusy(_)
245 | Self::LockBusy(_)
246 | Self::AllSlotsFull { .. }
247 | Self::JobSingletonLocked { .. }
248 | Self::EmbeddingSingletonLocked { .. }
249 | Self::LowMemory { .. }
250 | Self::RateLimited { .. }
251 | Self::Timeout { .. }
252 )
253 }
254
255 #[inline]
270 #[must_use]
271 pub fn is_permanent(&self) -> bool {
272 matches!(
273 self,
274 Self::Validation(_)
275 | Self::BinaryNotFound { .. }
276 | Self::Duplicate(_)
277 | Self::NotFound(_)
278 | Self::MemoryNotFound { .. }
279 | Self::MemoryNotFoundById { .. }
280 | Self::NamespaceError(_)
281 | Self::LimitExceeded(_)
282 | Self::VecExtension(_)
283 )
284 }
285
286 pub fn localized_message(&self) -> String {
291 self.localized_message_for(current())
292 }
293
294 pub fn localized_message_for(&self, lang: Language) -> String {
312 match lang {
313 Language::English => self.to_string(),
314 Language::Portuguese => self.to_string_pt(),
315 }
316 }
317
318 fn to_string_pt(&self) -> String {
319 use crate::i18n::validation::app_error_pt as pt;
320 match self {
321 Self::Validation(msg) => pt::validation(msg),
322 Self::BinaryNotFound { name } => pt::binary_not_found(name),
323 Self::RateLimited { detail } => pt::rate_limited(detail),
324 Self::Timeout {
325 operation,
326 duration_secs,
327 } => pt::timeout(operation, *duration_secs),
328 Self::Duplicate(msg) => pt::duplicate(msg),
329 Self::Conflict(msg) => pt::conflict(msg),
330 Self::NotFound(msg) => pt::not_found(msg),
331 Self::MemoryNotFound { name, namespace } => pt::memory_not_found(name, namespace),
332 Self::MemoryNotFoundById { id } => pt::memory_not_found_by_id(*id),
333 Self::NamespaceError(msg) => pt::namespace_error(msg),
334 Self::LimitExceeded(msg) => pt::limit_exceeded(msg),
335 Self::Database(e) => pt::database(&e.to_string()),
336 Self::Embedding(msg) => pt::embedding(msg),
337 Self::VecExtension(msg) => pt::vec_extension(msg),
338 Self::DbBusy(msg) => pt::db_busy(msg),
339 Self::BatchPartialFailure { total, failed } => {
340 pt::batch_partial_failure(*total, *failed)
341 }
342 Self::Io(e) => pt::io(&e.to_string()),
343 Self::Internal(e) => pt::internal(&e.to_string()),
344 Self::Json(e) => pt::json(&e.to_string()),
345 Self::LockBusy(msg) => pt::lock_busy(msg),
346 Self::AllSlotsFull { max, waited_secs } => pt::all_slots_full(*max, *waited_secs),
347 Self::JobSingletonLocked {
348 job_type,
349 namespace,
350 } => pt::job_singleton_locked(job_type, namespace),
351 Self::EmbeddingSingletonLocked { namespace } => {
352 pt::embedding_singleton_locked(namespace)
353 }
354 Self::LowMemory {
355 available_mb,
356 required_mb,
357 } => pt::low_memory(*available_mb, *required_mb),
358 }
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365 use std::io;
366
367 #[test]
368 fn exit_code_validation_returns_1() {
369 assert_eq!(AppError::Validation("invalid field".into()).exit_code(), 1);
370 }
371
372 #[test]
373 fn exit_code_duplicate_returns_9() {
374 assert_eq!(AppError::Duplicate("namespace/name".into()).exit_code(), 9);
375 }
376
377 #[test]
378 fn exit_code_conflict_returns_3() {
379 assert_eq!(
380 AppError::Conflict("updated_at changed".into()).exit_code(),
381 3
382 );
383 }
384
385 #[test]
386 fn exit_code_not_found_returns_4() {
387 assert_eq!(AppError::NotFound("memory missing".into()).exit_code(), 4);
388 }
389
390 #[test]
391 fn exit_code_namespace_error_returns_5() {
392 assert_eq!(
393 AppError::NamespaceError("not resolved".into()).exit_code(),
394 5
395 );
396 }
397
398 #[test]
399 fn exit_code_limit_exceeded_returns_6() {
400 assert_eq!(
401 AppError::LimitExceeded("body too large".into()).exit_code(),
402 6
403 );
404 }
405
406 #[test]
407 fn exit_code_embedding_returns_11() {
408 assert_eq!(AppError::Embedding("model failure".into()).exit_code(), 11);
409 }
410
411 #[test]
412 fn exit_code_vec_extension_returns_12() {
413 assert_eq!(
414 AppError::VecExtension("extension did not load".into()).exit_code(),
415 12
416 );
417 }
418
419 #[test]
420 fn exit_code_db_busy_returns_15() {
421 assert_eq!(AppError::DbBusy("retries exhausted".into()).exit_code(), 15);
422 }
423
424 #[test]
425 fn exit_code_batch_partial_failure_returns_13() {
426 assert_eq!(
427 AppError::BatchPartialFailure {
428 total: 10,
429 failed: 3
430 }
431 .exit_code(),
432 13
433 );
434 }
435
436 #[test]
437 fn display_batch_partial_failure_includes_counts() {
438 let err = AppError::BatchPartialFailure {
439 total: 50,
440 failed: 7,
441 };
442 let msg = err.to_string();
443 assert!(msg.contains("7"));
444 assert!(msg.contains("50"));
445 assert!(msg.contains("batch partial failure"));
447 }
448
449 #[test]
450 fn exit_code_io_returns_14() {
451 let io_err = io::Error::new(io::ErrorKind::NotFound, "file missing");
452 assert_eq!(AppError::Io(io_err).exit_code(), 14);
453 }
454
455 #[test]
456 fn exit_code_internal_returns_20() {
457 let anyhow_err = anyhow::anyhow!("unexpected internal error");
458 assert_eq!(AppError::Internal(anyhow_err).exit_code(), 20);
459 }
460
461 #[test]
462 fn exit_code_json_returns_20() {
463 let json_err = serde_json::from_str::<serde_json::Value>("invalid json {{").unwrap_err();
464 assert_eq!(AppError::Json(json_err).exit_code(), 20);
465 }
466
467 #[test]
468 fn exit_code_lock_busy_returns_75() {
469 assert_eq!(
470 AppError::LockBusy("another active instance".into()).exit_code(),
471 75
472 );
473 }
474
475 #[test]
476 fn display_validation_includes_message() {
477 let err = AppError::Validation("invalid id".into());
478 assert!(err.to_string().contains("invalid id"));
479 assert!(err.to_string().contains("validation error"));
480 }
481
482 #[test]
483 fn display_duplicate_includes_message() {
484 let err = AppError::Duplicate("proj/mem".into());
485 assert!(err.to_string().contains("proj/mem"));
486 assert!(err.to_string().contains("duplicate detected"));
487 }
488
489 #[test]
490 fn display_not_found_includes_message() {
491 let err = AppError::NotFound("id 42".into());
492 assert!(err.to_string().contains("id 42"));
493 assert!(err.to_string().contains("not found"));
494 }
495
496 #[test]
497 fn display_embedding_includes_message() {
498 let err = AppError::Embedding("wrong dimension".into());
499 assert!(err.to_string().contains("wrong dimension"));
500 assert!(err.to_string().contains("embedding error"));
501 }
502
503 #[test]
504 fn display_lock_busy_includes_message() {
505 let err = AppError::LockBusy("pid 1234".into());
506 assert!(err.to_string().contains("pid 1234"));
507 assert!(err.to_string().contains("lock busy"));
508 }
509
510 #[test]
511 fn from_io_error_converts_correctly() {
512 let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "permission denied");
513 let app_err: AppError = io_err.into();
514 assert_eq!(app_err.exit_code(), 14);
515 assert!(app_err.to_string().contains("IO error"));
516 }
517
518 #[test]
519 fn from_anyhow_error_converts_correctly() {
520 let anyhow_err = anyhow::anyhow!("internal detail");
521 let app_err: AppError = anyhow_err.into();
522 assert_eq!(app_err.exit_code(), 20);
523 assert!(app_err.to_string().contains("internal detail"));
524 }
525
526 #[test]
527 fn from_serde_json_error_converts_correctly() {
528 let json_err = serde_json::from_str::<serde_json::Value>("{bad_field}").unwrap_err();
529 let app_err: AppError = json_err.into();
530 assert_eq!(app_err.exit_code(), 20);
531 assert!(app_err.to_string().contains("json error"));
532 }
533
534 #[test]
535 fn exit_code_lock_busy_matches_constant() {
536 assert_eq!(
537 AppError::LockBusy("test".into()).exit_code(),
538 crate::constants::CLI_LOCK_EXIT_CODE
539 );
540 }
541
542 #[test]
543 fn localized_message_en_equals_to_string() {
544 let err = AppError::NotFound("mem-x".into());
545 assert_eq!(
546 err.localized_message_for(crate::i18n::Language::English),
547 err.to_string()
548 );
549 }
550
551 #[test]
556 fn localized_message_pt_differs_from_en() {
557 let err = AppError::NotFound("mem-x".into());
558 let en = err.localized_message_for(crate::i18n::Language::English);
559 let pt = err.localized_message_for(crate::i18n::Language::Portuguese);
560 assert_ne!(en, pt, "PT and EN must produce distinct messages");
561 assert!(pt.contains("mem-x"), "PT must include the variant payload");
562 }
563
564 #[test]
565 fn localized_message_pt_delegates_to_app_error_pt_helper() {
566 use crate::i18n::validation::app_error_pt as pt;
567
568 let cases: Vec<(AppError, String)> = vec![
569 (AppError::Validation("x".into()), pt::validation("x")),
570 (AppError::Duplicate("x".into()), pt::duplicate("x")),
571 (AppError::Conflict("x".into()), pt::conflict("x")),
572 (AppError::NotFound("x".into()), pt::not_found("x")),
573 (
574 AppError::NamespaceError("x".into()),
575 pt::namespace_error("x"),
576 ),
577 (AppError::LimitExceeded("x".into()), pt::limit_exceeded("x")),
578 (AppError::Embedding("x".into()), pt::embedding("x")),
579 (AppError::VecExtension("x".into()), pt::vec_extension("x")),
580 (AppError::DbBusy("x".into()), pt::db_busy("x")),
581 (
582 AppError::BatchPartialFailure {
583 total: 10,
584 failed: 3,
585 },
586 pt::batch_partial_failure(10, 3),
587 ),
588 (AppError::LockBusy("x".into()), pt::lock_busy("x")),
589 (
590 AppError::AllSlotsFull {
591 max: 4,
592 waited_secs: 60,
593 },
594 pt::all_slots_full(4, 60),
595 ),
596 (
597 AppError::LowMemory {
598 available_mb: 100,
599 required_mb: 500,
600 },
601 pt::low_memory(100, 500),
602 ),
603 (
604 AppError::BinaryNotFound {
605 name: "claude".into(),
606 },
607 pt::binary_not_found("claude"),
608 ),
609 (
610 AppError::RateLimited {
611 detail: "429".into(),
612 },
613 pt::rate_limited("429"),
614 ),
615 (
616 AppError::Timeout {
617 operation: "op".into(),
618 duration_secs: 30,
619 },
620 pt::timeout("op", 30),
621 ),
622 ];
623
624 for (err, expected) in cases {
625 let actual = err.localized_message_for(crate::i18n::Language::Portuguese);
626 assert_eq!(actual, expected, "delegation mismatch");
627 }
628 }
629
630 #[test]
631 fn is_retryable_transient_errors() {
632 assert!(AppError::DbBusy("x".into()).is_retryable());
633 assert!(AppError::LockBusy("x".into()).is_retryable());
634 assert!(AppError::AllSlotsFull {
635 max: 4,
636 waited_secs: 60
637 }
638 .is_retryable());
639 assert!(AppError::LowMemory {
640 available_mb: 100,
641 required_mb: 500
642 }
643 .is_retryable());
644 assert!(AppError::RateLimited {
645 detail: "429".into()
646 }
647 .is_retryable());
648 assert!(AppError::Timeout {
649 operation: "op".into(),
650 duration_secs: 30
651 }
652 .is_retryable());
653 }
654
655 #[test]
656 fn is_retryable_permanent_errors() {
657 assert!(!AppError::Validation("x".into()).is_retryable());
658 assert!(!AppError::NotFound("x".into()).is_retryable());
659 assert!(!AppError::Duplicate("x".into()).is_retryable());
660 assert!(!AppError::Conflict("x".into()).is_retryable());
661 assert!(!AppError::BinaryNotFound { name: "x".into() }.is_retryable());
662 }
663
664 #[test]
665 fn exit_code_new_variants() {
666 assert_eq!(AppError::BinaryNotFound { name: "x".into() }.exit_code(), 1);
667 assert_eq!(AppError::RateLimited { detail: "x".into() }.exit_code(), 1);
668 assert_eq!(
669 AppError::Timeout {
670 operation: "x".into(),
671 duration_secs: 5
672 }
673 .exit_code(),
674 1
675 );
676 }
677
678 #[test]
679 fn app_error_size_does_not_exceed_budget() {
680 let size = std::mem::size_of::<AppError>();
681 assert!(
682 size <= 128,
683 "AppError is {size} bytes — exceeds 128-byte budget; \
684 consider boxing large variants to reduce memcpy cost in Result propagation"
685 );
686 }
687}