1use crate::i18n::{current, Language};
9use crate::spawn::preflight::PreFlightError;
10use thiserror::Error;
11
12#[derive(Error, Debug)]
23#[non_exhaustive]
24pub enum AppError {
25 #[error("validation error: {0}")]
31 Validation(String),
32
33 #[error("binary not found: {name} — ensure it is installed and in PATH")]
35 BinaryNotFound { name: String },
36
37 #[error("rate limited: {detail}")]
39 RateLimited { detail: String },
40
41 #[error("timeout after {duration_secs}s: {operation}")]
43 Timeout {
44 operation: String,
45 duration_secs: u64,
46 },
47
48 #[error("duplicate detected: {0}")]
50 Duplicate(String),
51
52 #[error("conflict: {0}")]
54 Conflict(String),
55
56 #[error("not found: {0}")]
58 NotFound(String),
59
60 #[error("memory not found: name='{name}' in namespace '{namespace}'")]
69 MemoryNotFound { name: String, namespace: String },
70
71 #[error("memory not found: id={id}")]
73 MemoryNotFoundById { id: i64 },
74
75 #[error("namespace not resolved: {0}")]
77 NamespaceError(String),
78
79 #[error("limit exceeded: {0}")]
81 LimitExceeded(String),
82
83 #[error("database error: {0}")]
85 Database(#[from] rusqlite::Error),
86
87 #[error("embedding error: {0}")]
89 Embedding(String),
90
91 #[error("sqlite-vec extension failed: {0}")]
93 VecExtension(String),
94
95 #[error("database busy: {0}")]
97 DbBusy(String),
98
99 #[error("batch partial failure: {failed} of {total} items failed")]
104 BatchPartialFailure { total: usize, failed: usize },
105
106 #[error("IO error: {0}")]
108 Io(#[from] std::io::Error),
109
110 #[error(transparent)]
112 Internal(#[from] anyhow::Error),
113
114 #[error("json error: {0}")]
116 Json(#[from] serde_json::Error),
117
118 #[error("lock busy: {0}")]
122 LockBusy(String),
123
124 #[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 #[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 #[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 #[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 #[error("shutdown signal received: {signal}")]
178 Shutdown { signal: String },
179
180 #[error("preflight validation failed: {source}")]
197 PreFlightFailed { source: Box<PreFlightError> },
198}
199
200impl From<PreFlightError> for AppError {
207 fn from(source: PreFlightError) -> Self {
208 AppError::PreFlightFailed {
209 source: Box::new(source),
210 }
211 }
212}
213
214impl AppError {
215 #[inline]
242 #[must_use]
243 pub fn exit_code(&self) -> i32 {
244 match self {
245 Self::Validation(_) => 1,
246 Self::BinaryNotFound { .. } => 1,
247 Self::RateLimited { .. } => 1,
248 Self::Timeout { .. } => 1,
249 Self::Duplicate(_) => crate::constants::DUPLICATE_EXIT_CODE,
250 Self::Conflict(_) => 3,
251 Self::NotFound(_) => 4,
252 Self::MemoryNotFound { .. } => 4,
253 Self::MemoryNotFoundById { .. } => 4,
254 Self::NamespaceError(_) => 5,
255 Self::LimitExceeded(_) => 6,
256 Self::Database(_) => 10,
257 Self::Embedding(_) => 11,
258 Self::VecExtension(_) => 12,
259 Self::BatchPartialFailure { .. } => crate::constants::BATCH_PARTIAL_FAILURE_EXIT_CODE,
260 Self::DbBusy(_) => crate::constants::DB_BUSY_EXIT_CODE,
261 Self::Io(_) => 14,
262 Self::Internal(_) => 20,
263 Self::Json(_) => 20,
264 Self::LockBusy(_) => crate::constants::CLI_LOCK_EXIT_CODE,
265 Self::AllSlotsFull { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
266 Self::JobSingletonLocked { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
267 Self::EmbeddingSingletonLocked { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
268 Self::LowMemory { .. } => crate::constants::LOW_MEMORY_EXIT_CODE,
269 Self::Shutdown { .. } => crate::constants::SHUTDOWN_EXIT_CODE,
270 Self::PreFlightFailed { .. } => 16,
271 }
272 }
273
274 #[inline]
288 #[must_use]
289 pub fn is_retryable(&self) -> bool {
290 matches!(
291 self,
292 Self::DbBusy(_)
293 | Self::LockBusy(_)
294 | Self::AllSlotsFull { .. }
295 | Self::JobSingletonLocked { .. }
296 | Self::EmbeddingSingletonLocked { .. }
297 | Self::LowMemory { .. }
298 | Self::RateLimited { .. }
299 | Self::Timeout { .. }
300 )
301 }
302
303 #[inline]
318 #[must_use]
319 pub fn is_shutdown(&self) -> bool {
320 matches!(self, Self::Shutdown { .. })
321 }
322
323 #[inline]
338 #[must_use]
339 pub fn is_permanent(&self) -> bool {
340 matches!(
341 self,
342 Self::Validation(_)
343 | Self::BinaryNotFound { .. }
344 | Self::Duplicate(_)
345 | Self::NotFound(_)
346 | Self::MemoryNotFound { .. }
347 | Self::MemoryNotFoundById { .. }
348 | Self::NamespaceError(_)
349 | Self::LimitExceeded(_)
350 | Self::VecExtension(_)
351 | Self::PreFlightFailed { .. }
352 )
353 }
354
355 pub fn localized_message(&self) -> String {
360 self.localized_message_for(current())
361 }
362
363 pub fn localized_message_for(&self, lang: Language) -> String {
381 match lang {
382 Language::English => self.to_string(),
383 Language::Portuguese => self.to_string_pt(),
384 }
385 }
386
387 fn to_string_pt(&self) -> String {
388 use crate::i18n::validation::app_error_pt as pt;
389 match self {
390 Self::Validation(msg) => pt::validation(msg),
391 Self::BinaryNotFound { name } => pt::binary_not_found(name),
392 Self::RateLimited { detail } => pt::rate_limited(detail),
393 Self::Timeout {
394 operation,
395 duration_secs,
396 } => pt::timeout(operation, *duration_secs),
397 Self::Duplicate(msg) => pt::duplicate(msg),
398 Self::Conflict(msg) => pt::conflict(msg),
399 Self::NotFound(msg) => pt::not_found(msg),
400 Self::MemoryNotFound { name, namespace } => pt::memory_not_found(name, namespace),
401 Self::MemoryNotFoundById { id } => pt::memory_not_found_by_id(*id),
402 Self::NamespaceError(msg) => pt::namespace_error(msg),
403 Self::LimitExceeded(msg) => pt::limit_exceeded(msg),
404 Self::Database(e) => pt::database(&e.to_string()),
405 Self::Embedding(msg) => pt::embedding(msg),
406 Self::VecExtension(msg) => pt::vec_extension(msg),
407 Self::DbBusy(msg) => pt::db_busy(msg),
408 Self::BatchPartialFailure { total, failed } => {
409 pt::batch_partial_failure(*total, *failed)
410 }
411 Self::Io(e) => pt::io(&e.to_string()),
412 Self::Internal(e) => pt::internal(&e.to_string()),
413 Self::Json(e) => pt::json(&e.to_string()),
414 Self::LockBusy(msg) => pt::lock_busy(msg),
415 Self::AllSlotsFull { max, waited_secs } => pt::all_slots_full(*max, *waited_secs),
416 Self::JobSingletonLocked {
417 job_type,
418 namespace,
419 } => pt::job_singleton_locked(job_type, namespace),
420 Self::EmbeddingSingletonLocked { namespace } => {
421 pt::embedding_singleton_locked(namespace)
422 }
423 Self::LowMemory {
424 available_mb,
425 required_mb,
426 } => pt::low_memory(*available_mb, *required_mb),
427 Self::Shutdown { signal } => pt::shutdown(signal),
428 Self::PreFlightFailed { source } => pt::preflight_failed(&source.to_string()),
429 }
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436 use std::io;
437
438 #[test]
439 fn exit_code_validation_returns_1() {
440 assert_eq!(AppError::Validation("invalid field".into()).exit_code(), 1);
441 }
442
443 #[test]
444 fn exit_code_duplicate_returns_9() {
445 assert_eq!(AppError::Duplicate("namespace/name".into()).exit_code(), 9);
446 }
447
448 #[test]
449 fn exit_code_conflict_returns_3() {
450 assert_eq!(
451 AppError::Conflict("updated_at changed".into()).exit_code(),
452 3
453 );
454 }
455
456 #[test]
457 fn exit_code_not_found_returns_4() {
458 assert_eq!(AppError::NotFound("memory missing".into()).exit_code(), 4);
459 }
460
461 #[test]
462 fn exit_code_namespace_error_returns_5() {
463 assert_eq!(
464 AppError::NamespaceError("not resolved".into()).exit_code(),
465 5
466 );
467 }
468
469 #[test]
470 fn exit_code_limit_exceeded_returns_6() {
471 assert_eq!(
472 AppError::LimitExceeded("body too large".into()).exit_code(),
473 6
474 );
475 }
476
477 #[test]
478 fn exit_code_embedding_returns_11() {
479 assert_eq!(AppError::Embedding("model failure".into()).exit_code(), 11);
480 }
481
482 #[test]
483 fn exit_code_vec_extension_returns_12() {
484 assert_eq!(
485 AppError::VecExtension("extension did not load".into()).exit_code(),
486 12
487 );
488 }
489
490 #[test]
491 fn exit_code_db_busy_returns_15() {
492 assert_eq!(AppError::DbBusy("retries exhausted".into()).exit_code(), 15);
493 }
494
495 #[test]
496 fn exit_code_batch_partial_failure_returns_13() {
497 assert_eq!(
498 AppError::BatchPartialFailure {
499 total: 10,
500 failed: 3
501 }
502 .exit_code(),
503 13
504 );
505 }
506
507 #[test]
508 fn display_batch_partial_failure_includes_counts() {
509 let err = AppError::BatchPartialFailure {
510 total: 50,
511 failed: 7,
512 };
513 let msg = err.to_string();
514 assert!(msg.contains("7"));
515 assert!(msg.contains("50"));
516 assert!(msg.contains("batch partial failure"));
518 }
519
520 #[test]
521 fn exit_code_io_returns_14() {
522 let io_err = io::Error::new(io::ErrorKind::NotFound, "file missing");
523 assert_eq!(AppError::Io(io_err).exit_code(), 14);
524 }
525
526 #[test]
527 fn exit_code_internal_returns_20() {
528 let anyhow_err = anyhow::anyhow!("unexpected internal error");
529 assert_eq!(AppError::Internal(anyhow_err).exit_code(), 20);
530 }
531
532 #[test]
533 fn exit_code_json_returns_20() {
534 let json_err = serde_json::from_str::<serde_json::Value>("invalid json {{").unwrap_err();
535 assert_eq!(AppError::Json(json_err).exit_code(), 20);
536 }
537
538 #[test]
539 fn exit_code_lock_busy_returns_75() {
540 assert_eq!(
541 AppError::LockBusy("another active instance".into()).exit_code(),
542 75
543 );
544 }
545
546 #[test]
547 fn display_validation_includes_message() {
548 let err = AppError::Validation("invalid id".into());
549 assert!(err.to_string().contains("invalid id"));
550 assert!(err.to_string().contains("validation error"));
551 }
552
553 #[test]
554 fn display_duplicate_includes_message() {
555 let err = AppError::Duplicate("proj/mem".into());
556 assert!(err.to_string().contains("proj/mem"));
557 assert!(err.to_string().contains("duplicate detected"));
558 }
559
560 #[test]
561 fn display_not_found_includes_message() {
562 let err = AppError::NotFound("id 42".into());
563 assert!(err.to_string().contains("id 42"));
564 assert!(err.to_string().contains("not found"));
565 }
566
567 #[test]
568 fn display_embedding_includes_message() {
569 let err = AppError::Embedding("wrong dimension".into());
570 assert!(err.to_string().contains("wrong dimension"));
571 assert!(err.to_string().contains("embedding error"));
572 }
573
574 #[test]
575 fn display_lock_busy_includes_message() {
576 let err = AppError::LockBusy("pid 1234".into());
577 assert!(err.to_string().contains("pid 1234"));
578 assert!(err.to_string().contains("lock busy"));
579 }
580
581 #[test]
582 fn from_io_error_converts_correctly() {
583 let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "permission denied");
584 let app_err: AppError = io_err.into();
585 assert_eq!(app_err.exit_code(), 14);
586 assert!(app_err.to_string().contains("IO error"));
587 }
588
589 #[test]
590 fn from_anyhow_error_converts_correctly() {
591 let anyhow_err = anyhow::anyhow!("internal detail");
592 let app_err: AppError = anyhow_err.into();
593 assert_eq!(app_err.exit_code(), 20);
594 assert!(app_err.to_string().contains("internal detail"));
595 }
596
597 #[test]
598 fn from_serde_json_error_converts_correctly() {
599 let json_err = serde_json::from_str::<serde_json::Value>("{bad_field}").unwrap_err();
600 let app_err: AppError = json_err.into();
601 assert_eq!(app_err.exit_code(), 20);
602 assert!(app_err.to_string().contains("json error"));
603 }
604
605 #[test]
606 fn exit_code_lock_busy_matches_constant() {
607 assert_eq!(
608 AppError::LockBusy("test".into()).exit_code(),
609 crate::constants::CLI_LOCK_EXIT_CODE
610 );
611 }
612
613 #[test]
614 fn localized_message_en_equals_to_string() {
615 let err = AppError::NotFound("mem-x".into());
616 assert_eq!(
617 err.localized_message_for(crate::i18n::Language::English),
618 err.to_string()
619 );
620 }
621
622 #[test]
627 fn localized_message_pt_differs_from_en() {
628 let err = AppError::NotFound("mem-x".into());
629 let en = err.localized_message_for(crate::i18n::Language::English);
630 let pt = err.localized_message_for(crate::i18n::Language::Portuguese);
631 assert_ne!(en, pt, "PT and EN must produce distinct messages");
632 assert!(pt.contains("mem-x"), "PT must include the variant payload");
633 }
634
635 #[test]
636 fn localized_message_pt_delegates_to_app_error_pt_helper() {
637 use crate::i18n::validation::app_error_pt as pt;
638
639 let cases: Vec<(AppError, String)> = vec![
640 (AppError::Validation("x".into()), pt::validation("x")),
641 (AppError::Duplicate("x".into()), pt::duplicate("x")),
642 (AppError::Conflict("x".into()), pt::conflict("x")),
643 (AppError::NotFound("x".into()), pt::not_found("x")),
644 (
645 AppError::NamespaceError("x".into()),
646 pt::namespace_error("x"),
647 ),
648 (AppError::LimitExceeded("x".into()), pt::limit_exceeded("x")),
649 (AppError::Embedding("x".into()), pt::embedding("x")),
650 (AppError::VecExtension("x".into()), pt::vec_extension("x")),
651 (AppError::DbBusy("x".into()), pt::db_busy("x")),
652 (
653 AppError::BatchPartialFailure {
654 total: 10,
655 failed: 3,
656 },
657 pt::batch_partial_failure(10, 3),
658 ),
659 (AppError::LockBusy("x".into()), pt::lock_busy("x")),
660 (
661 AppError::AllSlotsFull {
662 max: 4,
663 waited_secs: 60,
664 },
665 pt::all_slots_full(4, 60),
666 ),
667 (
668 AppError::LowMemory {
669 available_mb: 100,
670 required_mb: 500,
671 },
672 pt::low_memory(100, 500),
673 ),
674 (
675 AppError::BinaryNotFound {
676 name: "claude".into(),
677 },
678 pt::binary_not_found("claude"),
679 ),
680 (
681 AppError::RateLimited {
682 detail: "429".into(),
683 },
684 pt::rate_limited("429"),
685 ),
686 (
687 AppError::Timeout {
688 operation: "op".into(),
689 duration_secs: 30,
690 },
691 pt::timeout("op", 30),
692 ),
693 ];
694
695 for (err, expected) in cases {
696 let actual = err.localized_message_for(crate::i18n::Language::Portuguese);
697 assert_eq!(actual, expected, "delegation mismatch");
698 }
699 }
700
701 #[test]
702 fn is_retryable_transient_errors() {
703 assert!(AppError::DbBusy("x".into()).is_retryable());
704 assert!(AppError::LockBusy("x".into()).is_retryable());
705 assert!(AppError::AllSlotsFull {
706 max: 4,
707 waited_secs: 60
708 }
709 .is_retryable());
710 assert!(AppError::LowMemory {
711 available_mb: 100,
712 required_mb: 500
713 }
714 .is_retryable());
715 assert!(AppError::RateLimited {
716 detail: "429".into()
717 }
718 .is_retryable());
719 assert!(AppError::Timeout {
720 operation: "op".into(),
721 duration_secs: 30
722 }
723 .is_retryable());
724 }
725
726 #[test]
727 fn is_retryable_permanent_errors() {
728 assert!(!AppError::Validation("x".into()).is_retryable());
729 assert!(!AppError::NotFound("x".into()).is_retryable());
730 assert!(!AppError::Duplicate("x".into()).is_retryable());
731 assert!(!AppError::Conflict("x".into()).is_retryable());
732 assert!(!AppError::BinaryNotFound { name: "x".into() }.is_retryable());
733 }
734
735 #[test]
736 fn exit_code_new_variants() {
737 assert_eq!(AppError::BinaryNotFound { name: "x".into() }.exit_code(), 1);
738 assert_eq!(AppError::RateLimited { detail: "x".into() }.exit_code(), 1);
739 assert_eq!(
740 AppError::Timeout {
741 operation: "x".into(),
742 duration_secs: 5
743 }
744 .exit_code(),
745 1
746 );
747 }
748
749 #[test]
750 fn app_error_size_does_not_exceed_budget() {
751 let size = std::mem::size_of::<AppError>();
752 assert!(
753 size <= 128,
754 "AppError is {size} bytes — exceeds 128-byte budget; \
755 consider boxing large variants to reduce memcpy cost in Result propagation"
756 );
757 }
758}