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("namespace not resolved: {0}")]
61 NamespaceError(String),
62
63 #[error("limit exceeded: {0}")]
65 LimitExceeded(String),
66
67 #[error("database error: {0}")]
69 Database(#[from] rusqlite::Error),
70
71 #[error("embedding error: {0}")]
73 Embedding(String),
74
75 #[error("sqlite-vec extension failed: {0}")]
77 VecExtension(String),
78
79 #[error("database busy: {0}")]
81 DbBusy(String),
82
83 #[error("batch partial failure: {failed} of {total} items failed")]
88 BatchPartialFailure { total: usize, failed: usize },
89
90 #[error("IO error: {0}")]
92 Io(#[from] std::io::Error),
93
94 #[error(transparent)]
96 Internal(#[from] anyhow::Error),
97
98 #[error("json error: {0}")]
100 Json(#[from] serde_json::Error),
101
102 #[error("lock busy: {0}")]
106 LockBusy(String),
107
108 #[error(
113 "all {max} concurrency slots occupied after waiting {waited_secs}s (exit 75); \
114 use --max-concurrency or wait for other invocations to finish"
115 )]
116 AllSlotsFull { max: usize, waited_secs: u64 },
117
118 #[error(
127 "job {job_type} for namespace '{namespace}' is already running (exit 75); \
128 wait for it to finish or pass --wait-job-singleton <SECONDS>"
129 )]
130 JobSingletonLocked { job_type: String, namespace: String },
131
132 #[error(
137 "available memory ({available_mb}MB) below required minimum ({required_mb}MB) \
138 to load the model; abort other loads or use --skip-memory-guard (exit 77)"
139 )]
140 LowMemory { available_mb: u64, required_mb: u64 },
141}
142
143impl AppError {
144 #[inline]
171 #[must_use]
172 pub fn exit_code(&self) -> i32 {
173 match self {
174 Self::Validation(_) => 1,
175 Self::BinaryNotFound { .. } => 1,
176 Self::RateLimited { .. } => 1,
177 Self::Timeout { .. } => 1,
178 Self::Duplicate(_) => crate::constants::DUPLICATE_EXIT_CODE,
179 Self::Conflict(_) => 3,
180 Self::NotFound(_) => 4,
181 Self::NamespaceError(_) => 5,
182 Self::LimitExceeded(_) => 6,
183 Self::Database(_) => 10,
184 Self::Embedding(_) => 11,
185 Self::VecExtension(_) => 12,
186 Self::BatchPartialFailure { .. } => crate::constants::BATCH_PARTIAL_FAILURE_EXIT_CODE,
187 Self::DbBusy(_) => crate::constants::DB_BUSY_EXIT_CODE,
188 Self::Io(_) => 14,
189 Self::Internal(_) => 20,
190 Self::Json(_) => 20,
191 Self::LockBusy(_) => crate::constants::CLI_LOCK_EXIT_CODE,
192 Self::AllSlotsFull { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
193 Self::JobSingletonLocked { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
194 Self::LowMemory { .. } => crate::constants::LOW_MEMORY_EXIT_CODE,
195 }
196 }
197
198 #[inline]
212 #[must_use]
213 pub fn is_retryable(&self) -> bool {
214 matches!(
215 self,
216 Self::DbBusy(_)
217 | Self::LockBusy(_)
218 | Self::AllSlotsFull { .. }
219 | Self::JobSingletonLocked { .. }
220 | Self::LowMemory { .. }
221 | Self::RateLimited { .. }
222 | Self::Timeout { .. }
223 )
224 }
225
226 #[inline]
241 #[must_use]
242 pub fn is_permanent(&self) -> bool {
243 matches!(
244 self,
245 Self::Validation(_)
246 | Self::BinaryNotFound { .. }
247 | Self::Duplicate(_)
248 | Self::NotFound(_)
249 | Self::NamespaceError(_)
250 | Self::LimitExceeded(_)
251 | Self::VecExtension(_)
252 )
253 }
254
255 pub fn localized_message(&self) -> String {
260 self.localized_message_for(current())
261 }
262
263 pub fn localized_message_for(&self, lang: Language) -> String {
281 match lang {
282 Language::English => self.to_string(),
283 Language::Portuguese => self.to_string_pt(),
284 }
285 }
286
287 fn to_string_pt(&self) -> String {
288 use crate::i18n::validation::app_error_pt as pt;
289 match self {
290 Self::Validation(msg) => pt::validation(msg),
291 Self::BinaryNotFound { name } => pt::binary_not_found(name),
292 Self::RateLimited { detail } => pt::rate_limited(detail),
293 Self::Timeout {
294 operation,
295 duration_secs,
296 } => pt::timeout(operation, *duration_secs),
297 Self::Duplicate(msg) => pt::duplicate(msg),
298 Self::Conflict(msg) => pt::conflict(msg),
299 Self::NotFound(msg) => pt::not_found(msg),
300 Self::NamespaceError(msg) => pt::namespace_error(msg),
301 Self::LimitExceeded(msg) => pt::limit_exceeded(msg),
302 Self::Database(e) => pt::database(&e.to_string()),
303 Self::Embedding(msg) => pt::embedding(msg),
304 Self::VecExtension(msg) => pt::vec_extension(msg),
305 Self::DbBusy(msg) => pt::db_busy(msg),
306 Self::BatchPartialFailure { total, failed } => {
307 pt::batch_partial_failure(*total, *failed)
308 }
309 Self::Io(e) => pt::io(&e.to_string()),
310 Self::Internal(e) => pt::internal(&e.to_string()),
311 Self::Json(e) => pt::json(&e.to_string()),
312 Self::LockBusy(msg) => pt::lock_busy(msg),
313 Self::AllSlotsFull { max, waited_secs } => pt::all_slots_full(*max, *waited_secs),
314 Self::JobSingletonLocked {
315 job_type,
316 namespace,
317 } => pt::job_singleton_locked(job_type, namespace),
318 Self::LowMemory {
319 available_mb,
320 required_mb,
321 } => pt::low_memory(*available_mb, *required_mb),
322 }
323 }
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329 use std::io;
330
331 #[test]
332 fn exit_code_validation_returns_1() {
333 assert_eq!(AppError::Validation("invalid field".into()).exit_code(), 1);
334 }
335
336 #[test]
337 fn exit_code_duplicate_returns_9() {
338 assert_eq!(AppError::Duplicate("namespace/name".into()).exit_code(), 9);
339 }
340
341 #[test]
342 fn exit_code_conflict_returns_3() {
343 assert_eq!(
344 AppError::Conflict("updated_at changed".into()).exit_code(),
345 3
346 );
347 }
348
349 #[test]
350 fn exit_code_not_found_returns_4() {
351 assert_eq!(AppError::NotFound("memory missing".into()).exit_code(), 4);
352 }
353
354 #[test]
355 fn exit_code_namespace_error_returns_5() {
356 assert_eq!(
357 AppError::NamespaceError("not resolved".into()).exit_code(),
358 5
359 );
360 }
361
362 #[test]
363 fn exit_code_limit_exceeded_returns_6() {
364 assert_eq!(
365 AppError::LimitExceeded("body too large".into()).exit_code(),
366 6
367 );
368 }
369
370 #[test]
371 fn exit_code_embedding_returns_11() {
372 assert_eq!(AppError::Embedding("model failure".into()).exit_code(), 11);
373 }
374
375 #[test]
376 fn exit_code_vec_extension_returns_12() {
377 assert_eq!(
378 AppError::VecExtension("extension did not load".into()).exit_code(),
379 12
380 );
381 }
382
383 #[test]
384 fn exit_code_db_busy_returns_15() {
385 assert_eq!(AppError::DbBusy("retries exhausted".into()).exit_code(), 15);
386 }
387
388 #[test]
389 fn exit_code_batch_partial_failure_returns_13() {
390 assert_eq!(
391 AppError::BatchPartialFailure {
392 total: 10,
393 failed: 3
394 }
395 .exit_code(),
396 13
397 );
398 }
399
400 #[test]
401 fn display_batch_partial_failure_includes_counts() {
402 let err = AppError::BatchPartialFailure {
403 total: 50,
404 failed: 7,
405 };
406 let msg = err.to_string();
407 assert!(msg.contains("7"));
408 assert!(msg.contains("50"));
409 assert!(msg.contains("batch partial failure"));
411 }
412
413 #[test]
414 fn exit_code_io_returns_14() {
415 let io_err = io::Error::new(io::ErrorKind::NotFound, "file missing");
416 assert_eq!(AppError::Io(io_err).exit_code(), 14);
417 }
418
419 #[test]
420 fn exit_code_internal_returns_20() {
421 let anyhow_err = anyhow::anyhow!("unexpected internal error");
422 assert_eq!(AppError::Internal(anyhow_err).exit_code(), 20);
423 }
424
425 #[test]
426 fn exit_code_json_returns_20() {
427 let json_err = serde_json::from_str::<serde_json::Value>("invalid json {{").unwrap_err();
428 assert_eq!(AppError::Json(json_err).exit_code(), 20);
429 }
430
431 #[test]
432 fn exit_code_lock_busy_returns_75() {
433 assert_eq!(
434 AppError::LockBusy("another active instance".into()).exit_code(),
435 75
436 );
437 }
438
439 #[test]
440 fn display_validation_includes_message() {
441 let err = AppError::Validation("invalid id".into());
442 assert!(err.to_string().contains("invalid id"));
443 assert!(err.to_string().contains("validation error"));
444 }
445
446 #[test]
447 fn display_duplicate_includes_message() {
448 let err = AppError::Duplicate("proj/mem".into());
449 assert!(err.to_string().contains("proj/mem"));
450 assert!(err.to_string().contains("duplicate detected"));
451 }
452
453 #[test]
454 fn display_not_found_includes_message() {
455 let err = AppError::NotFound("id 42".into());
456 assert!(err.to_string().contains("id 42"));
457 assert!(err.to_string().contains("not found"));
458 }
459
460 #[test]
461 fn display_embedding_includes_message() {
462 let err = AppError::Embedding("wrong dimension".into());
463 assert!(err.to_string().contains("wrong dimension"));
464 assert!(err.to_string().contains("embedding error"));
465 }
466
467 #[test]
468 fn display_lock_busy_includes_message() {
469 let err = AppError::LockBusy("pid 1234".into());
470 assert!(err.to_string().contains("pid 1234"));
471 assert!(err.to_string().contains("lock busy"));
472 }
473
474 #[test]
475 fn from_io_error_converts_correctly() {
476 let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "permission denied");
477 let app_err: AppError = io_err.into();
478 assert_eq!(app_err.exit_code(), 14);
479 assert!(app_err.to_string().contains("IO error"));
480 }
481
482 #[test]
483 fn from_anyhow_error_converts_correctly() {
484 let anyhow_err = anyhow::anyhow!("internal detail");
485 let app_err: AppError = anyhow_err.into();
486 assert_eq!(app_err.exit_code(), 20);
487 assert!(app_err.to_string().contains("internal detail"));
488 }
489
490 #[test]
491 fn from_serde_json_error_converts_correctly() {
492 let json_err = serde_json::from_str::<serde_json::Value>("{bad_field}").unwrap_err();
493 let app_err: AppError = json_err.into();
494 assert_eq!(app_err.exit_code(), 20);
495 assert!(app_err.to_string().contains("json error"));
496 }
497
498 #[test]
499 fn exit_code_lock_busy_matches_constant() {
500 assert_eq!(
501 AppError::LockBusy("test".into()).exit_code(),
502 crate::constants::CLI_LOCK_EXIT_CODE
503 );
504 }
505
506 #[test]
507 fn localized_message_en_equals_to_string() {
508 let err = AppError::NotFound("mem-x".into());
509 assert_eq!(
510 err.localized_message_for(crate::i18n::Language::English),
511 err.to_string()
512 );
513 }
514
515 #[test]
520 fn localized_message_pt_differs_from_en() {
521 let err = AppError::NotFound("mem-x".into());
522 let en = err.localized_message_for(crate::i18n::Language::English);
523 let pt = err.localized_message_for(crate::i18n::Language::Portuguese);
524 assert_ne!(en, pt, "PT and EN must produce distinct messages");
525 assert!(pt.contains("mem-x"), "PT must include the variant payload");
526 }
527
528 #[test]
529 fn localized_message_pt_delegates_to_app_error_pt_helper() {
530 use crate::i18n::validation::app_error_pt as pt;
531
532 let cases: Vec<(AppError, String)> = vec![
533 (AppError::Validation("x".into()), pt::validation("x")),
534 (AppError::Duplicate("x".into()), pt::duplicate("x")),
535 (AppError::Conflict("x".into()), pt::conflict("x")),
536 (AppError::NotFound("x".into()), pt::not_found("x")),
537 (
538 AppError::NamespaceError("x".into()),
539 pt::namespace_error("x"),
540 ),
541 (AppError::LimitExceeded("x".into()), pt::limit_exceeded("x")),
542 (AppError::Embedding("x".into()), pt::embedding("x")),
543 (AppError::VecExtension("x".into()), pt::vec_extension("x")),
544 (AppError::DbBusy("x".into()), pt::db_busy("x")),
545 (
546 AppError::BatchPartialFailure {
547 total: 10,
548 failed: 3,
549 },
550 pt::batch_partial_failure(10, 3),
551 ),
552 (AppError::LockBusy("x".into()), pt::lock_busy("x")),
553 (
554 AppError::AllSlotsFull {
555 max: 4,
556 waited_secs: 60,
557 },
558 pt::all_slots_full(4, 60),
559 ),
560 (
561 AppError::LowMemory {
562 available_mb: 100,
563 required_mb: 500,
564 },
565 pt::low_memory(100, 500),
566 ),
567 (
568 AppError::BinaryNotFound {
569 name: "claude".into(),
570 },
571 pt::binary_not_found("claude"),
572 ),
573 (
574 AppError::RateLimited {
575 detail: "429".into(),
576 },
577 pt::rate_limited("429"),
578 ),
579 (
580 AppError::Timeout {
581 operation: "op".into(),
582 duration_secs: 30,
583 },
584 pt::timeout("op", 30),
585 ),
586 ];
587
588 for (err, expected) in cases {
589 let actual = err.localized_message_for(crate::i18n::Language::Portuguese);
590 assert_eq!(actual, expected, "delegation mismatch");
591 }
592 }
593
594 #[test]
595 fn is_retryable_transient_errors() {
596 assert!(AppError::DbBusy("x".into()).is_retryable());
597 assert!(AppError::LockBusy("x".into()).is_retryable());
598 assert!(AppError::AllSlotsFull {
599 max: 4,
600 waited_secs: 60
601 }
602 .is_retryable());
603 assert!(AppError::LowMemory {
604 available_mb: 100,
605 required_mb: 500
606 }
607 .is_retryable());
608 assert!(AppError::RateLimited {
609 detail: "429".into()
610 }
611 .is_retryable());
612 assert!(AppError::Timeout {
613 operation: "op".into(),
614 duration_secs: 30
615 }
616 .is_retryable());
617 }
618
619 #[test]
620 fn is_retryable_permanent_errors() {
621 assert!(!AppError::Validation("x".into()).is_retryable());
622 assert!(!AppError::NotFound("x".into()).is_retryable());
623 assert!(!AppError::Duplicate("x".into()).is_retryable());
624 assert!(!AppError::Conflict("x".into()).is_retryable());
625 assert!(!AppError::BinaryNotFound { name: "x".into() }.is_retryable());
626 }
627
628 #[test]
629 fn exit_code_new_variants() {
630 assert_eq!(AppError::BinaryNotFound { name: "x".into() }.exit_code(), 1);
631 assert_eq!(AppError::RateLimited { detail: "x".into() }.exit_code(), 1);
632 assert_eq!(
633 AppError::Timeout {
634 operation: "x".into(),
635 duration_secs: 5
636 }
637 .exit_code(),
638 1
639 );
640 }
641
642 #[test]
643 fn app_error_size_does_not_exceed_budget() {
644 let size = std::mem::size_of::<AppError>();
645 assert!(
646 size <= 128,
647 "AppError is {size} bytes — exceeds 128-byte budget; \
648 consider boxing large variants to reduce memcpy cost in Result propagation"
649 );
650 }
651}