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("entity '{name}' not yet materialized in namespace '{namespace}'")]
95 EntityNotYetMaterialized { name: String, namespace: String },
96
97 #[error("namespace not resolved: {0}")]
99 NamespaceError(String),
100
101 #[error("limit exceeded: {0}")]
103 LimitExceeded(String),
104
105 #[error("database error: {0}")]
107 Database(#[from] rusqlite::Error),
108
109 #[error("embedding error: {0}")]
111 Embedding(String),
112
113 #[error("sqlite-vec extension failed: {0}")]
115 VecExtension(String),
116
117 #[error("database busy: {0}")]
119 DbBusy(String),
120
121 #[error("batch partial failure: {failed} of {total} items failed")]
126 BatchPartialFailure { total: usize, failed: usize },
127
128 #[error("IO error: {0}")]
130 Io(#[from] std::io::Error),
131
132 #[error(transparent)]
134 Internal(#[from] anyhow::Error),
135
136 #[error("json error: {0}")]
138 Json(#[from] serde_json::Error),
139
140 #[error("lock busy: {0}")]
144 LockBusy(String),
145
146 #[error(
151 "all {max} concurrency slots occupied after waiting {waited_secs}s (exit 75); \
152 use --max-concurrency or wait for other invocations to finish"
153 )]
154 AllSlotsFull { max: usize, waited_secs: u64 },
155
156 #[error(
165 "job {job_type} for namespace '{namespace}' is already running (exit 75); \
166 wait for it to finish or pass --wait-job-singleton <SECONDS>"
167 )]
168 JobSingletonLocked { job_type: String, namespace: String },
169
170 #[error(
175 "embedding singleton for namespace '{namespace}' is already held (exit 75); \
176 another CLI is calling the LLM on this database; pass --wait-embed-singleton <SECONDS> to wait"
177 )]
178 EmbeddingSingletonLocked { namespace: String },
179
180 #[error(
185 "available memory ({available_mb}MB) below required minimum ({required_mb}MB) \
186 to load the model; abort other loads or use --skip-memory-guard (exit 77)"
187 )]
188 LowMemory { available_mb: u64, required_mb: u64 },
189
190 #[error("shutdown signal received: {signal}")]
200 Shutdown { signal: String },
201
202 #[error("preflight validation failed: {source}")]
219 PreFlightFailed { source: Box<PreFlightError> },
220
221 #[error("provider error (code {code}): {message}")]
237 ProviderError { code: String, message: String },
238}
239
240impl From<PreFlightError> for AppError {
247 fn from(source: PreFlightError) -> Self {
248 AppError::PreFlightFailed {
249 source: Box::new(source),
250 }
251 }
252}
253
254impl AppError {
255 #[inline]
282 #[must_use]
283 pub fn exit_code(&self) -> i32 {
284 match self {
285 Self::Validation(_) => 1,
286 Self::BinaryNotFound { .. } => 1,
287 Self::RateLimited { .. } => 1,
288 Self::Timeout { .. } => 1,
289 Self::Duplicate(_) => crate::constants::DUPLICATE_EXIT_CODE,
290 Self::Conflict(_) => 3,
291 Self::NotFound(_) => 4,
292 Self::MemoryNotFound { .. } => 4,
293 Self::MemoryNotFoundById { .. } => 4,
294 Self::EntityNotYetMaterialized { .. } => 4,
295 Self::NamespaceError(_) => 5,
296 Self::LimitExceeded(_) => 6,
297 Self::Database(_) => 10,
298 Self::Embedding(_) => 11,
299 Self::VecExtension(_) => 12,
300 Self::BatchPartialFailure { .. } => crate::constants::BATCH_PARTIAL_FAILURE_EXIT_CODE,
301 Self::DbBusy(_) => crate::constants::DB_BUSY_EXIT_CODE,
302 Self::Io(_) => 14,
303 Self::Internal(_) => 20,
304 Self::Json(_) => 20,
305 Self::LockBusy(_) => crate::constants::CLI_LOCK_EXIT_CODE,
306 Self::AllSlotsFull { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
307 Self::JobSingletonLocked { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
308 Self::EmbeddingSingletonLocked { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
309 Self::LowMemory { .. } => crate::constants::LOW_MEMORY_EXIT_CODE,
310 Self::Shutdown { .. } => crate::constants::SHUTDOWN_EXIT_CODE,
311 Self::PreFlightFailed { .. } => 16,
312 Self::ProviderError { .. } => 1,
313 }
314 }
315
316 #[inline]
330 #[must_use]
331 pub fn is_retryable(&self) -> bool {
332 matches!(
333 self,
334 Self::DbBusy(_)
335 | Self::LockBusy(_)
336 | Self::AllSlotsFull { .. }
337 | Self::JobSingletonLocked { .. }
338 | Self::EmbeddingSingletonLocked { .. }
339 | Self::LowMemory { .. }
340 | Self::RateLimited { .. }
341 | Self::Timeout { .. }
342 | Self::EntityNotYetMaterialized { .. }
343 )
344 }
345
346 #[inline]
361 #[must_use]
362 pub fn is_shutdown(&self) -> bool {
363 matches!(self, Self::Shutdown { .. })
364 }
365
366 #[inline]
381 #[must_use]
382 pub fn is_permanent(&self) -> bool {
383 matches!(
384 self,
385 Self::Validation(_)
386 | Self::BinaryNotFound { .. }
387 | Self::Duplicate(_)
388 | Self::NotFound(_)
389 | Self::MemoryNotFound { .. }
390 | Self::MemoryNotFoundById { .. }
391 | Self::NamespaceError(_)
392 | Self::LimitExceeded(_)
393 | Self::VecExtension(_)
394 | Self::PreFlightFailed { .. }
395 | Self::ProviderError { .. }
396 )
397 }
398
399 #[must_use]
406 pub fn suggestion(&self) -> Option<&'static str> {
407 match self {
408 Self::Validation(_) => Some(
409 "review the input against the command's --help; names must be kebab-case (lowercase letters, digits, hyphens) and bodies non-empty",
410 ),
411 Self::Duplicate(_) => {
412 Some("pass --force-merge to update the existing memory instead of failing")
413 }
414 Self::Conflict(_) => Some(
415 "another writer changed the row; re-read with `read --name <n> --json` and retry with a fresh --expected-updated-at",
416 ),
417 Self::NotFound(_) | Self::MemoryNotFound { .. } | Self::MemoryNotFoundById { .. } => {
418 Some("verify the name/id and namespace with `list --json` or `read --name <n> --json`")
419 }
420 Self::NamespaceError(_) => {
421 Some("set --namespace or SQLITE_GRAPHRAG_NAMESPACE; inspect with `namespace-detect --json`")
422 }
423 Self::LimitExceeded(_) => {
424 Some("split the input into smaller memories or raise the documented cap before retrying")
425 }
426 Self::Embedding(_) => Some(
427 "verify the embedding backend and OPENROUTER_API_KEY; re-run `enrich --operation re-embed` once resolved",
428 ),
429 Self::Database(_) | Self::DbBusy(_) => {
430 Some("run `health --json` then `vacuum --json`; widen --wait-lock if the database is busy")
431 }
432 Self::Io(_) => Some("check the path exists and is writable, then retry"),
433 Self::RateLimited { .. } => {
434 Some("wait for the reported retry-after window, then retry")
435 }
436 Self::LockBusy(_) | Self::AllSlotsFull { .. } | Self::JobSingletonLocked { .. } => {
437 Some("wait for the other invocation to finish or pass --wait-lock / --wait-job-singleton")
438 }
439 _ => None,
440 }
441 }
442
443 pub fn localized_message(&self) -> String {
448 self.localized_message_for(current())
449 }
450
451 pub fn localized_message_for(&self, lang: Language) -> String {
469 match lang {
470 Language::English => self.to_string(),
471 Language::Portuguese => self.to_string_pt(),
472 }
473 }
474
475 fn to_string_pt(&self) -> String {
476 use crate::i18n::validation::app_error_pt as pt;
477 match self {
478 Self::Validation(msg) => pt::validation(msg),
479 Self::BinaryNotFound { name } => pt::binary_not_found(name),
480 Self::RateLimited { detail } => pt::rate_limited(detail),
481 Self::Timeout {
482 operation,
483 duration_secs,
484 } => pt::timeout(operation, *duration_secs),
485 Self::Duplicate(msg) => pt::duplicate(msg),
486 Self::Conflict(msg) => pt::conflict(msg),
487 Self::NotFound(msg) => pt::not_found(msg),
488 Self::MemoryNotFound { name, namespace } => pt::memory_not_found(name, namespace),
489 Self::MemoryNotFoundById { id } => pt::memory_not_found_by_id(*id),
490 Self::EntityNotYetMaterialized { name, namespace } => {
491 pt::entity_not_yet_materialized(name, namespace)
492 }
493 Self::NamespaceError(msg) => pt::namespace_error(msg),
494 Self::LimitExceeded(msg) => pt::limit_exceeded(msg),
495 Self::Database(e) => pt::database(&e.to_string()),
496 Self::Embedding(msg) => pt::embedding(msg),
497 Self::VecExtension(msg) => pt::vec_extension(msg),
498 Self::DbBusy(msg) => pt::db_busy(msg),
499 Self::BatchPartialFailure { total, failed } => {
500 pt::batch_partial_failure(*total, *failed)
501 }
502 Self::Io(e) => pt::io(&e.to_string()),
503 Self::Internal(e) => pt::internal(&e.to_string()),
504 Self::Json(e) => pt::json(&e.to_string()),
505 Self::LockBusy(msg) => pt::lock_busy(msg),
506 Self::AllSlotsFull { max, waited_secs } => pt::all_slots_full(*max, *waited_secs),
507 Self::JobSingletonLocked {
508 job_type,
509 namespace,
510 } => pt::job_singleton_locked(job_type, namespace),
511 Self::EmbeddingSingletonLocked { namespace } => {
512 pt::embedding_singleton_locked(namespace)
513 }
514 Self::LowMemory {
515 available_mb,
516 required_mb,
517 } => pt::low_memory(*available_mb, *required_mb),
518 Self::Shutdown { signal } => pt::shutdown(signal),
519 Self::PreFlightFailed { source } => pt::preflight_failed(&source.to_string()),
520 Self::ProviderError { code, message } => pt::provider_error(code, message),
521 }
522 }
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528 use std::io;
529
530 #[test]
531 fn exit_code_validation_returns_1() {
532 assert_eq!(AppError::Validation("invalid field".into()).exit_code(), 1);
533 }
534
535 #[test]
537 fn suggestion_present_for_actionable_variants() {
538 assert!(AppError::Validation("bad name".into())
539 .suggestion()
540 .is_some());
541 let dup = AppError::Duplicate("global/x".into());
542 assert!(dup.suggestion().unwrap().contains("--force-merge"));
543 let nf = AppError::MemoryNotFound {
544 name: "x".into(),
545 namespace: "global".into(),
546 };
547 assert!(nf.suggestion().is_some());
548 }
549
550 #[test]
551 fn exit_code_duplicate_returns_9() {
552 assert_eq!(AppError::Duplicate("namespace/name".into()).exit_code(), 9);
553 }
554
555 #[test]
556 fn exit_code_conflict_returns_3() {
557 assert_eq!(
558 AppError::Conflict("updated_at changed".into()).exit_code(),
559 3
560 );
561 }
562
563 #[test]
564 fn exit_code_not_found_returns_4() {
565 assert_eq!(AppError::NotFound("memory missing".into()).exit_code(), 4);
566 }
567
568 #[test]
569 fn exit_code_namespace_error_returns_5() {
570 assert_eq!(
571 AppError::NamespaceError("not resolved".into()).exit_code(),
572 5
573 );
574 }
575
576 #[test]
577 fn exit_code_limit_exceeded_returns_6() {
578 assert_eq!(
579 AppError::LimitExceeded("body too large".into()).exit_code(),
580 6
581 );
582 }
583
584 #[test]
585 fn exit_code_embedding_returns_11() {
586 assert_eq!(AppError::Embedding("model failure".into()).exit_code(), 11);
587 }
588
589 #[test]
590 fn exit_code_vec_extension_returns_12() {
591 assert_eq!(
592 AppError::VecExtension("extension did not load".into()).exit_code(),
593 12
594 );
595 }
596
597 #[test]
598 fn exit_code_db_busy_returns_15() {
599 assert_eq!(AppError::DbBusy("retries exhausted".into()).exit_code(), 15);
600 }
601
602 #[test]
603 fn exit_code_batch_partial_failure_returns_13() {
604 assert_eq!(
605 AppError::BatchPartialFailure {
606 total: 10,
607 failed: 3
608 }
609 .exit_code(),
610 13
611 );
612 }
613
614 #[test]
615 fn display_batch_partial_failure_includes_counts() {
616 let err = AppError::BatchPartialFailure {
617 total: 50,
618 failed: 7,
619 };
620 let msg = err.to_string();
621 assert!(msg.contains("7"));
622 assert!(msg.contains("50"));
623 assert!(msg.contains("batch partial failure"));
625 }
626
627 #[test]
628 fn exit_code_io_returns_14() {
629 let io_err = io::Error::new(io::ErrorKind::NotFound, "file missing");
630 assert_eq!(AppError::Io(io_err).exit_code(), 14);
631 }
632
633 #[test]
634 fn exit_code_internal_returns_20() {
635 let anyhow_err = anyhow::anyhow!("unexpected internal error");
636 assert_eq!(AppError::Internal(anyhow_err).exit_code(), 20);
637 }
638
639 #[test]
640 fn exit_code_json_returns_20() {
641 let json_err = serde_json::from_str::<serde_json::Value>("invalid json {{").unwrap_err();
642 assert_eq!(AppError::Json(json_err).exit_code(), 20);
643 }
644
645 #[test]
646 fn exit_code_lock_busy_returns_75() {
647 assert_eq!(
648 AppError::LockBusy("another active instance".into()).exit_code(),
649 75
650 );
651 }
652
653 #[test]
654 fn display_validation_includes_message() {
655 let err = AppError::Validation("invalid id".into());
656 assert!(err.to_string().contains("invalid id"));
657 assert!(err.to_string().contains("validation error"));
658 }
659
660 #[test]
661 fn display_duplicate_includes_message() {
662 let err = AppError::Duplicate("proj/mem".into());
663 assert!(err.to_string().contains("proj/mem"));
664 assert!(err.to_string().contains("duplicate detected"));
665 }
666
667 #[test]
668 fn display_not_found_includes_message() {
669 let err = AppError::NotFound("id 42".into());
670 assert!(err.to_string().contains("id 42"));
671 assert!(err.to_string().contains("not found"));
672 }
673
674 #[test]
675 fn display_embedding_includes_message() {
676 let err = AppError::Embedding("wrong dimension".into());
677 assert!(err.to_string().contains("wrong dimension"));
678 assert!(err.to_string().contains("embedding error"));
679 }
680
681 #[test]
682 fn display_lock_busy_includes_message() {
683 let err = AppError::LockBusy("pid 1234".into());
684 assert!(err.to_string().contains("pid 1234"));
685 assert!(err.to_string().contains("lock busy"));
686 }
687
688 #[test]
689 fn from_io_error_converts_correctly() {
690 let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "permission denied");
691 let app_err: AppError = io_err.into();
692 assert_eq!(app_err.exit_code(), 14);
693 assert!(app_err.to_string().contains("IO error"));
694 }
695
696 #[test]
697 fn from_anyhow_error_converts_correctly() {
698 let anyhow_err = anyhow::anyhow!("internal detail");
699 let app_err: AppError = anyhow_err.into();
700 assert_eq!(app_err.exit_code(), 20);
701 assert!(app_err.to_string().contains("internal detail"));
702 }
703
704 #[test]
705 fn from_serde_json_error_converts_correctly() {
706 let json_err = serde_json::from_str::<serde_json::Value>("{bad_field}").unwrap_err();
707 let app_err: AppError = json_err.into();
708 assert_eq!(app_err.exit_code(), 20);
709 assert!(app_err.to_string().contains("json error"));
710 }
711
712 #[test]
713 fn exit_code_lock_busy_matches_constant() {
714 assert_eq!(
715 AppError::LockBusy("test".into()).exit_code(),
716 crate::constants::CLI_LOCK_EXIT_CODE
717 );
718 }
719
720 #[test]
721 fn localized_message_en_equals_to_string() {
722 let err = AppError::NotFound("mem-x".into());
723 assert_eq!(
724 err.localized_message_for(crate::i18n::Language::English),
725 err.to_string()
726 );
727 }
728
729 #[test]
734 fn localized_message_pt_differs_from_en() {
735 let err = AppError::NotFound("mem-x".into());
736 let en = err.localized_message_for(crate::i18n::Language::English);
737 let pt = err.localized_message_for(crate::i18n::Language::Portuguese);
738 assert_ne!(en, pt, "PT and EN must produce distinct messages");
739 assert!(pt.contains("mem-x"), "PT must include the variant payload");
740 }
741
742 #[test]
743 fn localized_message_pt_delegates_to_app_error_pt_helper() {
744 use crate::i18n::validation::app_error_pt as pt;
745
746 let cases: Vec<(AppError, String)> = vec![
747 (AppError::Validation("x".into()), pt::validation("x")),
748 (AppError::Duplicate("x".into()), pt::duplicate("x")),
749 (AppError::Conflict("x".into()), pt::conflict("x")),
750 (AppError::NotFound("x".into()), pt::not_found("x")),
751 (
752 AppError::NamespaceError("x".into()),
753 pt::namespace_error("x"),
754 ),
755 (AppError::LimitExceeded("x".into()), pt::limit_exceeded("x")),
756 (AppError::Embedding("x".into()), pt::embedding("x")),
757 (AppError::VecExtension("x".into()), pt::vec_extension("x")),
758 (AppError::DbBusy("x".into()), pt::db_busy("x")),
759 (
760 AppError::BatchPartialFailure {
761 total: 10,
762 failed: 3,
763 },
764 pt::batch_partial_failure(10, 3),
765 ),
766 (AppError::LockBusy("x".into()), pt::lock_busy("x")),
767 (
768 AppError::AllSlotsFull {
769 max: 4,
770 waited_secs: 60,
771 },
772 pt::all_slots_full(4, 60),
773 ),
774 (
775 AppError::LowMemory {
776 available_mb: 100,
777 required_mb: 500,
778 },
779 pt::low_memory(100, 500),
780 ),
781 (
782 AppError::BinaryNotFound {
783 name: "claude".into(),
784 },
785 pt::binary_not_found("claude"),
786 ),
787 (
788 AppError::RateLimited {
789 detail: "429".into(),
790 },
791 pt::rate_limited("429"),
792 ),
793 (
794 AppError::Timeout {
795 operation: "op".into(),
796 duration_secs: 30,
797 },
798 pt::timeout("op", 30),
799 ),
800 ];
801
802 for (err, expected) in cases {
803 let actual = err.localized_message_for(crate::i18n::Language::Portuguese);
804 assert_eq!(actual, expected, "delegation mismatch");
805 }
806 }
807
808 #[test]
809 fn is_retryable_transient_errors() {
810 assert!(AppError::DbBusy("x".into()).is_retryable());
811 assert!(AppError::LockBusy("x".into()).is_retryable());
812 assert!(AppError::AllSlotsFull {
813 max: 4,
814 waited_secs: 60
815 }
816 .is_retryable());
817 assert!(AppError::LowMemory {
818 available_mb: 100,
819 required_mb: 500
820 }
821 .is_retryable());
822 assert!(AppError::RateLimited {
823 detail: "429".into()
824 }
825 .is_retryable());
826 assert!(AppError::Timeout {
827 operation: "op".into(),
828 duration_secs: 30
829 }
830 .is_retryable());
831 }
832
833 #[test]
834 fn is_retryable_permanent_errors() {
835 assert!(!AppError::Validation("x".into()).is_retryable());
836 assert!(!AppError::NotFound("x".into()).is_retryable());
837 assert!(!AppError::Duplicate("x".into()).is_retryable());
838 assert!(!AppError::Conflict("x".into()).is_retryable());
839 assert!(!AppError::BinaryNotFound { name: "x".into() }.is_retryable());
840 }
841
842 #[test]
843 fn exit_code_new_variants() {
844 assert_eq!(AppError::BinaryNotFound { name: "x".into() }.exit_code(), 1);
845 assert_eq!(AppError::RateLimited { detail: "x".into() }.exit_code(), 1);
846 assert_eq!(
847 AppError::Timeout {
848 operation: "x".into(),
849 duration_secs: 5
850 }
851 .exit_code(),
852 1
853 );
854 }
855
856 #[test]
859 fn entity_not_yet_materialized_exit_code_is_4() {
860 let e = AppError::EntityNotYetMaterialized {
861 name: "acme".into(),
862 namespace: "global".into(),
863 };
864 assert_eq!(e.exit_code(), 4);
865 }
866
867 #[test]
868 fn entity_not_yet_materialized_is_retryable_not_permanent() {
869 let e = AppError::EntityNotYetMaterialized {
870 name: "acme".into(),
871 namespace: "global".into(),
872 };
873 assert!(e.is_retryable());
874 assert!(!e.is_permanent());
875 }
876
877 #[test]
878 fn entity_not_yet_materialized_user_message_non_empty() {
879 let e = AppError::EntityNotYetMaterialized {
880 name: "acme".into(),
881 namespace: "global".into(),
882 };
883 assert!(!e
884 .localized_message_for(crate::i18n::Language::English)
885 .is_empty());
886 assert!(!e
887 .localized_message_for(crate::i18n::Language::Portuguese)
888 .is_empty());
889 }
890
891 #[test]
892 fn app_error_size_does_not_exceed_budget() {
893 let size = std::mem::size_of::<AppError>();
894 assert!(
895 size <= 128,
896 "AppError is {size} bytes — exceeds 128-byte budget; \
897 consider boxing large variants to reduce memcpy cost in Result propagation"
898 );
899 }
900}