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(
150 "limit exceeded: body is {tokens} tokens (estimated), above the \
151 {limit}-token cap (EMBEDDING_REQUEST_MAX_TOKENS); split the content \
152 into multiple memories"
153 )]
154 TooManyTokens { tokens: u64, limit: u64 },
155
156 #[error("database error: {0}")]
158 Database(#[from] rusqlite::Error),
159
160 #[error("embedding error: {0}")]
162 Embedding(String),
163
164 #[error("sqlite-vec extension failed: {0}")]
166 VecExtension(String),
167
168 #[error("database busy: {0}")]
170 DbBusy(String),
171
172 #[error("batch partial failure: {failed} of {total} items failed")]
177 BatchPartialFailure { total: usize, failed: usize },
178
179 #[error("IO error: {0}")]
181 Io(#[from] std::io::Error),
182
183 #[error(transparent)]
185 Internal(#[from] anyhow::Error),
186
187 #[error("json error: {0}")]
189 Json(#[from] serde_json::Error),
190
191 #[error("lock busy: {0}")]
195 LockBusy(String),
196
197 #[error(
202 "all {max} concurrency slots occupied after waiting {waited_secs}s (exit 75); \
203 use --max-concurrency or wait for other invocations to finish"
204 )]
205 AllSlotsFull { max: usize, waited_secs: u64 },
206
207 #[error(
216 "job {job_type} for namespace '{namespace}' is already running (exit 75); \
217 wait for it to finish or pass --wait-job-singleton <SECONDS>"
218 )]
219 JobSingletonLocked { job_type: String, namespace: String },
220
221 #[error(
226 "embedding singleton for namespace '{namespace}' is already held (exit 75); \
227 another CLI is calling the LLM on this database; pass --wait-embed-singleton <SECONDS> to wait"
228 )]
229 EmbeddingSingletonLocked { namespace: String },
230
231 #[error(
236 "available memory ({available_mb}MB) below required minimum ({required_mb}MB) \
237 to load the model; abort other loads or use --skip-memory-guard (exit 77)"
238 )]
239 LowMemory { available_mb: u64, required_mb: u64 },
240
241 #[error("shutdown signal received: {signal}")]
251 Shutdown { signal: String },
252
253 #[error("preflight validation failed: {source}")]
270 PreFlightFailed { source: Box<PreFlightError> },
271
272 #[error("provider error (code {code}): {message}")]
288 ProviderError { code: String, message: String },
289}
290
291impl From<PreFlightError> for AppError {
298 fn from(source: PreFlightError) -> Self {
299 AppError::PreFlightFailed {
300 source: Box::new(source),
301 }
302 }
303}
304
305impl AppError {
306 #[inline]
333 #[must_use]
334 pub fn exit_code(&self) -> i32 {
335 match self {
336 Self::Validation(_) => 1,
337 Self::BinaryNotFound { .. } => 1,
338 Self::RateLimited { .. } => 1,
339 Self::Timeout { .. } => 1,
340 Self::Duplicate(_) => crate::constants::DUPLICATE_EXIT_CODE,
341 Self::Conflict(_) => 3,
342 Self::NotFound(_) => 4,
343 Self::MemoryNotFound { .. } => 4,
344 Self::MemoryNotFoundById { .. } => 4,
345 Self::EntityNotYetMaterialized { .. } => 4,
346 Self::NamespaceError(_) => 5,
347 Self::LimitExceeded(_) => 6,
348 Self::BodyTooLarge { .. } => 6,
349 Self::TooManyChunks { .. } => 6,
350 Self::TooManyTokens { .. } => 6,
351 Self::Database(_) => 10,
352 Self::Embedding(_) => 11,
353 Self::VecExtension(_) => 12,
354 Self::BatchPartialFailure { .. } => crate::constants::BATCH_PARTIAL_FAILURE_EXIT_CODE,
355 Self::DbBusy(_) => crate::constants::DB_BUSY_EXIT_CODE,
356 Self::Io(_) => 14,
357 Self::Internal(_) => 20,
358 Self::Json(_) => 20,
359 Self::LockBusy(_) => crate::constants::CLI_LOCK_EXIT_CODE,
360 Self::AllSlotsFull { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
361 Self::JobSingletonLocked { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
362 Self::EmbeddingSingletonLocked { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
363 Self::LowMemory { .. } => crate::constants::LOW_MEMORY_EXIT_CODE,
364 Self::Shutdown { .. } => crate::constants::SHUTDOWN_EXIT_CODE,
365 Self::PreFlightFailed { .. } => 16,
366 Self::ProviderError { .. } => 1,
367 }
368 }
369
370 #[inline]
384 #[must_use]
385 pub fn is_retryable(&self) -> bool {
386 matches!(
387 self,
388 Self::DbBusy(_)
389 | Self::LockBusy(_)
390 | Self::AllSlotsFull { .. }
391 | Self::JobSingletonLocked { .. }
392 | Self::EmbeddingSingletonLocked { .. }
393 | Self::LowMemory { .. }
394 | Self::RateLimited { .. }
395 | Self::Timeout { .. }
396 | Self::EntityNotYetMaterialized { .. }
397 )
398 }
399
400 #[inline]
415 #[must_use]
416 pub fn is_shutdown(&self) -> bool {
417 matches!(self, Self::Shutdown { .. })
418 }
419
420 #[inline]
435 #[must_use]
436 pub fn is_permanent(&self) -> bool {
437 matches!(
438 self,
439 Self::Validation(_)
440 | Self::BinaryNotFound { .. }
441 | Self::Duplicate(_)
442 | Self::NotFound(_)
443 | Self::MemoryNotFound { .. }
444 | Self::MemoryNotFoundById { .. }
445 | Self::NamespaceError(_)
446 | Self::LimitExceeded(_)
447 | Self::BodyTooLarge { .. }
448 | Self::TooManyChunks { .. }
449 | Self::TooManyTokens { .. }
450 | Self::VecExtension(_)
451 | Self::PreFlightFailed { .. }
452 | Self::ProviderError { .. }
453 )
454 }
455
456 #[must_use]
463 pub fn suggestion(&self) -> Option<&'static str> {
464 match self {
465 Self::Validation(_) => Some(
466 "review the input against the command's --help; names must be kebab-case (lowercase letters, digits, hyphens) and bodies non-empty",
467 ),
468 Self::Duplicate(_) => {
469 Some("pass --force-merge to update the existing memory instead of failing")
470 }
471 Self::Conflict(_) => Some(
472 "another writer changed the row; re-read with `read --name <n> --json` and retry with a fresh --expected-updated-at",
473 ),
474 Self::NotFound(_) | Self::MemoryNotFound { .. } | Self::MemoryNotFoundById { .. } => {
475 Some("verify the name/id and namespace with `list --json` or `read --name <n> --json`")
476 }
477 Self::NamespaceError(_) => {
478 Some("set --namespace or SQLITE_GRAPHRAG_NAMESPACE; inspect with `namespace-detect --json`")
479 }
480 Self::LimitExceeded(_) => {
481 Some("split the input into smaller memories or raise the documented cap before retrying")
482 }
483 Self::BodyTooLarge { .. } => {
484 Some("the body-bytes cap (MAX_MEMORY_BODY_LEN) fired; split the content into multiple memories or use --body-file")
485 }
486 Self::TooManyChunks { .. } => {
487 Some("the chunk-count cap (REMEMBER_MAX_SAFE_MULTI_CHUNKS) fired; split the document into smaller memories before writing")
488 }
489 Self::TooManyTokens { .. } => {
490 Some("the token cap (EMBEDDING_REQUEST_MAX_TOKENS) fired; split the content into multiple memories, keeping each under ~25000 tokens")
491 }
492 Self::Embedding(_) => Some(
493 "verify the embedding backend and OPENROUTER_API_KEY; re-run `enrich --operation re-embed` once resolved",
494 ),
495 Self::Database(_) | Self::DbBusy(_) => {
496 Some("run `health --json` then `vacuum --json`; widen --wait-lock if the database is busy")
497 }
498 Self::Io(_) => Some("check the path exists and is writable, then retry"),
499 Self::RateLimited { .. } => {
500 Some("wait for the reported retry-after window, then retry")
501 }
502 Self::LockBusy(_) | Self::AllSlotsFull { .. } | Self::JobSingletonLocked { .. } => {
503 Some("wait for the other invocation to finish or pass --wait-lock / --wait-job-singleton")
504 }
505 _ => None,
506 }
507 }
508
509 pub fn localized_message(&self) -> String {
514 self.localized_message_for(current())
515 }
516
517 pub fn localized_message_for(&self, lang: Language) -> String {
535 match lang {
536 Language::English => self.to_string(),
537 Language::Portuguese => self.to_string_pt(),
538 }
539 }
540
541 fn to_string_pt(&self) -> String {
542 use crate::i18n::validation::app_error_pt as pt;
543 match self {
544 Self::Validation(msg) => pt::validation(msg),
545 Self::BinaryNotFound { name } => pt::binary_not_found(name),
546 Self::RateLimited { detail } => pt::rate_limited(detail),
547 Self::Timeout {
548 operation,
549 duration_secs,
550 } => pt::timeout(operation, *duration_secs),
551 Self::Duplicate(msg) => pt::duplicate(msg),
552 Self::Conflict(msg) => pt::conflict(msg),
553 Self::NotFound(msg) => pt::not_found(msg),
554 Self::MemoryNotFound { name, namespace } => pt::memory_not_found(name, namespace),
555 Self::MemoryNotFoundById { id } => pt::memory_not_found_by_id(*id),
556 Self::EntityNotYetMaterialized { name, namespace } => {
557 pt::entity_not_yet_materialized(name, namespace)
558 }
559 Self::NamespaceError(msg) => pt::namespace_error(msg),
560 Self::LimitExceeded(msg) => pt::limit_exceeded(msg),
561 Self::BodyTooLarge { bytes, limit } => pt::body_too_large(*bytes, *limit),
562 Self::TooManyChunks { chunks, limit } => pt::too_many_chunks(*chunks, *limit),
563 Self::TooManyTokens { tokens, limit } => pt::too_many_tokens(*tokens, *limit),
564 Self::Database(e) => pt::database(&e.to_string()),
565 Self::Embedding(msg) => pt::embedding(msg),
566 Self::VecExtension(msg) => pt::vec_extension(msg),
567 Self::DbBusy(msg) => pt::db_busy(msg),
568 Self::BatchPartialFailure { total, failed } => {
569 pt::batch_partial_failure(*total, *failed)
570 }
571 Self::Io(e) => pt::io(&e.to_string()),
572 Self::Internal(e) => pt::internal(&e.to_string()),
573 Self::Json(e) => pt::json(&e.to_string()),
574 Self::LockBusy(msg) => pt::lock_busy(msg),
575 Self::AllSlotsFull { max, waited_secs } => pt::all_slots_full(*max, *waited_secs),
576 Self::JobSingletonLocked {
577 job_type,
578 namespace,
579 } => pt::job_singleton_locked(job_type, namespace),
580 Self::EmbeddingSingletonLocked { namespace } => {
581 pt::embedding_singleton_locked(namespace)
582 }
583 Self::LowMemory {
584 available_mb,
585 required_mb,
586 } => pt::low_memory(*available_mb, *required_mb),
587 Self::Shutdown { signal } => pt::shutdown(signal),
588 Self::PreFlightFailed { source } => pt::preflight_failed(&source.to_string()),
589 Self::ProviderError { code, message } => pt::provider_error(code, message),
590 }
591 }
592}
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597 use std::io;
598
599 #[test]
600 fn exit_code_validation_returns_1() {
601 assert_eq!(AppError::Validation("invalid field".into()).exit_code(), 1);
602 }
603
604 #[test]
606 fn suggestion_present_for_actionable_variants() {
607 assert!(AppError::Validation("bad name".into())
608 .suggestion()
609 .is_some());
610 let dup = AppError::Duplicate("global/x".into());
611 assert!(dup.suggestion().unwrap().contains("--force-merge"));
612 let nf = AppError::MemoryNotFound {
613 name: "x".into(),
614 namespace: "global".into(),
615 };
616 assert!(nf.suggestion().is_some());
617 }
618
619 #[test]
620 fn exit_code_duplicate_returns_9() {
621 assert_eq!(AppError::Duplicate("namespace/name".into()).exit_code(), 9);
622 }
623
624 #[test]
625 fn exit_code_conflict_returns_3() {
626 assert_eq!(
627 AppError::Conflict("updated_at changed".into()).exit_code(),
628 3
629 );
630 }
631
632 #[test]
633 fn exit_code_not_found_returns_4() {
634 assert_eq!(AppError::NotFound("memory missing".into()).exit_code(), 4);
635 }
636
637 #[test]
638 fn exit_code_namespace_error_returns_5() {
639 assert_eq!(
640 AppError::NamespaceError("not resolved".into()).exit_code(),
641 5
642 );
643 }
644
645 #[test]
646 fn exit_code_limit_exceeded_returns_6() {
647 assert_eq!(
648 AppError::LimitExceeded("body too large".into()).exit_code(),
649 6
650 );
651 }
652
653 #[test]
655 fn exit_code_body_too_large_and_too_many_chunks_return_6() {
656 assert_eq!(
657 AppError::BodyTooLarge {
658 bytes: 600_000,
659 limit: 512_000
660 }
661 .exit_code(),
662 6
663 );
664 assert_eq!(
665 AppError::TooManyChunks {
666 chunks: 700,
667 limit: 512
668 }
669 .exit_code(),
670 6
671 );
672 assert_eq!(
673 AppError::TooManyTokens {
674 tokens: 35_000,
675 limit: 30_000
676 }
677 .exit_code(),
678 6
679 );
680 }
681
682 #[test]
686 fn too_many_tokens_message_identifies_token_cap() {
687 let err = AppError::TooManyTokens {
688 tokens: 35_000,
689 limit: 30_000,
690 };
691 let msg = err.to_string();
692 assert!(msg.contains("limit exceeded"), "obtido: {msg}");
693 assert!(msg.contains("35000 tokens"), "obtido: {msg}");
694 assert!(msg.contains("30000-token cap"), "obtido: {msg}");
695 assert!(
696 msg.contains("EMBEDDING_REQUEST_MAX_TOKENS"),
697 "obtido: {msg}"
698 );
699 assert!(!msg.contains("byte cap"), "obtido: {msg}");
700 assert!(!msg.contains("chunk"), "obtido: {msg}");
701 }
702
703 #[test]
707 fn body_too_large_message_identifies_bytes_cap() {
708 let err = AppError::BodyTooLarge {
709 bytes: 600_000,
710 limit: 512_000,
711 };
712 let msg = err.to_string();
713 assert!(msg.contains("limit exceeded"), "obtido: {msg}");
714 assert!(msg.contains("600000 bytes"), "obtido: {msg}");
715 assert!(msg.contains("512000-byte cap"), "obtido: {msg}");
716 assert!(msg.contains("MAX_MEMORY_BODY_LEN"), "obtido: {msg}");
717 assert!(!msg.contains("chunk"), "obtido: {msg}");
718 }
719
720 #[test]
721 fn too_many_chunks_message_identifies_chunk_cap() {
722 let err = AppError::TooManyChunks {
723 chunks: 700,
724 limit: 512,
725 };
726 let msg = err.to_string();
727 assert!(msg.contains("limit exceeded"), "obtido: {msg}");
728 assert!(msg.contains("700 chunks"), "obtido: {msg}");
729 assert!(msg.contains("512-chunk cap"), "obtido: {msg}");
730 assert!(
731 msg.contains("REMEMBER_MAX_SAFE_MULTI_CHUNKS"),
732 "obtido: {msg}"
733 );
734 assert!(!msg.contains("byte cap"), "obtido: {msg}");
735 }
736
737 #[test]
738 fn typed_limit_variants_are_permanent_with_suggestion() {
739 let body = AppError::BodyTooLarge { bytes: 1, limit: 1 };
740 let chunks = AppError::TooManyChunks {
741 chunks: 1,
742 limit: 1,
743 };
744 assert!(body.is_permanent());
745 assert!(chunks.is_permanent());
746 assert!(!body.is_retryable());
747 assert!(!chunks.is_retryable());
748 assert!(body.suggestion().unwrap().contains("MAX_MEMORY_BODY_LEN"));
749 assert!(chunks
750 .suggestion()
751 .unwrap()
752 .contains("REMEMBER_MAX_SAFE_MULTI_CHUNKS"));
753 let tokens = AppError::TooManyTokens {
754 tokens: 1,
755 limit: 1,
756 };
757 assert!(tokens.is_permanent());
758 assert!(!tokens.is_retryable());
759 assert!(tokens
760 .suggestion()
761 .unwrap()
762 .contains("EMBEDDING_REQUEST_MAX_TOKENS"));
763 }
764
765 #[test]
766 fn typed_limit_variants_localize_to_pt() {
767 let body = AppError::BodyTooLarge {
768 bytes: 600_000,
769 limit: 512_000,
770 };
771 let pt = body.localized_message_for(crate::i18n::Language::Portuguese);
772 assert!(pt.contains("limite excedido"), "obtido: {pt}");
773 assert!(pt.contains("600000"), "obtido: {pt}");
774 let chunks = AppError::TooManyChunks {
775 chunks: 700,
776 limit: 512,
777 };
778 let pt = chunks.localized_message_for(crate::i18n::Language::Portuguese);
779 assert!(pt.contains("limite excedido"), "obtido: {pt}");
780 assert!(pt.contains("700"), "obtido: {pt}");
781 let tokens = AppError::TooManyTokens {
782 tokens: 35_000,
783 limit: 30_000,
784 };
785 let pt = tokens.localized_message_for(crate::i18n::Language::Portuguese);
786 assert!(pt.contains("limite excedido"), "obtido: {pt}");
787 assert!(pt.contains("35000"), "obtido: {pt}");
788 assert!(pt.contains("EMBEDDING_REQUEST_MAX_TOKENS"), "obtido: {pt}");
789 }
790
791 #[test]
792 fn exit_code_embedding_returns_11() {
793 assert_eq!(AppError::Embedding("model failure".into()).exit_code(), 11);
794 }
795
796 #[test]
797 fn exit_code_vec_extension_returns_12() {
798 assert_eq!(
799 AppError::VecExtension("extension did not load".into()).exit_code(),
800 12
801 );
802 }
803
804 #[test]
805 fn exit_code_db_busy_returns_15() {
806 assert_eq!(AppError::DbBusy("retries exhausted".into()).exit_code(), 15);
807 }
808
809 #[test]
810 fn exit_code_batch_partial_failure_returns_13() {
811 assert_eq!(
812 AppError::BatchPartialFailure {
813 total: 10,
814 failed: 3
815 }
816 .exit_code(),
817 13
818 );
819 }
820
821 #[test]
822 fn display_batch_partial_failure_includes_counts() {
823 let err = AppError::BatchPartialFailure {
824 total: 50,
825 failed: 7,
826 };
827 let msg = err.to_string();
828 assert!(msg.contains("7"));
829 assert!(msg.contains("50"));
830 assert!(msg.contains("batch partial failure"));
832 }
833
834 #[test]
835 fn exit_code_io_returns_14() {
836 let io_err = io::Error::new(io::ErrorKind::NotFound, "file missing");
837 assert_eq!(AppError::Io(io_err).exit_code(), 14);
838 }
839
840 #[test]
841 fn exit_code_internal_returns_20() {
842 let anyhow_err = anyhow::anyhow!("unexpected internal error");
843 assert_eq!(AppError::Internal(anyhow_err).exit_code(), 20);
844 }
845
846 #[test]
847 fn exit_code_json_returns_20() {
848 let json_err = serde_json::from_str::<serde_json::Value>("invalid json {{").unwrap_err();
849 assert_eq!(AppError::Json(json_err).exit_code(), 20);
850 }
851
852 #[test]
853 fn exit_code_lock_busy_returns_75() {
854 assert_eq!(
855 AppError::LockBusy("another active instance".into()).exit_code(),
856 75
857 );
858 }
859
860 #[test]
861 fn display_validation_includes_message() {
862 let err = AppError::Validation("invalid id".into());
863 assert!(err.to_string().contains("invalid id"));
864 assert!(err.to_string().contains("validation error"));
865 }
866
867 #[test]
868 fn display_duplicate_includes_message() {
869 let err = AppError::Duplicate("proj/mem".into());
870 assert!(err.to_string().contains("proj/mem"));
871 assert!(err.to_string().contains("duplicate detected"));
872 }
873
874 #[test]
875 fn display_not_found_includes_message() {
876 let err = AppError::NotFound("id 42".into());
877 assert!(err.to_string().contains("id 42"));
878 assert!(err.to_string().contains("not found"));
879 }
880
881 #[test]
882 fn display_embedding_includes_message() {
883 let err = AppError::Embedding("wrong dimension".into());
884 assert!(err.to_string().contains("wrong dimension"));
885 assert!(err.to_string().contains("embedding error"));
886 }
887
888 #[test]
889 fn display_lock_busy_includes_message() {
890 let err = AppError::LockBusy("pid 1234".into());
891 assert!(err.to_string().contains("pid 1234"));
892 assert!(err.to_string().contains("lock busy"));
893 }
894
895 #[test]
896 fn from_io_error_converts_correctly() {
897 let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "permission denied");
898 let app_err: AppError = io_err.into();
899 assert_eq!(app_err.exit_code(), 14);
900 assert!(app_err.to_string().contains("IO error"));
901 }
902
903 #[test]
904 fn from_anyhow_error_converts_correctly() {
905 let anyhow_err = anyhow::anyhow!("internal detail");
906 let app_err: AppError = anyhow_err.into();
907 assert_eq!(app_err.exit_code(), 20);
908 assert!(app_err.to_string().contains("internal detail"));
909 }
910
911 #[test]
912 fn from_serde_json_error_converts_correctly() {
913 let json_err = serde_json::from_str::<serde_json::Value>("{bad_field}").unwrap_err();
914 let app_err: AppError = json_err.into();
915 assert_eq!(app_err.exit_code(), 20);
916 assert!(app_err.to_string().contains("json error"));
917 }
918
919 #[test]
920 fn exit_code_lock_busy_matches_constant() {
921 assert_eq!(
922 AppError::LockBusy("test".into()).exit_code(),
923 crate::constants::CLI_LOCK_EXIT_CODE
924 );
925 }
926
927 #[test]
928 fn localized_message_en_equals_to_string() {
929 let err = AppError::NotFound("mem-x".into());
930 assert_eq!(
931 err.localized_message_for(crate::i18n::Language::English),
932 err.to_string()
933 );
934 }
935
936 #[test]
941 fn localized_message_pt_differs_from_en() {
942 let err = AppError::NotFound("mem-x".into());
943 let en = err.localized_message_for(crate::i18n::Language::English);
944 let pt = err.localized_message_for(crate::i18n::Language::Portuguese);
945 assert_ne!(en, pt, "PT and EN must produce distinct messages");
946 assert!(pt.contains("mem-x"), "PT must include the variant payload");
947 }
948
949 #[test]
950 fn localized_message_pt_delegates_to_app_error_pt_helper() {
951 use crate::i18n::validation::app_error_pt as pt;
952
953 let cases: Vec<(AppError, String)> = vec![
954 (AppError::Validation("x".into()), pt::validation("x")),
955 (AppError::Duplicate("x".into()), pt::duplicate("x")),
956 (AppError::Conflict("x".into()), pt::conflict("x")),
957 (AppError::NotFound("x".into()), pt::not_found("x")),
958 (
959 AppError::NamespaceError("x".into()),
960 pt::namespace_error("x"),
961 ),
962 (AppError::LimitExceeded("x".into()), pt::limit_exceeded("x")),
963 (AppError::Embedding("x".into()), pt::embedding("x")),
964 (AppError::VecExtension("x".into()), pt::vec_extension("x")),
965 (AppError::DbBusy("x".into()), pt::db_busy("x")),
966 (
967 AppError::BatchPartialFailure {
968 total: 10,
969 failed: 3,
970 },
971 pt::batch_partial_failure(10, 3),
972 ),
973 (AppError::LockBusy("x".into()), pt::lock_busy("x")),
974 (
975 AppError::AllSlotsFull {
976 max: 4,
977 waited_secs: 60,
978 },
979 pt::all_slots_full(4, 60),
980 ),
981 (
982 AppError::LowMemory {
983 available_mb: 100,
984 required_mb: 500,
985 },
986 pt::low_memory(100, 500),
987 ),
988 (
989 AppError::BinaryNotFound {
990 name: "claude".into(),
991 },
992 pt::binary_not_found("claude"),
993 ),
994 (
995 AppError::RateLimited {
996 detail: "429".into(),
997 },
998 pt::rate_limited("429"),
999 ),
1000 (
1001 AppError::Timeout {
1002 operation: "op".into(),
1003 duration_secs: 30,
1004 },
1005 pt::timeout("op", 30),
1006 ),
1007 ];
1008
1009 for (err, expected) in cases {
1010 let actual = err.localized_message_for(crate::i18n::Language::Portuguese);
1011 assert_eq!(actual, expected, "delegation mismatch");
1012 }
1013 }
1014
1015 #[test]
1016 fn is_retryable_transient_errors() {
1017 assert!(AppError::DbBusy("x".into()).is_retryable());
1018 assert!(AppError::LockBusy("x".into()).is_retryable());
1019 assert!(AppError::AllSlotsFull {
1020 max: 4,
1021 waited_secs: 60
1022 }
1023 .is_retryable());
1024 assert!(AppError::LowMemory {
1025 available_mb: 100,
1026 required_mb: 500
1027 }
1028 .is_retryable());
1029 assert!(AppError::RateLimited {
1030 detail: "429".into()
1031 }
1032 .is_retryable());
1033 assert!(AppError::Timeout {
1034 operation: "op".into(),
1035 duration_secs: 30
1036 }
1037 .is_retryable());
1038 }
1039
1040 #[test]
1041 fn is_retryable_permanent_errors() {
1042 assert!(!AppError::Validation("x".into()).is_retryable());
1043 assert!(!AppError::NotFound("x".into()).is_retryable());
1044 assert!(!AppError::Duplicate("x".into()).is_retryable());
1045 assert!(!AppError::Conflict("x".into()).is_retryable());
1046 assert!(!AppError::BinaryNotFound { name: "x".into() }.is_retryable());
1047 }
1048
1049 #[test]
1050 fn exit_code_new_variants() {
1051 assert_eq!(AppError::BinaryNotFound { name: "x".into() }.exit_code(), 1);
1052 assert_eq!(AppError::RateLimited { detail: "x".into() }.exit_code(), 1);
1053 assert_eq!(
1054 AppError::Timeout {
1055 operation: "x".into(),
1056 duration_secs: 5
1057 }
1058 .exit_code(),
1059 1
1060 );
1061 }
1062
1063 #[test]
1066 fn entity_not_yet_materialized_exit_code_is_4() {
1067 let e = AppError::EntityNotYetMaterialized {
1068 name: "acme".into(),
1069 namespace: "global".into(),
1070 };
1071 assert_eq!(e.exit_code(), 4);
1072 }
1073
1074 #[test]
1075 fn entity_not_yet_materialized_is_retryable_not_permanent() {
1076 let e = AppError::EntityNotYetMaterialized {
1077 name: "acme".into(),
1078 namespace: "global".into(),
1079 };
1080 assert!(e.is_retryable());
1081 assert!(!e.is_permanent());
1082 }
1083
1084 #[test]
1085 fn entity_not_yet_materialized_user_message_non_empty() {
1086 let e = AppError::EntityNotYetMaterialized {
1087 name: "acme".into(),
1088 namespace: "global".into(),
1089 };
1090 assert!(!e
1091 .localized_message_for(crate::i18n::Language::English)
1092 .is_empty());
1093 assert!(!e
1094 .localized_message_for(crate::i18n::Language::Portuguese)
1095 .is_empty());
1096 }
1097
1098 #[test]
1099 fn app_error_size_does_not_exceed_budget() {
1100 let size = std::mem::size_of::<AppError>();
1101 assert!(
1102 size <= 128,
1103 "AppError is {size} bytes — exceeds 128-byte budget; \
1104 consider boxing large variants to reduce memcpy cost in Result propagation"
1105 );
1106 }
1107}