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(
123 "available memory ({available_mb}MB) below required minimum ({required_mb}MB) \
124 to load the model; abort other loads or use --skip-memory-guard (exit 77)"
125 )]
126 LowMemory { available_mb: u64, required_mb: u64 },
127}
128
129impl AppError {
130 #[inline]
157 #[must_use]
158 pub fn exit_code(&self) -> i32 {
159 match self {
160 Self::Validation(_) => 1,
161 Self::BinaryNotFound { .. } => 1,
162 Self::RateLimited { .. } => 1,
163 Self::Timeout { .. } => 1,
164 Self::Duplicate(_) => crate::constants::DUPLICATE_EXIT_CODE,
165 Self::Conflict(_) => 3,
166 Self::NotFound(_) => 4,
167 Self::NamespaceError(_) => 5,
168 Self::LimitExceeded(_) => 6,
169 Self::Database(_) => 10,
170 Self::Embedding(_) => 11,
171 Self::VecExtension(_) => 12,
172 Self::BatchPartialFailure { .. } => crate::constants::BATCH_PARTIAL_FAILURE_EXIT_CODE,
173 Self::DbBusy(_) => crate::constants::DB_BUSY_EXIT_CODE,
174 Self::Io(_) => 14,
175 Self::Internal(_) => 20,
176 Self::Json(_) => 20,
177 Self::LockBusy(_) => crate::constants::CLI_LOCK_EXIT_CODE,
178 Self::AllSlotsFull { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
179 Self::LowMemory { .. } => crate::constants::LOW_MEMORY_EXIT_CODE,
180 }
181 }
182
183 #[inline]
197 #[must_use]
198 pub fn is_retryable(&self) -> bool {
199 matches!(
200 self,
201 Self::DbBusy(_)
202 | Self::LockBusy(_)
203 | Self::AllSlotsFull { .. }
204 | Self::LowMemory { .. }
205 | Self::RateLimited { .. }
206 | Self::Timeout { .. }
207 )
208 }
209
210 #[inline]
225 #[must_use]
226 pub fn is_permanent(&self) -> bool {
227 matches!(
228 self,
229 Self::Validation(_)
230 | Self::BinaryNotFound { .. }
231 | Self::Duplicate(_)
232 | Self::NotFound(_)
233 | Self::NamespaceError(_)
234 | Self::LimitExceeded(_)
235 | Self::VecExtension(_)
236 )
237 }
238
239 pub fn localized_message(&self) -> String {
244 self.localized_message_for(current())
245 }
246
247 pub fn localized_message_for(&self, lang: Language) -> String {
265 match lang {
266 Language::English => self.to_string(),
267 Language::Portuguese => self.to_string_pt(),
268 }
269 }
270
271 fn to_string_pt(&self) -> String {
272 use crate::i18n::validation::app_error_pt as pt;
273 match self {
274 Self::Validation(msg) => pt::validation(msg),
275 Self::BinaryNotFound { name } => pt::binary_not_found(name),
276 Self::RateLimited { detail } => pt::rate_limited(detail),
277 Self::Timeout {
278 operation,
279 duration_secs,
280 } => pt::timeout(operation, *duration_secs),
281 Self::Duplicate(msg) => pt::duplicate(msg),
282 Self::Conflict(msg) => pt::conflict(msg),
283 Self::NotFound(msg) => pt::not_found(msg),
284 Self::NamespaceError(msg) => pt::namespace_error(msg),
285 Self::LimitExceeded(msg) => pt::limit_exceeded(msg),
286 Self::Database(e) => pt::database(&e.to_string()),
287 Self::Embedding(msg) => pt::embedding(msg),
288 Self::VecExtension(msg) => pt::vec_extension(msg),
289 Self::DbBusy(msg) => pt::db_busy(msg),
290 Self::BatchPartialFailure { total, failed } => {
291 pt::batch_partial_failure(*total, *failed)
292 }
293 Self::Io(e) => pt::io(&e.to_string()),
294 Self::Internal(e) => pt::internal(&e.to_string()),
295 Self::Json(e) => pt::json(&e.to_string()),
296 Self::LockBusy(msg) => pt::lock_busy(msg),
297 Self::AllSlotsFull { max, waited_secs } => pt::all_slots_full(*max, *waited_secs),
298 Self::LowMemory {
299 available_mb,
300 required_mb,
301 } => pt::low_memory(*available_mb, *required_mb),
302 }
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309 use std::io;
310
311 #[test]
312 fn exit_code_validation_returns_1() {
313 assert_eq!(AppError::Validation("invalid field".into()).exit_code(), 1);
314 }
315
316 #[test]
317 fn exit_code_duplicate_returns_9() {
318 assert_eq!(AppError::Duplicate("namespace/name".into()).exit_code(), 9);
319 }
320
321 #[test]
322 fn exit_code_conflict_returns_3() {
323 assert_eq!(
324 AppError::Conflict("updated_at changed".into()).exit_code(),
325 3
326 );
327 }
328
329 #[test]
330 fn exit_code_not_found_returns_4() {
331 assert_eq!(AppError::NotFound("memory missing".into()).exit_code(), 4);
332 }
333
334 #[test]
335 fn exit_code_namespace_error_returns_5() {
336 assert_eq!(
337 AppError::NamespaceError("not resolved".into()).exit_code(),
338 5
339 );
340 }
341
342 #[test]
343 fn exit_code_limit_exceeded_returns_6() {
344 assert_eq!(
345 AppError::LimitExceeded("body too large".into()).exit_code(),
346 6
347 );
348 }
349
350 #[test]
351 fn exit_code_embedding_returns_11() {
352 assert_eq!(AppError::Embedding("model failure".into()).exit_code(), 11);
353 }
354
355 #[test]
356 fn exit_code_vec_extension_returns_12() {
357 assert_eq!(
358 AppError::VecExtension("extension did not load".into()).exit_code(),
359 12
360 );
361 }
362
363 #[test]
364 fn exit_code_db_busy_returns_15() {
365 assert_eq!(AppError::DbBusy("retries exhausted".into()).exit_code(), 15);
366 }
367
368 #[test]
369 fn exit_code_batch_partial_failure_returns_13() {
370 assert_eq!(
371 AppError::BatchPartialFailure {
372 total: 10,
373 failed: 3
374 }
375 .exit_code(),
376 13
377 );
378 }
379
380 #[test]
381 fn display_batch_partial_failure_includes_counts() {
382 let err = AppError::BatchPartialFailure {
383 total: 50,
384 failed: 7,
385 };
386 let msg = err.to_string();
387 assert!(msg.contains("7"));
388 assert!(msg.contains("50"));
389 assert!(msg.contains("batch partial failure"));
391 }
392
393 #[test]
394 fn exit_code_io_returns_14() {
395 let io_err = io::Error::new(io::ErrorKind::NotFound, "file missing");
396 assert_eq!(AppError::Io(io_err).exit_code(), 14);
397 }
398
399 #[test]
400 fn exit_code_internal_returns_20() {
401 let anyhow_err = anyhow::anyhow!("unexpected internal error");
402 assert_eq!(AppError::Internal(anyhow_err).exit_code(), 20);
403 }
404
405 #[test]
406 fn exit_code_json_returns_20() {
407 let json_err = serde_json::from_str::<serde_json::Value>("invalid json {{").unwrap_err();
408 assert_eq!(AppError::Json(json_err).exit_code(), 20);
409 }
410
411 #[test]
412 fn exit_code_lock_busy_returns_75() {
413 assert_eq!(
414 AppError::LockBusy("another active instance".into()).exit_code(),
415 75
416 );
417 }
418
419 #[test]
420 fn display_validation_includes_message() {
421 let err = AppError::Validation("invalid id".into());
422 assert!(err.to_string().contains("invalid id"));
423 assert!(err.to_string().contains("validation error"));
424 }
425
426 #[test]
427 fn display_duplicate_includes_message() {
428 let err = AppError::Duplicate("proj/mem".into());
429 assert!(err.to_string().contains("proj/mem"));
430 assert!(err.to_string().contains("duplicate detected"));
431 }
432
433 #[test]
434 fn display_not_found_includes_message() {
435 let err = AppError::NotFound("id 42".into());
436 assert!(err.to_string().contains("id 42"));
437 assert!(err.to_string().contains("not found"));
438 }
439
440 #[test]
441 fn display_embedding_includes_message() {
442 let err = AppError::Embedding("wrong dimension".into());
443 assert!(err.to_string().contains("wrong dimension"));
444 assert!(err.to_string().contains("embedding error"));
445 }
446
447 #[test]
448 fn display_lock_busy_includes_message() {
449 let err = AppError::LockBusy("pid 1234".into());
450 assert!(err.to_string().contains("pid 1234"));
451 assert!(err.to_string().contains("lock busy"));
452 }
453
454 #[test]
455 fn from_io_error_converts_correctly() {
456 let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "permission denied");
457 let app_err: AppError = io_err.into();
458 assert_eq!(app_err.exit_code(), 14);
459 assert!(app_err.to_string().contains("IO error"));
460 }
461
462 #[test]
463 fn from_anyhow_error_converts_correctly() {
464 let anyhow_err = anyhow::anyhow!("internal detail");
465 let app_err: AppError = anyhow_err.into();
466 assert_eq!(app_err.exit_code(), 20);
467 assert!(app_err.to_string().contains("internal detail"));
468 }
469
470 #[test]
471 fn from_serde_json_error_converts_correctly() {
472 let json_err = serde_json::from_str::<serde_json::Value>("{bad_field}").unwrap_err();
473 let app_err: AppError = json_err.into();
474 assert_eq!(app_err.exit_code(), 20);
475 assert!(app_err.to_string().contains("json error"));
476 }
477
478 #[test]
479 fn exit_code_lock_busy_matches_constant() {
480 assert_eq!(
481 AppError::LockBusy("test".into()).exit_code(),
482 crate::constants::CLI_LOCK_EXIT_CODE
483 );
484 }
485
486 #[test]
487 fn localized_message_en_equals_to_string() {
488 let err = AppError::NotFound("mem-x".into());
489 assert_eq!(
490 err.localized_message_for(crate::i18n::Language::English),
491 err.to_string()
492 );
493 }
494
495 #[test]
500 fn localized_message_pt_differs_from_en() {
501 let err = AppError::NotFound("mem-x".into());
502 let en = err.localized_message_for(crate::i18n::Language::English);
503 let pt = err.localized_message_for(crate::i18n::Language::Portuguese);
504 assert_ne!(en, pt, "PT and EN must produce distinct messages");
505 assert!(pt.contains("mem-x"), "PT must include the variant payload");
506 }
507
508 #[test]
509 fn localized_message_pt_delegates_to_app_error_pt_helper() {
510 use crate::i18n::validation::app_error_pt as pt;
511
512 let cases: Vec<(AppError, String)> = vec![
513 (AppError::Validation("x".into()), pt::validation("x")),
514 (AppError::Duplicate("x".into()), pt::duplicate("x")),
515 (AppError::Conflict("x".into()), pt::conflict("x")),
516 (AppError::NotFound("x".into()), pt::not_found("x")),
517 (
518 AppError::NamespaceError("x".into()),
519 pt::namespace_error("x"),
520 ),
521 (AppError::LimitExceeded("x".into()), pt::limit_exceeded("x")),
522 (AppError::Embedding("x".into()), pt::embedding("x")),
523 (AppError::VecExtension("x".into()), pt::vec_extension("x")),
524 (AppError::DbBusy("x".into()), pt::db_busy("x")),
525 (
526 AppError::BatchPartialFailure {
527 total: 10,
528 failed: 3,
529 },
530 pt::batch_partial_failure(10, 3),
531 ),
532 (AppError::LockBusy("x".into()), pt::lock_busy("x")),
533 (
534 AppError::AllSlotsFull {
535 max: 4,
536 waited_secs: 60,
537 },
538 pt::all_slots_full(4, 60),
539 ),
540 (
541 AppError::LowMemory {
542 available_mb: 100,
543 required_mb: 500,
544 },
545 pt::low_memory(100, 500),
546 ),
547 (
548 AppError::BinaryNotFound {
549 name: "claude".into(),
550 },
551 pt::binary_not_found("claude"),
552 ),
553 (
554 AppError::RateLimited {
555 detail: "429".into(),
556 },
557 pt::rate_limited("429"),
558 ),
559 (
560 AppError::Timeout {
561 operation: "op".into(),
562 duration_secs: 30,
563 },
564 pt::timeout("op", 30),
565 ),
566 ];
567
568 for (err, expected) in cases {
569 let actual = err.localized_message_for(crate::i18n::Language::Portuguese);
570 assert_eq!(actual, expected, "delegation mismatch");
571 }
572 }
573
574 #[test]
575 fn is_retryable_transient_errors() {
576 assert!(AppError::DbBusy("x".into()).is_retryable());
577 assert!(AppError::LockBusy("x".into()).is_retryable());
578 assert!(AppError::AllSlotsFull {
579 max: 4,
580 waited_secs: 60
581 }
582 .is_retryable());
583 assert!(AppError::LowMemory {
584 available_mb: 100,
585 required_mb: 500
586 }
587 .is_retryable());
588 assert!(AppError::RateLimited {
589 detail: "429".into()
590 }
591 .is_retryable());
592 assert!(AppError::Timeout {
593 operation: "op".into(),
594 duration_secs: 30
595 }
596 .is_retryable());
597 }
598
599 #[test]
600 fn is_retryable_permanent_errors() {
601 assert!(!AppError::Validation("x".into()).is_retryable());
602 assert!(!AppError::NotFound("x".into()).is_retryable());
603 assert!(!AppError::Duplicate("x".into()).is_retryable());
604 assert!(!AppError::Conflict("x".into()).is_retryable());
605 assert!(!AppError::BinaryNotFound { name: "x".into() }.is_retryable());
606 }
607
608 #[test]
609 fn exit_code_new_variants() {
610 assert_eq!(AppError::BinaryNotFound { name: "x".into() }.exit_code(), 1);
611 assert_eq!(AppError::RateLimited { detail: "x".into() }.exit_code(), 1);
612 assert_eq!(
613 AppError::Timeout {
614 operation: "x".into(),
615 duration_secs: 5
616 }
617 .exit_code(),
618 1
619 );
620 }
621
622 #[test]
623 fn app_error_size_does_not_exceed_budget() {
624 let size = std::mem::size_of::<AppError>();
625 assert!(
626 size <= 128,
627 "AppError is {size} bytes — exceeds 128-byte budget; \
628 consider boxing large variants to reduce memcpy cost in Result propagation"
629 );
630 }
631}