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 #[error("provider error (code {code}): {message}")]
215 ProviderError { code: String, message: String },
216}
217
218impl From<PreFlightError> for AppError {
225 fn from(source: PreFlightError) -> Self {
226 AppError::PreFlightFailed {
227 source: Box::new(source),
228 }
229 }
230}
231
232impl AppError {
233 #[inline]
260 #[must_use]
261 pub fn exit_code(&self) -> i32 {
262 match self {
263 Self::Validation(_) => 1,
264 Self::BinaryNotFound { .. } => 1,
265 Self::RateLimited { .. } => 1,
266 Self::Timeout { .. } => 1,
267 Self::Duplicate(_) => crate::constants::DUPLICATE_EXIT_CODE,
268 Self::Conflict(_) => 3,
269 Self::NotFound(_) => 4,
270 Self::MemoryNotFound { .. } => 4,
271 Self::MemoryNotFoundById { .. } => 4,
272 Self::NamespaceError(_) => 5,
273 Self::LimitExceeded(_) => 6,
274 Self::Database(_) => 10,
275 Self::Embedding(_) => 11,
276 Self::VecExtension(_) => 12,
277 Self::BatchPartialFailure { .. } => crate::constants::BATCH_PARTIAL_FAILURE_EXIT_CODE,
278 Self::DbBusy(_) => crate::constants::DB_BUSY_EXIT_CODE,
279 Self::Io(_) => 14,
280 Self::Internal(_) => 20,
281 Self::Json(_) => 20,
282 Self::LockBusy(_) => crate::constants::CLI_LOCK_EXIT_CODE,
283 Self::AllSlotsFull { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
284 Self::JobSingletonLocked { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
285 Self::EmbeddingSingletonLocked { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
286 Self::LowMemory { .. } => crate::constants::LOW_MEMORY_EXIT_CODE,
287 Self::Shutdown { .. } => crate::constants::SHUTDOWN_EXIT_CODE,
288 Self::PreFlightFailed { .. } => 16,
289 Self::ProviderError { .. } => 1,
290 }
291 }
292
293 #[inline]
307 #[must_use]
308 pub fn is_retryable(&self) -> bool {
309 matches!(
310 self,
311 Self::DbBusy(_)
312 | Self::LockBusy(_)
313 | Self::AllSlotsFull { .. }
314 | Self::JobSingletonLocked { .. }
315 | Self::EmbeddingSingletonLocked { .. }
316 | Self::LowMemory { .. }
317 | Self::RateLimited { .. }
318 | Self::Timeout { .. }
319 )
320 }
321
322 #[inline]
337 #[must_use]
338 pub fn is_shutdown(&self) -> bool {
339 matches!(self, Self::Shutdown { .. })
340 }
341
342 #[inline]
357 #[must_use]
358 pub fn is_permanent(&self) -> bool {
359 matches!(
360 self,
361 Self::Validation(_)
362 | Self::BinaryNotFound { .. }
363 | Self::Duplicate(_)
364 | Self::NotFound(_)
365 | Self::MemoryNotFound { .. }
366 | Self::MemoryNotFoundById { .. }
367 | Self::NamespaceError(_)
368 | Self::LimitExceeded(_)
369 | Self::VecExtension(_)
370 | Self::PreFlightFailed { .. }
371 | Self::ProviderError { .. }
372 )
373 }
374
375 #[must_use]
382 pub fn suggestion(&self) -> Option<&'static str> {
383 match self {
384 Self::Validation(_) => Some(
385 "review the input against the command's --help; names must be kebab-case (lowercase letters, digits, hyphens) and bodies non-empty",
386 ),
387 Self::Duplicate(_) => {
388 Some("pass --force-merge to update the existing memory instead of failing")
389 }
390 Self::Conflict(_) => Some(
391 "another writer changed the row; re-read with `read --name <n> --json` and retry with a fresh --expected-updated-at",
392 ),
393 Self::NotFound(_) | Self::MemoryNotFound { .. } | Self::MemoryNotFoundById { .. } => {
394 Some("verify the name/id and namespace with `list --json` or `read --name <n> --json`")
395 }
396 Self::NamespaceError(_) => {
397 Some("set --namespace or SQLITE_GRAPHRAG_NAMESPACE; inspect with `namespace-detect --json`")
398 }
399 Self::LimitExceeded(_) => {
400 Some("split the input into smaller memories or raise the documented cap before retrying")
401 }
402 Self::Embedding(_) => Some(
403 "verify the embedding backend and OPENROUTER_API_KEY; re-run `enrich --operation re-embed` once resolved",
404 ),
405 Self::Database(_) | Self::DbBusy(_) => {
406 Some("run `health --json` then `vacuum --json`; widen --wait-lock if the database is busy")
407 }
408 Self::Io(_) => Some("check the path exists and is writable, then retry"),
409 Self::RateLimited { .. } => {
410 Some("wait for the reported retry-after window, then retry")
411 }
412 Self::LockBusy(_) | Self::AllSlotsFull { .. } | Self::JobSingletonLocked { .. } => {
413 Some("wait for the other invocation to finish or pass --wait-lock / --wait-job-singleton")
414 }
415 _ => None,
416 }
417 }
418
419 pub fn localized_message(&self) -> String {
424 self.localized_message_for(current())
425 }
426
427 pub fn localized_message_for(&self, lang: Language) -> String {
445 match lang {
446 Language::English => self.to_string(),
447 Language::Portuguese => self.to_string_pt(),
448 }
449 }
450
451 fn to_string_pt(&self) -> String {
452 use crate::i18n::validation::app_error_pt as pt;
453 match self {
454 Self::Validation(msg) => pt::validation(msg),
455 Self::BinaryNotFound { name } => pt::binary_not_found(name),
456 Self::RateLimited { detail } => pt::rate_limited(detail),
457 Self::Timeout {
458 operation,
459 duration_secs,
460 } => pt::timeout(operation, *duration_secs),
461 Self::Duplicate(msg) => pt::duplicate(msg),
462 Self::Conflict(msg) => pt::conflict(msg),
463 Self::NotFound(msg) => pt::not_found(msg),
464 Self::MemoryNotFound { name, namespace } => pt::memory_not_found(name, namespace),
465 Self::MemoryNotFoundById { id } => pt::memory_not_found_by_id(*id),
466 Self::NamespaceError(msg) => pt::namespace_error(msg),
467 Self::LimitExceeded(msg) => pt::limit_exceeded(msg),
468 Self::Database(e) => pt::database(&e.to_string()),
469 Self::Embedding(msg) => pt::embedding(msg),
470 Self::VecExtension(msg) => pt::vec_extension(msg),
471 Self::DbBusy(msg) => pt::db_busy(msg),
472 Self::BatchPartialFailure { total, failed } => {
473 pt::batch_partial_failure(*total, *failed)
474 }
475 Self::Io(e) => pt::io(&e.to_string()),
476 Self::Internal(e) => pt::internal(&e.to_string()),
477 Self::Json(e) => pt::json(&e.to_string()),
478 Self::LockBusy(msg) => pt::lock_busy(msg),
479 Self::AllSlotsFull { max, waited_secs } => pt::all_slots_full(*max, *waited_secs),
480 Self::JobSingletonLocked {
481 job_type,
482 namespace,
483 } => pt::job_singleton_locked(job_type, namespace),
484 Self::EmbeddingSingletonLocked { namespace } => {
485 pt::embedding_singleton_locked(namespace)
486 }
487 Self::LowMemory {
488 available_mb,
489 required_mb,
490 } => pt::low_memory(*available_mb, *required_mb),
491 Self::Shutdown { signal } => pt::shutdown(signal),
492 Self::PreFlightFailed { source } => pt::preflight_failed(&source.to_string()),
493 Self::ProviderError { code, message } => pt::provider_error(code, message),
494 }
495 }
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501 use std::io;
502
503 #[test]
504 fn exit_code_validation_returns_1() {
505 assert_eq!(AppError::Validation("invalid field".into()).exit_code(), 1);
506 }
507
508 #[test]
510 fn suggestion_present_for_actionable_variants() {
511 assert!(AppError::Validation("bad name".into())
512 .suggestion()
513 .is_some());
514 let dup = AppError::Duplicate("global/x".into());
515 assert!(dup.suggestion().unwrap().contains("--force-merge"));
516 let nf = AppError::MemoryNotFound {
517 name: "x".into(),
518 namespace: "global".into(),
519 };
520 assert!(nf.suggestion().is_some());
521 }
522
523 #[test]
524 fn exit_code_duplicate_returns_9() {
525 assert_eq!(AppError::Duplicate("namespace/name".into()).exit_code(), 9);
526 }
527
528 #[test]
529 fn exit_code_conflict_returns_3() {
530 assert_eq!(
531 AppError::Conflict("updated_at changed".into()).exit_code(),
532 3
533 );
534 }
535
536 #[test]
537 fn exit_code_not_found_returns_4() {
538 assert_eq!(AppError::NotFound("memory missing".into()).exit_code(), 4);
539 }
540
541 #[test]
542 fn exit_code_namespace_error_returns_5() {
543 assert_eq!(
544 AppError::NamespaceError("not resolved".into()).exit_code(),
545 5
546 );
547 }
548
549 #[test]
550 fn exit_code_limit_exceeded_returns_6() {
551 assert_eq!(
552 AppError::LimitExceeded("body too large".into()).exit_code(),
553 6
554 );
555 }
556
557 #[test]
558 fn exit_code_embedding_returns_11() {
559 assert_eq!(AppError::Embedding("model failure".into()).exit_code(), 11);
560 }
561
562 #[test]
563 fn exit_code_vec_extension_returns_12() {
564 assert_eq!(
565 AppError::VecExtension("extension did not load".into()).exit_code(),
566 12
567 );
568 }
569
570 #[test]
571 fn exit_code_db_busy_returns_15() {
572 assert_eq!(AppError::DbBusy("retries exhausted".into()).exit_code(), 15);
573 }
574
575 #[test]
576 fn exit_code_batch_partial_failure_returns_13() {
577 assert_eq!(
578 AppError::BatchPartialFailure {
579 total: 10,
580 failed: 3
581 }
582 .exit_code(),
583 13
584 );
585 }
586
587 #[test]
588 fn display_batch_partial_failure_includes_counts() {
589 let err = AppError::BatchPartialFailure {
590 total: 50,
591 failed: 7,
592 };
593 let msg = err.to_string();
594 assert!(msg.contains("7"));
595 assert!(msg.contains("50"));
596 assert!(msg.contains("batch partial failure"));
598 }
599
600 #[test]
601 fn exit_code_io_returns_14() {
602 let io_err = io::Error::new(io::ErrorKind::NotFound, "file missing");
603 assert_eq!(AppError::Io(io_err).exit_code(), 14);
604 }
605
606 #[test]
607 fn exit_code_internal_returns_20() {
608 let anyhow_err = anyhow::anyhow!("unexpected internal error");
609 assert_eq!(AppError::Internal(anyhow_err).exit_code(), 20);
610 }
611
612 #[test]
613 fn exit_code_json_returns_20() {
614 let json_err = serde_json::from_str::<serde_json::Value>("invalid json {{").unwrap_err();
615 assert_eq!(AppError::Json(json_err).exit_code(), 20);
616 }
617
618 #[test]
619 fn exit_code_lock_busy_returns_75() {
620 assert_eq!(
621 AppError::LockBusy("another active instance".into()).exit_code(),
622 75
623 );
624 }
625
626 #[test]
627 fn display_validation_includes_message() {
628 let err = AppError::Validation("invalid id".into());
629 assert!(err.to_string().contains("invalid id"));
630 assert!(err.to_string().contains("validation error"));
631 }
632
633 #[test]
634 fn display_duplicate_includes_message() {
635 let err = AppError::Duplicate("proj/mem".into());
636 assert!(err.to_string().contains("proj/mem"));
637 assert!(err.to_string().contains("duplicate detected"));
638 }
639
640 #[test]
641 fn display_not_found_includes_message() {
642 let err = AppError::NotFound("id 42".into());
643 assert!(err.to_string().contains("id 42"));
644 assert!(err.to_string().contains("not found"));
645 }
646
647 #[test]
648 fn display_embedding_includes_message() {
649 let err = AppError::Embedding("wrong dimension".into());
650 assert!(err.to_string().contains("wrong dimension"));
651 assert!(err.to_string().contains("embedding error"));
652 }
653
654 #[test]
655 fn display_lock_busy_includes_message() {
656 let err = AppError::LockBusy("pid 1234".into());
657 assert!(err.to_string().contains("pid 1234"));
658 assert!(err.to_string().contains("lock busy"));
659 }
660
661 #[test]
662 fn from_io_error_converts_correctly() {
663 let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "permission denied");
664 let app_err: AppError = io_err.into();
665 assert_eq!(app_err.exit_code(), 14);
666 assert!(app_err.to_string().contains("IO error"));
667 }
668
669 #[test]
670 fn from_anyhow_error_converts_correctly() {
671 let anyhow_err = anyhow::anyhow!("internal detail");
672 let app_err: AppError = anyhow_err.into();
673 assert_eq!(app_err.exit_code(), 20);
674 assert!(app_err.to_string().contains("internal detail"));
675 }
676
677 #[test]
678 fn from_serde_json_error_converts_correctly() {
679 let json_err = serde_json::from_str::<serde_json::Value>("{bad_field}").unwrap_err();
680 let app_err: AppError = json_err.into();
681 assert_eq!(app_err.exit_code(), 20);
682 assert!(app_err.to_string().contains("json error"));
683 }
684
685 #[test]
686 fn exit_code_lock_busy_matches_constant() {
687 assert_eq!(
688 AppError::LockBusy("test".into()).exit_code(),
689 crate::constants::CLI_LOCK_EXIT_CODE
690 );
691 }
692
693 #[test]
694 fn localized_message_en_equals_to_string() {
695 let err = AppError::NotFound("mem-x".into());
696 assert_eq!(
697 err.localized_message_for(crate::i18n::Language::English),
698 err.to_string()
699 );
700 }
701
702 #[test]
707 fn localized_message_pt_differs_from_en() {
708 let err = AppError::NotFound("mem-x".into());
709 let en = err.localized_message_for(crate::i18n::Language::English);
710 let pt = err.localized_message_for(crate::i18n::Language::Portuguese);
711 assert_ne!(en, pt, "PT and EN must produce distinct messages");
712 assert!(pt.contains("mem-x"), "PT must include the variant payload");
713 }
714
715 #[test]
716 fn localized_message_pt_delegates_to_app_error_pt_helper() {
717 use crate::i18n::validation::app_error_pt as pt;
718
719 let cases: Vec<(AppError, String)> = vec![
720 (AppError::Validation("x".into()), pt::validation("x")),
721 (AppError::Duplicate("x".into()), pt::duplicate("x")),
722 (AppError::Conflict("x".into()), pt::conflict("x")),
723 (AppError::NotFound("x".into()), pt::not_found("x")),
724 (
725 AppError::NamespaceError("x".into()),
726 pt::namespace_error("x"),
727 ),
728 (AppError::LimitExceeded("x".into()), pt::limit_exceeded("x")),
729 (AppError::Embedding("x".into()), pt::embedding("x")),
730 (AppError::VecExtension("x".into()), pt::vec_extension("x")),
731 (AppError::DbBusy("x".into()), pt::db_busy("x")),
732 (
733 AppError::BatchPartialFailure {
734 total: 10,
735 failed: 3,
736 },
737 pt::batch_partial_failure(10, 3),
738 ),
739 (AppError::LockBusy("x".into()), pt::lock_busy("x")),
740 (
741 AppError::AllSlotsFull {
742 max: 4,
743 waited_secs: 60,
744 },
745 pt::all_slots_full(4, 60),
746 ),
747 (
748 AppError::LowMemory {
749 available_mb: 100,
750 required_mb: 500,
751 },
752 pt::low_memory(100, 500),
753 ),
754 (
755 AppError::BinaryNotFound {
756 name: "claude".into(),
757 },
758 pt::binary_not_found("claude"),
759 ),
760 (
761 AppError::RateLimited {
762 detail: "429".into(),
763 },
764 pt::rate_limited("429"),
765 ),
766 (
767 AppError::Timeout {
768 operation: "op".into(),
769 duration_secs: 30,
770 },
771 pt::timeout("op", 30),
772 ),
773 ];
774
775 for (err, expected) in cases {
776 let actual = err.localized_message_for(crate::i18n::Language::Portuguese);
777 assert_eq!(actual, expected, "delegation mismatch");
778 }
779 }
780
781 #[test]
782 fn is_retryable_transient_errors() {
783 assert!(AppError::DbBusy("x".into()).is_retryable());
784 assert!(AppError::LockBusy("x".into()).is_retryable());
785 assert!(AppError::AllSlotsFull {
786 max: 4,
787 waited_secs: 60
788 }
789 .is_retryable());
790 assert!(AppError::LowMemory {
791 available_mb: 100,
792 required_mb: 500
793 }
794 .is_retryable());
795 assert!(AppError::RateLimited {
796 detail: "429".into()
797 }
798 .is_retryable());
799 assert!(AppError::Timeout {
800 operation: "op".into(),
801 duration_secs: 30
802 }
803 .is_retryable());
804 }
805
806 #[test]
807 fn is_retryable_permanent_errors() {
808 assert!(!AppError::Validation("x".into()).is_retryable());
809 assert!(!AppError::NotFound("x".into()).is_retryable());
810 assert!(!AppError::Duplicate("x".into()).is_retryable());
811 assert!(!AppError::Conflict("x".into()).is_retryable());
812 assert!(!AppError::BinaryNotFound { name: "x".into() }.is_retryable());
813 }
814
815 #[test]
816 fn exit_code_new_variants() {
817 assert_eq!(AppError::BinaryNotFound { name: "x".into() }.exit_code(), 1);
818 assert_eq!(AppError::RateLimited { detail: "x".into() }.exit_code(), 1);
819 assert_eq!(
820 AppError::Timeout {
821 operation: "x".into(),
822 duration_secs: 5
823 }
824 .exit_code(),
825 1
826 );
827 }
828
829 #[test]
830 fn app_error_size_does_not_exceed_budget() {
831 let size = std::mem::size_of::<AppError>();
832 assert!(
833 size <= 128,
834 "AppError is {size} bytes — exceeds 128-byte budget; \
835 consider boxing large variants to reduce memcpy cost in Result propagation"
836 );
837 }
838}