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}")]
108 LimitExceeded(String),
109
110 #[error(
120 "limit exceeded: body is {bytes} bytes, above the {limit}-byte cap \
121 (MAX_MEMORY_BODY_LEN); split the content into multiple memories"
122 )]
123 BodyTooLarge { bytes: u64, limit: u64 },
124
125 #[error(
133 "limit exceeded: document produces {chunks} chunks, above the \
134 {limit}-chunk cap (REMEMBER_MAX_SAFE_MULTI_CHUNKS); split the \
135 document before writing"
136 )]
137 TooManyChunks { chunks: usize, limit: usize },
138
139 #[error("database error: {0}")]
141 Database(#[from] rusqlite::Error),
142
143 #[error("embedding error: {0}")]
145 Embedding(String),
146
147 #[error("sqlite-vec extension failed: {0}")]
149 VecExtension(String),
150
151 #[error("database busy: {0}")]
153 DbBusy(String),
154
155 #[error("batch partial failure: {failed} of {total} items failed")]
160 BatchPartialFailure { total: usize, failed: usize },
161
162 #[error("IO error: {0}")]
164 Io(#[from] std::io::Error),
165
166 #[error(transparent)]
168 Internal(#[from] anyhow::Error),
169
170 #[error("json error: {0}")]
172 Json(#[from] serde_json::Error),
173
174 #[error("lock busy: {0}")]
178 LockBusy(String),
179
180 #[error(
185 "all {max} concurrency slots occupied after waiting {waited_secs}s (exit 75); \
186 use --max-concurrency or wait for other invocations to finish"
187 )]
188 AllSlotsFull { max: usize, waited_secs: u64 },
189
190 #[error(
199 "job {job_type} for namespace '{namespace}' is already running (exit 75); \
200 wait for it to finish or pass --wait-job-singleton <SECONDS>"
201 )]
202 JobSingletonLocked { job_type: String, namespace: String },
203
204 #[error(
209 "embedding singleton for namespace '{namespace}' is already held (exit 75); \
210 another CLI is calling the LLM on this database; pass --wait-embed-singleton <SECONDS> to wait"
211 )]
212 EmbeddingSingletonLocked { namespace: String },
213
214 #[error(
219 "available memory ({available_mb}MB) below required minimum ({required_mb}MB) \
220 to load the model; abort other loads or use --skip-memory-guard (exit 77)"
221 )]
222 LowMemory { available_mb: u64, required_mb: u64 },
223
224 #[error("shutdown signal received: {signal}")]
234 Shutdown { signal: String },
235
236 #[error("preflight validation failed: {source}")]
253 PreFlightFailed { source: Box<PreFlightError> },
254
255 #[error("provider error (code {code}): {message}")]
271 ProviderError { code: String, message: String },
272}
273
274impl From<PreFlightError> for AppError {
281 fn from(source: PreFlightError) -> Self {
282 AppError::PreFlightFailed {
283 source: Box::new(source),
284 }
285 }
286}
287
288impl AppError {
289 #[inline]
316 #[must_use]
317 pub fn exit_code(&self) -> i32 {
318 match self {
319 Self::Validation(_) => 1,
320 Self::BinaryNotFound { .. } => 1,
321 Self::RateLimited { .. } => 1,
322 Self::Timeout { .. } => 1,
323 Self::Duplicate(_) => crate::constants::DUPLICATE_EXIT_CODE,
324 Self::Conflict(_) => 3,
325 Self::NotFound(_) => 4,
326 Self::MemoryNotFound { .. } => 4,
327 Self::MemoryNotFoundById { .. } => 4,
328 Self::EntityNotYetMaterialized { .. } => 4,
329 Self::NamespaceError(_) => 5,
330 Self::LimitExceeded(_) => 6,
331 Self::BodyTooLarge { .. } => 6,
332 Self::TooManyChunks { .. } => 6,
333 Self::Database(_) => 10,
334 Self::Embedding(_) => 11,
335 Self::VecExtension(_) => 12,
336 Self::BatchPartialFailure { .. } => crate::constants::BATCH_PARTIAL_FAILURE_EXIT_CODE,
337 Self::DbBusy(_) => crate::constants::DB_BUSY_EXIT_CODE,
338 Self::Io(_) => 14,
339 Self::Internal(_) => 20,
340 Self::Json(_) => 20,
341 Self::LockBusy(_) => crate::constants::CLI_LOCK_EXIT_CODE,
342 Self::AllSlotsFull { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
343 Self::JobSingletonLocked { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
344 Self::EmbeddingSingletonLocked { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
345 Self::LowMemory { .. } => crate::constants::LOW_MEMORY_EXIT_CODE,
346 Self::Shutdown { .. } => crate::constants::SHUTDOWN_EXIT_CODE,
347 Self::PreFlightFailed { .. } => 16,
348 Self::ProviderError { .. } => 1,
349 }
350 }
351
352 #[inline]
366 #[must_use]
367 pub fn is_retryable(&self) -> bool {
368 matches!(
369 self,
370 Self::DbBusy(_)
371 | Self::LockBusy(_)
372 | Self::AllSlotsFull { .. }
373 | Self::JobSingletonLocked { .. }
374 | Self::EmbeddingSingletonLocked { .. }
375 | Self::LowMemory { .. }
376 | Self::RateLimited { .. }
377 | Self::Timeout { .. }
378 | Self::EntityNotYetMaterialized { .. }
379 )
380 }
381
382 #[inline]
397 #[must_use]
398 pub fn is_shutdown(&self) -> bool {
399 matches!(self, Self::Shutdown { .. })
400 }
401
402 #[inline]
417 #[must_use]
418 pub fn is_permanent(&self) -> bool {
419 matches!(
420 self,
421 Self::Validation(_)
422 | Self::BinaryNotFound { .. }
423 | Self::Duplicate(_)
424 | Self::NotFound(_)
425 | Self::MemoryNotFound { .. }
426 | Self::MemoryNotFoundById { .. }
427 | Self::NamespaceError(_)
428 | Self::LimitExceeded(_)
429 | Self::BodyTooLarge { .. }
430 | Self::TooManyChunks { .. }
431 | Self::VecExtension(_)
432 | Self::PreFlightFailed { .. }
433 | Self::ProviderError { .. }
434 )
435 }
436
437 #[must_use]
444 pub fn suggestion(&self) -> Option<&'static str> {
445 match self {
446 Self::Validation(_) => Some(
447 "review the input against the command's --help; names must be kebab-case (lowercase letters, digits, hyphens) and bodies non-empty",
448 ),
449 Self::Duplicate(_) => {
450 Some("pass --force-merge to update the existing memory instead of failing")
451 }
452 Self::Conflict(_) => Some(
453 "another writer changed the row; re-read with `read --name <n> --json` and retry with a fresh --expected-updated-at",
454 ),
455 Self::NotFound(_) | Self::MemoryNotFound { .. } | Self::MemoryNotFoundById { .. } => {
456 Some("verify the name/id and namespace with `list --json` or `read --name <n> --json`")
457 }
458 Self::NamespaceError(_) => {
459 Some("set --namespace or SQLITE_GRAPHRAG_NAMESPACE; inspect with `namespace-detect --json`")
460 }
461 Self::LimitExceeded(_) => {
462 Some("split the input into smaller memories or raise the documented cap before retrying")
463 }
464 Self::BodyTooLarge { .. } => {
465 Some("the body-bytes cap (MAX_MEMORY_BODY_LEN) fired; split the content into multiple memories or use --body-file")
466 }
467 Self::TooManyChunks { .. } => {
468 Some("the chunk-count cap (REMEMBER_MAX_SAFE_MULTI_CHUNKS) fired; split the document into smaller memories before writing")
469 }
470 Self::Embedding(_) => Some(
471 "verify the embedding backend and OPENROUTER_API_KEY; re-run `enrich --operation re-embed` once resolved",
472 ),
473 Self::Database(_) | Self::DbBusy(_) => {
474 Some("run `health --json` then `vacuum --json`; widen --wait-lock if the database is busy")
475 }
476 Self::Io(_) => Some("check the path exists and is writable, then retry"),
477 Self::RateLimited { .. } => {
478 Some("wait for the reported retry-after window, then retry")
479 }
480 Self::LockBusy(_) | Self::AllSlotsFull { .. } | Self::JobSingletonLocked { .. } => {
481 Some("wait for the other invocation to finish or pass --wait-lock / --wait-job-singleton")
482 }
483 _ => None,
484 }
485 }
486
487 pub fn localized_message(&self) -> String {
492 self.localized_message_for(current())
493 }
494
495 pub fn localized_message_for(&self, lang: Language) -> String {
513 match lang {
514 Language::English => self.to_string(),
515 Language::Portuguese => self.to_string_pt(),
516 }
517 }
518
519 fn to_string_pt(&self) -> String {
520 use crate::i18n::validation::app_error_pt as pt;
521 match self {
522 Self::Validation(msg) => pt::validation(msg),
523 Self::BinaryNotFound { name } => pt::binary_not_found(name),
524 Self::RateLimited { detail } => pt::rate_limited(detail),
525 Self::Timeout {
526 operation,
527 duration_secs,
528 } => pt::timeout(operation, *duration_secs),
529 Self::Duplicate(msg) => pt::duplicate(msg),
530 Self::Conflict(msg) => pt::conflict(msg),
531 Self::NotFound(msg) => pt::not_found(msg),
532 Self::MemoryNotFound { name, namespace } => pt::memory_not_found(name, namespace),
533 Self::MemoryNotFoundById { id } => pt::memory_not_found_by_id(*id),
534 Self::EntityNotYetMaterialized { name, namespace } => {
535 pt::entity_not_yet_materialized(name, namespace)
536 }
537 Self::NamespaceError(msg) => pt::namespace_error(msg),
538 Self::LimitExceeded(msg) => pt::limit_exceeded(msg),
539 Self::BodyTooLarge { bytes, limit } => pt::body_too_large(*bytes, *limit),
540 Self::TooManyChunks { chunks, limit } => pt::too_many_chunks(*chunks, *limit),
541 Self::Database(e) => pt::database(&e.to_string()),
542 Self::Embedding(msg) => pt::embedding(msg),
543 Self::VecExtension(msg) => pt::vec_extension(msg),
544 Self::DbBusy(msg) => pt::db_busy(msg),
545 Self::BatchPartialFailure { total, failed } => {
546 pt::batch_partial_failure(*total, *failed)
547 }
548 Self::Io(e) => pt::io(&e.to_string()),
549 Self::Internal(e) => pt::internal(&e.to_string()),
550 Self::Json(e) => pt::json(&e.to_string()),
551 Self::LockBusy(msg) => pt::lock_busy(msg),
552 Self::AllSlotsFull { max, waited_secs } => pt::all_slots_full(*max, *waited_secs),
553 Self::JobSingletonLocked {
554 job_type,
555 namespace,
556 } => pt::job_singleton_locked(job_type, namespace),
557 Self::EmbeddingSingletonLocked { namespace } => {
558 pt::embedding_singleton_locked(namespace)
559 }
560 Self::LowMemory {
561 available_mb,
562 required_mb,
563 } => pt::low_memory(*available_mb, *required_mb),
564 Self::Shutdown { signal } => pt::shutdown(signal),
565 Self::PreFlightFailed { source } => pt::preflight_failed(&source.to_string()),
566 Self::ProviderError { code, message } => pt::provider_error(code, message),
567 }
568 }
569}
570
571#[cfg(test)]
572mod tests {
573 use super::*;
574 use std::io;
575
576 #[test]
577 fn exit_code_validation_returns_1() {
578 assert_eq!(AppError::Validation("invalid field".into()).exit_code(), 1);
579 }
580
581 #[test]
583 fn suggestion_present_for_actionable_variants() {
584 assert!(AppError::Validation("bad name".into())
585 .suggestion()
586 .is_some());
587 let dup = AppError::Duplicate("global/x".into());
588 assert!(dup.suggestion().unwrap().contains("--force-merge"));
589 let nf = AppError::MemoryNotFound {
590 name: "x".into(),
591 namespace: "global".into(),
592 };
593 assert!(nf.suggestion().is_some());
594 }
595
596 #[test]
597 fn exit_code_duplicate_returns_9() {
598 assert_eq!(AppError::Duplicate("namespace/name".into()).exit_code(), 9);
599 }
600
601 #[test]
602 fn exit_code_conflict_returns_3() {
603 assert_eq!(
604 AppError::Conflict("updated_at changed".into()).exit_code(),
605 3
606 );
607 }
608
609 #[test]
610 fn exit_code_not_found_returns_4() {
611 assert_eq!(AppError::NotFound("memory missing".into()).exit_code(), 4);
612 }
613
614 #[test]
615 fn exit_code_namespace_error_returns_5() {
616 assert_eq!(
617 AppError::NamespaceError("not resolved".into()).exit_code(),
618 5
619 );
620 }
621
622 #[test]
623 fn exit_code_limit_exceeded_returns_6() {
624 assert_eq!(
625 AppError::LimitExceeded("body too large".into()).exit_code(),
626 6
627 );
628 }
629
630 #[test]
632 fn exit_code_body_too_large_and_too_many_chunks_return_6() {
633 assert_eq!(
634 AppError::BodyTooLarge {
635 bytes: 600_000,
636 limit: 512_000
637 }
638 .exit_code(),
639 6
640 );
641 assert_eq!(
642 AppError::TooManyChunks {
643 chunks: 700,
644 limit: 512
645 }
646 .exit_code(),
647 6
648 );
649 }
650
651 #[test]
655 fn body_too_large_message_identifies_bytes_cap() {
656 let err = AppError::BodyTooLarge {
657 bytes: 600_000,
658 limit: 512_000,
659 };
660 let msg = err.to_string();
661 assert!(msg.contains("limit exceeded"), "obtido: {msg}");
662 assert!(msg.contains("600000 bytes"), "obtido: {msg}");
663 assert!(msg.contains("512000-byte cap"), "obtido: {msg}");
664 assert!(msg.contains("MAX_MEMORY_BODY_LEN"), "obtido: {msg}");
665 assert!(!msg.contains("chunk"), "obtido: {msg}");
666 }
667
668 #[test]
669 fn too_many_chunks_message_identifies_chunk_cap() {
670 let err = AppError::TooManyChunks {
671 chunks: 700,
672 limit: 512,
673 };
674 let msg = err.to_string();
675 assert!(msg.contains("limit exceeded"), "obtido: {msg}");
676 assert!(msg.contains("700 chunks"), "obtido: {msg}");
677 assert!(msg.contains("512-chunk cap"), "obtido: {msg}");
678 assert!(
679 msg.contains("REMEMBER_MAX_SAFE_MULTI_CHUNKS"),
680 "obtido: {msg}"
681 );
682 assert!(!msg.contains("byte cap"), "obtido: {msg}");
683 }
684
685 #[test]
686 fn typed_limit_variants_are_permanent_with_suggestion() {
687 let body = AppError::BodyTooLarge { bytes: 1, limit: 1 };
688 let chunks = AppError::TooManyChunks {
689 chunks: 1,
690 limit: 1,
691 };
692 assert!(body.is_permanent());
693 assert!(chunks.is_permanent());
694 assert!(!body.is_retryable());
695 assert!(!chunks.is_retryable());
696 assert!(body.suggestion().unwrap().contains("MAX_MEMORY_BODY_LEN"));
697 assert!(chunks
698 .suggestion()
699 .unwrap()
700 .contains("REMEMBER_MAX_SAFE_MULTI_CHUNKS"));
701 }
702
703 #[test]
704 fn typed_limit_variants_localize_to_pt() {
705 let body = AppError::BodyTooLarge {
706 bytes: 600_000,
707 limit: 512_000,
708 };
709 let pt = body.localized_message_for(crate::i18n::Language::Portuguese);
710 assert!(pt.contains("limite excedido"), "obtido: {pt}");
711 assert!(pt.contains("600000"), "obtido: {pt}");
712 let chunks = AppError::TooManyChunks {
713 chunks: 700,
714 limit: 512,
715 };
716 let pt = chunks.localized_message_for(crate::i18n::Language::Portuguese);
717 assert!(pt.contains("limite excedido"), "obtido: {pt}");
718 assert!(pt.contains("700"), "obtido: {pt}");
719 }
720
721 #[test]
722 fn exit_code_embedding_returns_11() {
723 assert_eq!(AppError::Embedding("model failure".into()).exit_code(), 11);
724 }
725
726 #[test]
727 fn exit_code_vec_extension_returns_12() {
728 assert_eq!(
729 AppError::VecExtension("extension did not load".into()).exit_code(),
730 12
731 );
732 }
733
734 #[test]
735 fn exit_code_db_busy_returns_15() {
736 assert_eq!(AppError::DbBusy("retries exhausted".into()).exit_code(), 15);
737 }
738
739 #[test]
740 fn exit_code_batch_partial_failure_returns_13() {
741 assert_eq!(
742 AppError::BatchPartialFailure {
743 total: 10,
744 failed: 3
745 }
746 .exit_code(),
747 13
748 );
749 }
750
751 #[test]
752 fn display_batch_partial_failure_includes_counts() {
753 let err = AppError::BatchPartialFailure {
754 total: 50,
755 failed: 7,
756 };
757 let msg = err.to_string();
758 assert!(msg.contains("7"));
759 assert!(msg.contains("50"));
760 assert!(msg.contains("batch partial failure"));
762 }
763
764 #[test]
765 fn exit_code_io_returns_14() {
766 let io_err = io::Error::new(io::ErrorKind::NotFound, "file missing");
767 assert_eq!(AppError::Io(io_err).exit_code(), 14);
768 }
769
770 #[test]
771 fn exit_code_internal_returns_20() {
772 let anyhow_err = anyhow::anyhow!("unexpected internal error");
773 assert_eq!(AppError::Internal(anyhow_err).exit_code(), 20);
774 }
775
776 #[test]
777 fn exit_code_json_returns_20() {
778 let json_err = serde_json::from_str::<serde_json::Value>("invalid json {{").unwrap_err();
779 assert_eq!(AppError::Json(json_err).exit_code(), 20);
780 }
781
782 #[test]
783 fn exit_code_lock_busy_returns_75() {
784 assert_eq!(
785 AppError::LockBusy("another active instance".into()).exit_code(),
786 75
787 );
788 }
789
790 #[test]
791 fn display_validation_includes_message() {
792 let err = AppError::Validation("invalid id".into());
793 assert!(err.to_string().contains("invalid id"));
794 assert!(err.to_string().contains("validation error"));
795 }
796
797 #[test]
798 fn display_duplicate_includes_message() {
799 let err = AppError::Duplicate("proj/mem".into());
800 assert!(err.to_string().contains("proj/mem"));
801 assert!(err.to_string().contains("duplicate detected"));
802 }
803
804 #[test]
805 fn display_not_found_includes_message() {
806 let err = AppError::NotFound("id 42".into());
807 assert!(err.to_string().contains("id 42"));
808 assert!(err.to_string().contains("not found"));
809 }
810
811 #[test]
812 fn display_embedding_includes_message() {
813 let err = AppError::Embedding("wrong dimension".into());
814 assert!(err.to_string().contains("wrong dimension"));
815 assert!(err.to_string().contains("embedding error"));
816 }
817
818 #[test]
819 fn display_lock_busy_includes_message() {
820 let err = AppError::LockBusy("pid 1234".into());
821 assert!(err.to_string().contains("pid 1234"));
822 assert!(err.to_string().contains("lock busy"));
823 }
824
825 #[test]
826 fn from_io_error_converts_correctly() {
827 let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "permission denied");
828 let app_err: AppError = io_err.into();
829 assert_eq!(app_err.exit_code(), 14);
830 assert!(app_err.to_string().contains("IO error"));
831 }
832
833 #[test]
834 fn from_anyhow_error_converts_correctly() {
835 let anyhow_err = anyhow::anyhow!("internal detail");
836 let app_err: AppError = anyhow_err.into();
837 assert_eq!(app_err.exit_code(), 20);
838 assert!(app_err.to_string().contains("internal detail"));
839 }
840
841 #[test]
842 fn from_serde_json_error_converts_correctly() {
843 let json_err = serde_json::from_str::<serde_json::Value>("{bad_field}").unwrap_err();
844 let app_err: AppError = json_err.into();
845 assert_eq!(app_err.exit_code(), 20);
846 assert!(app_err.to_string().contains("json error"));
847 }
848
849 #[test]
850 fn exit_code_lock_busy_matches_constant() {
851 assert_eq!(
852 AppError::LockBusy("test".into()).exit_code(),
853 crate::constants::CLI_LOCK_EXIT_CODE
854 );
855 }
856
857 #[test]
858 fn localized_message_en_equals_to_string() {
859 let err = AppError::NotFound("mem-x".into());
860 assert_eq!(
861 err.localized_message_for(crate::i18n::Language::English),
862 err.to_string()
863 );
864 }
865
866 #[test]
871 fn localized_message_pt_differs_from_en() {
872 let err = AppError::NotFound("mem-x".into());
873 let en = err.localized_message_for(crate::i18n::Language::English);
874 let pt = err.localized_message_for(crate::i18n::Language::Portuguese);
875 assert_ne!(en, pt, "PT and EN must produce distinct messages");
876 assert!(pt.contains("mem-x"), "PT must include the variant payload");
877 }
878
879 #[test]
880 fn localized_message_pt_delegates_to_app_error_pt_helper() {
881 use crate::i18n::validation::app_error_pt as pt;
882
883 let cases: Vec<(AppError, String)> = vec![
884 (AppError::Validation("x".into()), pt::validation("x")),
885 (AppError::Duplicate("x".into()), pt::duplicate("x")),
886 (AppError::Conflict("x".into()), pt::conflict("x")),
887 (AppError::NotFound("x".into()), pt::not_found("x")),
888 (
889 AppError::NamespaceError("x".into()),
890 pt::namespace_error("x"),
891 ),
892 (AppError::LimitExceeded("x".into()), pt::limit_exceeded("x")),
893 (AppError::Embedding("x".into()), pt::embedding("x")),
894 (AppError::VecExtension("x".into()), pt::vec_extension("x")),
895 (AppError::DbBusy("x".into()), pt::db_busy("x")),
896 (
897 AppError::BatchPartialFailure {
898 total: 10,
899 failed: 3,
900 },
901 pt::batch_partial_failure(10, 3),
902 ),
903 (AppError::LockBusy("x".into()), pt::lock_busy("x")),
904 (
905 AppError::AllSlotsFull {
906 max: 4,
907 waited_secs: 60,
908 },
909 pt::all_slots_full(4, 60),
910 ),
911 (
912 AppError::LowMemory {
913 available_mb: 100,
914 required_mb: 500,
915 },
916 pt::low_memory(100, 500),
917 ),
918 (
919 AppError::BinaryNotFound {
920 name: "claude".into(),
921 },
922 pt::binary_not_found("claude"),
923 ),
924 (
925 AppError::RateLimited {
926 detail: "429".into(),
927 },
928 pt::rate_limited("429"),
929 ),
930 (
931 AppError::Timeout {
932 operation: "op".into(),
933 duration_secs: 30,
934 },
935 pt::timeout("op", 30),
936 ),
937 ];
938
939 for (err, expected) in cases {
940 let actual = err.localized_message_for(crate::i18n::Language::Portuguese);
941 assert_eq!(actual, expected, "delegation mismatch");
942 }
943 }
944
945 #[test]
946 fn is_retryable_transient_errors() {
947 assert!(AppError::DbBusy("x".into()).is_retryable());
948 assert!(AppError::LockBusy("x".into()).is_retryable());
949 assert!(AppError::AllSlotsFull {
950 max: 4,
951 waited_secs: 60
952 }
953 .is_retryable());
954 assert!(AppError::LowMemory {
955 available_mb: 100,
956 required_mb: 500
957 }
958 .is_retryable());
959 assert!(AppError::RateLimited {
960 detail: "429".into()
961 }
962 .is_retryable());
963 assert!(AppError::Timeout {
964 operation: "op".into(),
965 duration_secs: 30
966 }
967 .is_retryable());
968 }
969
970 #[test]
971 fn is_retryable_permanent_errors() {
972 assert!(!AppError::Validation("x".into()).is_retryable());
973 assert!(!AppError::NotFound("x".into()).is_retryable());
974 assert!(!AppError::Duplicate("x".into()).is_retryable());
975 assert!(!AppError::Conflict("x".into()).is_retryable());
976 assert!(!AppError::BinaryNotFound { name: "x".into() }.is_retryable());
977 }
978
979 #[test]
980 fn exit_code_new_variants() {
981 assert_eq!(AppError::BinaryNotFound { name: "x".into() }.exit_code(), 1);
982 assert_eq!(AppError::RateLimited { detail: "x".into() }.exit_code(), 1);
983 assert_eq!(
984 AppError::Timeout {
985 operation: "x".into(),
986 duration_secs: 5
987 }
988 .exit_code(),
989 1
990 );
991 }
992
993 #[test]
996 fn entity_not_yet_materialized_exit_code_is_4() {
997 let e = AppError::EntityNotYetMaterialized {
998 name: "acme".into(),
999 namespace: "global".into(),
1000 };
1001 assert_eq!(e.exit_code(), 4);
1002 }
1003
1004 #[test]
1005 fn entity_not_yet_materialized_is_retryable_not_permanent() {
1006 let e = AppError::EntityNotYetMaterialized {
1007 name: "acme".into(),
1008 namespace: "global".into(),
1009 };
1010 assert!(e.is_retryable());
1011 assert!(!e.is_permanent());
1012 }
1013
1014 #[test]
1015 fn entity_not_yet_materialized_user_message_non_empty() {
1016 let e = AppError::EntityNotYetMaterialized {
1017 name: "acme".into(),
1018 namespace: "global".into(),
1019 };
1020 assert!(!e
1021 .localized_message_for(crate::i18n::Language::English)
1022 .is_empty());
1023 assert!(!e
1024 .localized_message_for(crate::i18n::Language::Portuguese)
1025 .is_empty());
1026 }
1027
1028 #[test]
1029 fn app_error_size_does_not_exceed_budget() {
1030 let size = std::mem::size_of::<AppError>();
1031 assert!(
1032 size <= 128,
1033 "AppError is {size} bytes — exceeds 128-byte budget; \
1034 consider boxing large variants to reduce memcpy cost in Result propagation"
1035 );
1036 }
1037}