1use crate::i18n::{current, Language};
9use thiserror::Error;
10
11#[derive(Error, Debug)]
22#[non_exhaustive]
23pub enum AppError {
24 #[error("validation error: {0}")]
30 Validation(String),
31
32 #[error("binary not found: {name} — ensure it is installed and in PATH")]
34 BinaryNotFound { name: String },
35
36 #[error("rate limited: {detail}")]
38 RateLimited { detail: String },
39
40 #[error("timeout after {duration_secs}s: {operation}")]
42 Timeout {
43 operation: String,
44 duration_secs: u64,
45 },
46
47 #[error("duplicate detected: {0}")]
49 Duplicate(String),
50
51 #[error("conflict: {0}")]
53 Conflict(String),
54
55 #[error("not found: {0}")]
57 NotFound(String),
58
59 #[error("memory not found: name='{name}' in namespace '{namespace}'")]
68 MemoryNotFound { name: String, namespace: String },
69
70 #[error("memory not found: id={id}")]
72 MemoryNotFoundById { id: i64 },
73
74 #[error("namespace not resolved: {0}")]
76 NamespaceError(String),
77
78 #[error("limit exceeded: {0}")]
80 LimitExceeded(String),
81
82 #[error("database error: {0}")]
84 Database(#[from] rusqlite::Error),
85
86 #[error("embedding error: {0}")]
88 Embedding(String),
89
90 #[error("sqlite-vec extension failed: {0}")]
92 VecExtension(String),
93
94 #[error("database busy: {0}")]
96 DbBusy(String),
97
98 #[error("batch partial failure: {failed} of {total} items failed")]
103 BatchPartialFailure { total: usize, failed: usize },
104
105 #[error("IO error: {0}")]
107 Io(#[from] std::io::Error),
108
109 #[error(transparent)]
111 Internal(#[from] anyhow::Error),
112
113 #[error("json error: {0}")]
115 Json(#[from] serde_json::Error),
116
117 #[error("lock busy: {0}")]
121 LockBusy(String),
122
123 #[error(
128 "all {max} concurrency slots occupied after waiting {waited_secs}s (exit 75); \
129 use --max-concurrency or wait for other invocations to finish"
130 )]
131 AllSlotsFull { max: usize, waited_secs: u64 },
132
133 #[error(
142 "job {job_type} for namespace '{namespace}' is already running (exit 75); \
143 wait for it to finish or pass --wait-job-singleton <SECONDS>"
144 )]
145 JobSingletonLocked { job_type: String, namespace: String },
146
147 #[error(
152 "embedding singleton for namespace '{namespace}' is already held (exit 75); \
153 another CLI is calling the LLM on this database; pass --wait-embed-singleton <SECONDS> to wait"
154 )]
155 EmbeddingSingletonLocked { namespace: String },
156
157 #[error(
162 "available memory ({available_mb}MB) below required minimum ({required_mb}MB) \
163 to load the model; abort other loads or use --skip-memory-guard (exit 77)"
164 )]
165 LowMemory { available_mb: u64, required_mb: u64 },
166
167 #[error("shutdown signal received: {signal}")]
177 Shutdown { signal: String },
178}
179
180impl AppError {
181 #[inline]
208 #[must_use]
209 pub fn exit_code(&self) -> i32 {
210 match self {
211 Self::Validation(_) => 1,
212 Self::BinaryNotFound { .. } => 1,
213 Self::RateLimited { .. } => 1,
214 Self::Timeout { .. } => 1,
215 Self::Duplicate(_) => crate::constants::DUPLICATE_EXIT_CODE,
216 Self::Conflict(_) => 3,
217 Self::NotFound(_) => 4,
218 Self::MemoryNotFound { .. } => 4,
219 Self::MemoryNotFoundById { .. } => 4,
220 Self::NamespaceError(_) => 5,
221 Self::LimitExceeded(_) => 6,
222 Self::Database(_) => 10,
223 Self::Embedding(_) => 11,
224 Self::VecExtension(_) => 12,
225 Self::BatchPartialFailure { .. } => crate::constants::BATCH_PARTIAL_FAILURE_EXIT_CODE,
226 Self::DbBusy(_) => crate::constants::DB_BUSY_EXIT_CODE,
227 Self::Io(_) => 14,
228 Self::Internal(_) => 20,
229 Self::Json(_) => 20,
230 Self::LockBusy(_) => crate::constants::CLI_LOCK_EXIT_CODE,
231 Self::AllSlotsFull { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
232 Self::JobSingletonLocked { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
233 Self::EmbeddingSingletonLocked { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
234 Self::LowMemory { .. } => crate::constants::LOW_MEMORY_EXIT_CODE,
235 Self::Shutdown { .. } => crate::constants::SHUTDOWN_EXIT_CODE,
236 }
237 }
238
239 #[inline]
253 #[must_use]
254 pub fn is_retryable(&self) -> bool {
255 matches!(
256 self,
257 Self::DbBusy(_)
258 | Self::LockBusy(_)
259 | Self::AllSlotsFull { .. }
260 | Self::JobSingletonLocked { .. }
261 | Self::EmbeddingSingletonLocked { .. }
262 | Self::LowMemory { .. }
263 | Self::RateLimited { .. }
264 | Self::Timeout { .. }
265 )
266 }
267
268 #[inline]
283 #[must_use]
284 pub fn is_shutdown(&self) -> bool {
285 matches!(self, Self::Shutdown { .. })
286 }
287
288 #[inline]
303 #[must_use]
304 pub fn is_permanent(&self) -> bool {
305 matches!(
306 self,
307 Self::Validation(_)
308 | Self::BinaryNotFound { .. }
309 | Self::Duplicate(_)
310 | Self::NotFound(_)
311 | Self::MemoryNotFound { .. }
312 | Self::MemoryNotFoundById { .. }
313 | Self::NamespaceError(_)
314 | Self::LimitExceeded(_)
315 | Self::VecExtension(_)
316 )
317 }
318
319 pub fn localized_message(&self) -> String {
324 self.localized_message_for(current())
325 }
326
327 pub fn localized_message_for(&self, lang: Language) -> String {
345 match lang {
346 Language::English => self.to_string(),
347 Language::Portuguese => self.to_string_pt(),
348 }
349 }
350
351 fn to_string_pt(&self) -> String {
352 use crate::i18n::validation::app_error_pt as pt;
353 match self {
354 Self::Validation(msg) => pt::validation(msg),
355 Self::BinaryNotFound { name } => pt::binary_not_found(name),
356 Self::RateLimited { detail } => pt::rate_limited(detail),
357 Self::Timeout {
358 operation,
359 duration_secs,
360 } => pt::timeout(operation, *duration_secs),
361 Self::Duplicate(msg) => pt::duplicate(msg),
362 Self::Conflict(msg) => pt::conflict(msg),
363 Self::NotFound(msg) => pt::not_found(msg),
364 Self::MemoryNotFound { name, namespace } => pt::memory_not_found(name, namespace),
365 Self::MemoryNotFoundById { id } => pt::memory_not_found_by_id(*id),
366 Self::NamespaceError(msg) => pt::namespace_error(msg),
367 Self::LimitExceeded(msg) => pt::limit_exceeded(msg),
368 Self::Database(e) => pt::database(&e.to_string()),
369 Self::Embedding(msg) => pt::embedding(msg),
370 Self::VecExtension(msg) => pt::vec_extension(msg),
371 Self::DbBusy(msg) => pt::db_busy(msg),
372 Self::BatchPartialFailure { total, failed } => {
373 pt::batch_partial_failure(*total, *failed)
374 }
375 Self::Io(e) => pt::io(&e.to_string()),
376 Self::Internal(e) => pt::internal(&e.to_string()),
377 Self::Json(e) => pt::json(&e.to_string()),
378 Self::LockBusy(msg) => pt::lock_busy(msg),
379 Self::AllSlotsFull { max, waited_secs } => pt::all_slots_full(*max, *waited_secs),
380 Self::JobSingletonLocked {
381 job_type,
382 namespace,
383 } => pt::job_singleton_locked(job_type, namespace),
384 Self::EmbeddingSingletonLocked { namespace } => {
385 pt::embedding_singleton_locked(namespace)
386 }
387 Self::LowMemory {
388 available_mb,
389 required_mb,
390 } => pt::low_memory(*available_mb, *required_mb),
391 Self::Shutdown { signal } => pt::shutdown(signal),
392 }
393 }
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399 use std::io;
400
401 #[test]
402 fn exit_code_validation_returns_1() {
403 assert_eq!(AppError::Validation("invalid field".into()).exit_code(), 1);
404 }
405
406 #[test]
407 fn exit_code_duplicate_returns_9() {
408 assert_eq!(AppError::Duplicate("namespace/name".into()).exit_code(), 9);
409 }
410
411 #[test]
412 fn exit_code_conflict_returns_3() {
413 assert_eq!(
414 AppError::Conflict("updated_at changed".into()).exit_code(),
415 3
416 );
417 }
418
419 #[test]
420 fn exit_code_not_found_returns_4() {
421 assert_eq!(AppError::NotFound("memory missing".into()).exit_code(), 4);
422 }
423
424 #[test]
425 fn exit_code_namespace_error_returns_5() {
426 assert_eq!(
427 AppError::NamespaceError("not resolved".into()).exit_code(),
428 5
429 );
430 }
431
432 #[test]
433 fn exit_code_limit_exceeded_returns_6() {
434 assert_eq!(
435 AppError::LimitExceeded("body too large".into()).exit_code(),
436 6
437 );
438 }
439
440 #[test]
441 fn exit_code_embedding_returns_11() {
442 assert_eq!(AppError::Embedding("model failure".into()).exit_code(), 11);
443 }
444
445 #[test]
446 fn exit_code_vec_extension_returns_12() {
447 assert_eq!(
448 AppError::VecExtension("extension did not load".into()).exit_code(),
449 12
450 );
451 }
452
453 #[test]
454 fn exit_code_db_busy_returns_15() {
455 assert_eq!(AppError::DbBusy("retries exhausted".into()).exit_code(), 15);
456 }
457
458 #[test]
459 fn exit_code_batch_partial_failure_returns_13() {
460 assert_eq!(
461 AppError::BatchPartialFailure {
462 total: 10,
463 failed: 3
464 }
465 .exit_code(),
466 13
467 );
468 }
469
470 #[test]
471 fn display_batch_partial_failure_includes_counts() {
472 let err = AppError::BatchPartialFailure {
473 total: 50,
474 failed: 7,
475 };
476 let msg = err.to_string();
477 assert!(msg.contains("7"));
478 assert!(msg.contains("50"));
479 assert!(msg.contains("batch partial failure"));
481 }
482
483 #[test]
484 fn exit_code_io_returns_14() {
485 let io_err = io::Error::new(io::ErrorKind::NotFound, "file missing");
486 assert_eq!(AppError::Io(io_err).exit_code(), 14);
487 }
488
489 #[test]
490 fn exit_code_internal_returns_20() {
491 let anyhow_err = anyhow::anyhow!("unexpected internal error");
492 assert_eq!(AppError::Internal(anyhow_err).exit_code(), 20);
493 }
494
495 #[test]
496 fn exit_code_json_returns_20() {
497 let json_err = serde_json::from_str::<serde_json::Value>("invalid json {{").unwrap_err();
498 assert_eq!(AppError::Json(json_err).exit_code(), 20);
499 }
500
501 #[test]
502 fn exit_code_lock_busy_returns_75() {
503 assert_eq!(
504 AppError::LockBusy("another active instance".into()).exit_code(),
505 75
506 );
507 }
508
509 #[test]
510 fn display_validation_includes_message() {
511 let err = AppError::Validation("invalid id".into());
512 assert!(err.to_string().contains("invalid id"));
513 assert!(err.to_string().contains("validation error"));
514 }
515
516 #[test]
517 fn display_duplicate_includes_message() {
518 let err = AppError::Duplicate("proj/mem".into());
519 assert!(err.to_string().contains("proj/mem"));
520 assert!(err.to_string().contains("duplicate detected"));
521 }
522
523 #[test]
524 fn display_not_found_includes_message() {
525 let err = AppError::NotFound("id 42".into());
526 assert!(err.to_string().contains("id 42"));
527 assert!(err.to_string().contains("not found"));
528 }
529
530 #[test]
531 fn display_embedding_includes_message() {
532 let err = AppError::Embedding("wrong dimension".into());
533 assert!(err.to_string().contains("wrong dimension"));
534 assert!(err.to_string().contains("embedding error"));
535 }
536
537 #[test]
538 fn display_lock_busy_includes_message() {
539 let err = AppError::LockBusy("pid 1234".into());
540 assert!(err.to_string().contains("pid 1234"));
541 assert!(err.to_string().contains("lock busy"));
542 }
543
544 #[test]
545 fn from_io_error_converts_correctly() {
546 let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "permission denied");
547 let app_err: AppError = io_err.into();
548 assert_eq!(app_err.exit_code(), 14);
549 assert!(app_err.to_string().contains("IO error"));
550 }
551
552 #[test]
553 fn from_anyhow_error_converts_correctly() {
554 let anyhow_err = anyhow::anyhow!("internal detail");
555 let app_err: AppError = anyhow_err.into();
556 assert_eq!(app_err.exit_code(), 20);
557 assert!(app_err.to_string().contains("internal detail"));
558 }
559
560 #[test]
561 fn from_serde_json_error_converts_correctly() {
562 let json_err = serde_json::from_str::<serde_json::Value>("{bad_field}").unwrap_err();
563 let app_err: AppError = json_err.into();
564 assert_eq!(app_err.exit_code(), 20);
565 assert!(app_err.to_string().contains("json error"));
566 }
567
568 #[test]
569 fn exit_code_lock_busy_matches_constant() {
570 assert_eq!(
571 AppError::LockBusy("test".into()).exit_code(),
572 crate::constants::CLI_LOCK_EXIT_CODE
573 );
574 }
575
576 #[test]
577 fn localized_message_en_equals_to_string() {
578 let err = AppError::NotFound("mem-x".into());
579 assert_eq!(
580 err.localized_message_for(crate::i18n::Language::English),
581 err.to_string()
582 );
583 }
584
585 #[test]
590 fn localized_message_pt_differs_from_en() {
591 let err = AppError::NotFound("mem-x".into());
592 let en = err.localized_message_for(crate::i18n::Language::English);
593 let pt = err.localized_message_for(crate::i18n::Language::Portuguese);
594 assert_ne!(en, pt, "PT and EN must produce distinct messages");
595 assert!(pt.contains("mem-x"), "PT must include the variant payload");
596 }
597
598 #[test]
599 fn localized_message_pt_delegates_to_app_error_pt_helper() {
600 use crate::i18n::validation::app_error_pt as pt;
601
602 let cases: Vec<(AppError, String)> = vec![
603 (AppError::Validation("x".into()), pt::validation("x")),
604 (AppError::Duplicate("x".into()), pt::duplicate("x")),
605 (AppError::Conflict("x".into()), pt::conflict("x")),
606 (AppError::NotFound("x".into()), pt::not_found("x")),
607 (
608 AppError::NamespaceError("x".into()),
609 pt::namespace_error("x"),
610 ),
611 (AppError::LimitExceeded("x".into()), pt::limit_exceeded("x")),
612 (AppError::Embedding("x".into()), pt::embedding("x")),
613 (AppError::VecExtension("x".into()), pt::vec_extension("x")),
614 (AppError::DbBusy("x".into()), pt::db_busy("x")),
615 (
616 AppError::BatchPartialFailure {
617 total: 10,
618 failed: 3,
619 },
620 pt::batch_partial_failure(10, 3),
621 ),
622 (AppError::LockBusy("x".into()), pt::lock_busy("x")),
623 (
624 AppError::AllSlotsFull {
625 max: 4,
626 waited_secs: 60,
627 },
628 pt::all_slots_full(4, 60),
629 ),
630 (
631 AppError::LowMemory {
632 available_mb: 100,
633 required_mb: 500,
634 },
635 pt::low_memory(100, 500),
636 ),
637 (
638 AppError::BinaryNotFound {
639 name: "claude".into(),
640 },
641 pt::binary_not_found("claude"),
642 ),
643 (
644 AppError::RateLimited {
645 detail: "429".into(),
646 },
647 pt::rate_limited("429"),
648 ),
649 (
650 AppError::Timeout {
651 operation: "op".into(),
652 duration_secs: 30,
653 },
654 pt::timeout("op", 30),
655 ),
656 ];
657
658 for (err, expected) in cases {
659 let actual = err.localized_message_for(crate::i18n::Language::Portuguese);
660 assert_eq!(actual, expected, "delegation mismatch");
661 }
662 }
663
664 #[test]
665 fn is_retryable_transient_errors() {
666 assert!(AppError::DbBusy("x".into()).is_retryable());
667 assert!(AppError::LockBusy("x".into()).is_retryable());
668 assert!(AppError::AllSlotsFull {
669 max: 4,
670 waited_secs: 60
671 }
672 .is_retryable());
673 assert!(AppError::LowMemory {
674 available_mb: 100,
675 required_mb: 500
676 }
677 .is_retryable());
678 assert!(AppError::RateLimited {
679 detail: "429".into()
680 }
681 .is_retryable());
682 assert!(AppError::Timeout {
683 operation: "op".into(),
684 duration_secs: 30
685 }
686 .is_retryable());
687 }
688
689 #[test]
690 fn is_retryable_permanent_errors() {
691 assert!(!AppError::Validation("x".into()).is_retryable());
692 assert!(!AppError::NotFound("x".into()).is_retryable());
693 assert!(!AppError::Duplicate("x".into()).is_retryable());
694 assert!(!AppError::Conflict("x".into()).is_retryable());
695 assert!(!AppError::BinaryNotFound { name: "x".into() }.is_retryable());
696 }
697
698 #[test]
699 fn exit_code_new_variants() {
700 assert_eq!(AppError::BinaryNotFound { name: "x".into() }.exit_code(), 1);
701 assert_eq!(AppError::RateLimited { detail: "x".into() }.exit_code(), 1);
702 assert_eq!(
703 AppError::Timeout {
704 operation: "x".into(),
705 duration_secs: 5
706 }
707 .exit_code(),
708 1
709 );
710 }
711
712 #[test]
713 fn app_error_size_does_not_exceed_budget() {
714 let size = std::mem::size_of::<AppError>();
715 assert!(
716 size <= 128,
717 "AppError is {size} bytes — exceeds 128-byte budget; \
718 consider boxing large variants to reduce memcpy cost in Result propagation"
719 );
720 }
721}