1use crate::i18n::{current, Language};
9use thiserror::Error;
10
11#[derive(Error, Debug)]
17pub enum AppError {
18 #[error("validation error: {0}")]
20 Validation(String),
21
22 #[error("duplicate detected: {0}")]
24 Duplicate(String),
25
26 #[error("conflict: {0}")]
28 Conflict(String),
29
30 #[error("not found: {0}")]
32 NotFound(String),
33
34 #[error("namespace not resolved: {0}")]
36 NamespaceError(String),
37
38 #[error("limit exceeded: {0}")]
40 LimitExceeded(String),
41
42 #[error("database error: {0}")]
44 Database(#[from] rusqlite::Error),
45
46 #[error("embedding error: {0}")]
48 Embedding(String),
49
50 #[error("sqlite-vec extension failed: {0}")]
52 VecExtension(String),
53
54 #[error("database busy: {0}")]
56 DbBusy(String),
57
58 #[error("batch partial failure: {failed} of {total} items failed")]
63 BatchPartialFailure { total: usize, failed: usize },
64
65 #[error("IO error: {0}")]
67 Io(#[from] std::io::Error),
68
69 #[error("internal error: {0}")]
71 Internal(#[from] anyhow::Error),
72
73 #[error("json error: {0}")]
75 Json(#[from] serde_json::Error),
76
77 #[error("lock busy: {0}")]
81 LockBusy(String),
82
83 #[error(
88 "all {max} concurrency slots occupied after waiting {waited_secs}s (exit 75); \
89 use --max-concurrency or wait for other invocations to finish"
90 )]
91 AllSlotsFull { max: usize, waited_secs: u64 },
92
93 #[error(
98 "available memory ({available_mb}MB) below required minimum ({required_mb}MB) \
99 to load the model; abort other loads or use --skip-memory-guard (exit 77)"
100 )]
101 LowMemory { available_mb: u64, required_mb: u64 },
102}
103
104impl AppError {
105 pub fn exit_code(&self) -> i32 {
132 match self {
133 Self::Validation(_) => 1,
134 Self::Duplicate(_) => 2,
135 Self::Conflict(_) => 3,
136 Self::NotFound(_) => 4,
137 Self::NamespaceError(_) => 5,
138 Self::LimitExceeded(_) => 6,
139 Self::Database(_) => 10,
140 Self::Embedding(_) => 11,
141 Self::VecExtension(_) => 12,
142 Self::BatchPartialFailure { .. } => crate::constants::BATCH_PARTIAL_FAILURE_EXIT_CODE,
143 Self::DbBusy(_) => crate::constants::DB_BUSY_EXIT_CODE,
144 Self::Io(_) => 14,
145 Self::Internal(_) => 20,
146 Self::Json(_) => 20,
147 Self::LockBusy(_) => crate::constants::CLI_LOCK_EXIT_CODE,
148 Self::AllSlotsFull { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
149 Self::LowMemory { .. } => crate::constants::LOW_MEMORY_EXIT_CODE,
150 }
151 }
152
153 pub fn localized_message(&self) -> String {
158 self.localized_message_for(current())
159 }
160
161 pub fn localized_message_for(&self, lang: Language) -> String {
179 match lang {
180 Language::English => self.to_string(),
181 Language::Portuguese => self.to_string_pt(),
182 }
183 }
184
185 fn to_string_pt(&self) -> String {
186 use crate::i18n::validation::app_error_pt as pt;
187 match self {
188 Self::Validation(msg) => pt::validation(msg),
189 Self::Duplicate(msg) => pt::duplicate(msg),
190 Self::Conflict(msg) => pt::conflict(msg),
191 Self::NotFound(msg) => pt::not_found(msg),
192 Self::NamespaceError(msg) => pt::namespace_error(msg),
193 Self::LimitExceeded(msg) => pt::limit_exceeded(msg),
194 Self::Database(e) => pt::database(&e.to_string()),
195 Self::Embedding(msg) => pt::embedding(msg),
196 Self::VecExtension(msg) => pt::vec_extension(msg),
197 Self::DbBusy(msg) => pt::db_busy(msg),
198 Self::BatchPartialFailure { total, failed } => {
199 pt::batch_partial_failure(*total, *failed)
200 }
201 Self::Io(e) => pt::io(&e.to_string()),
202 Self::Internal(e) => pt::internal(&e.to_string()),
203 Self::Json(e) => pt::json(&e.to_string()),
204 Self::LockBusy(msg) => pt::lock_busy(msg),
205 Self::AllSlotsFull { max, waited_secs } => pt::all_slots_full(*max, *waited_secs),
206 Self::LowMemory {
207 available_mb,
208 required_mb,
209 } => pt::low_memory(*available_mb, *required_mb),
210 }
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use std::io;
218
219 #[test]
220 fn exit_code_validation_returns_1() {
221 assert_eq!(AppError::Validation("invalid field".into()).exit_code(), 1);
222 }
223
224 #[test]
225 fn exit_code_duplicate_returns_2() {
226 assert_eq!(AppError::Duplicate("namespace/name".into()).exit_code(), 2);
227 }
228
229 #[test]
230 fn exit_code_conflict_returns_3() {
231 assert_eq!(
232 AppError::Conflict("updated_at changed".into()).exit_code(),
233 3
234 );
235 }
236
237 #[test]
238 fn exit_code_not_found_returns_4() {
239 assert_eq!(AppError::NotFound("memory missing".into()).exit_code(), 4);
240 }
241
242 #[test]
243 fn exit_code_namespace_error_returns_5() {
244 assert_eq!(
245 AppError::NamespaceError("not resolved".into()).exit_code(),
246 5
247 );
248 }
249
250 #[test]
251 fn exit_code_limit_exceeded_returns_6() {
252 assert_eq!(
253 AppError::LimitExceeded("body too large".into()).exit_code(),
254 6
255 );
256 }
257
258 #[test]
259 fn exit_code_embedding_returns_11() {
260 assert_eq!(AppError::Embedding("model failure".into()).exit_code(), 11);
261 }
262
263 #[test]
264 fn exit_code_vec_extension_returns_12() {
265 assert_eq!(
266 AppError::VecExtension("extension did not load".into()).exit_code(),
267 12
268 );
269 }
270
271 #[test]
272 fn exit_code_db_busy_returns_15() {
273 assert_eq!(AppError::DbBusy("retries exhausted".into()).exit_code(), 15);
274 }
275
276 #[test]
277 fn exit_code_batch_partial_failure_returns_13() {
278 assert_eq!(
279 AppError::BatchPartialFailure {
280 total: 10,
281 failed: 3
282 }
283 .exit_code(),
284 13
285 );
286 }
287
288 #[test]
289 fn display_batch_partial_failure_includes_counts() {
290 let err = AppError::BatchPartialFailure {
291 total: 50,
292 failed: 7,
293 };
294 let msg = err.to_string();
295 assert!(msg.contains("7"));
296 assert!(msg.contains("50"));
297 assert!(msg.contains("batch partial failure"));
299 }
300
301 #[test]
302 fn exit_code_io_returns_14() {
303 let io_err = io::Error::new(io::ErrorKind::NotFound, "file missing");
304 assert_eq!(AppError::Io(io_err).exit_code(), 14);
305 }
306
307 #[test]
308 fn exit_code_internal_returns_20() {
309 let anyhow_err = anyhow::anyhow!("unexpected internal error");
310 assert_eq!(AppError::Internal(anyhow_err).exit_code(), 20);
311 }
312
313 #[test]
314 fn exit_code_json_returns_20() {
315 let json_err = serde_json::from_str::<serde_json::Value>("invalid json {{").unwrap_err();
316 assert_eq!(AppError::Json(json_err).exit_code(), 20);
317 }
318
319 #[test]
320 fn exit_code_lock_busy_returns_75() {
321 assert_eq!(
322 AppError::LockBusy("another active instance".into()).exit_code(),
323 75
324 );
325 }
326
327 #[test]
328 fn display_validation_includes_message() {
329 let err = AppError::Validation("invalid id".into());
330 assert!(err.to_string().contains("invalid id"));
331 assert!(err.to_string().contains("validation error"));
332 }
333
334 #[test]
335 fn display_duplicate_includes_message() {
336 let err = AppError::Duplicate("proj/mem".into());
337 assert!(err.to_string().contains("proj/mem"));
338 assert!(err.to_string().contains("duplicate detected"));
339 }
340
341 #[test]
342 fn display_not_found_includes_message() {
343 let err = AppError::NotFound("id 42".into());
344 assert!(err.to_string().contains("id 42"));
345 assert!(err.to_string().contains("not found"));
346 }
347
348 #[test]
349 fn display_embedding_includes_message() {
350 let err = AppError::Embedding("wrong dimension".into());
351 assert!(err.to_string().contains("wrong dimension"));
352 assert!(err.to_string().contains("embedding error"));
353 }
354
355 #[test]
356 fn display_lock_busy_includes_message() {
357 let err = AppError::LockBusy("pid 1234".into());
358 assert!(err.to_string().contains("pid 1234"));
359 assert!(err.to_string().contains("lock busy"));
360 }
361
362 #[test]
363 fn from_io_error_converts_correctly() {
364 let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "permission denied");
365 let app_err: AppError = io_err.into();
366 assert_eq!(app_err.exit_code(), 14);
367 assert!(app_err.to_string().contains("IO error"));
368 }
369
370 #[test]
371 fn from_anyhow_error_converts_correctly() {
372 let anyhow_err = anyhow::anyhow!("internal detail");
373 let app_err: AppError = anyhow_err.into();
374 assert_eq!(app_err.exit_code(), 20);
375 assert!(app_err.to_string().contains("internal error"));
376 }
377
378 #[test]
379 fn from_serde_json_error_converts_correctly() {
380 let json_err = serde_json::from_str::<serde_json::Value>("{bad_field}").unwrap_err();
381 let app_err: AppError = json_err.into();
382 assert_eq!(app_err.exit_code(), 20);
383 assert!(app_err.to_string().contains("json error"));
384 }
385
386 #[test]
387 fn exit_code_lock_busy_matches_constant() {
388 assert_eq!(
389 AppError::LockBusy("test".into()).exit_code(),
390 crate::constants::CLI_LOCK_EXIT_CODE
391 );
392 }
393
394 #[test]
395 fn localized_message_en_equals_to_string() {
396 let err = AppError::NotFound("mem-x".into());
397 assert_eq!(
398 err.localized_message_for(crate::i18n::Language::English),
399 err.to_string()
400 );
401 }
402
403 #[test]
408 fn localized_message_pt_differs_from_en() {
409 let err = AppError::NotFound("mem-x".into());
410 let en = err.localized_message_for(crate::i18n::Language::English);
411 let pt = err.localized_message_for(crate::i18n::Language::Portuguese);
412 assert_ne!(en, pt, "PT and EN must produce distinct messages");
413 assert!(pt.contains("mem-x"), "PT must include the variant payload");
414 }
415
416 #[test]
417 fn localized_message_pt_delegates_to_app_error_pt_helper() {
418 use crate::i18n::validation::app_error_pt as pt;
419
420 let cases: Vec<(AppError, String)> = vec![
421 (AppError::Validation("x".into()), pt::validation("x")),
422 (AppError::Duplicate("x".into()), pt::duplicate("x")),
423 (AppError::Conflict("x".into()), pt::conflict("x")),
424 (AppError::NotFound("x".into()), pt::not_found("x")),
425 (
426 AppError::NamespaceError("x".into()),
427 pt::namespace_error("x"),
428 ),
429 (AppError::LimitExceeded("x".into()), pt::limit_exceeded("x")),
430 (AppError::Embedding("x".into()), pt::embedding("x")),
431 (AppError::VecExtension("x".into()), pt::vec_extension("x")),
432 (AppError::DbBusy("x".into()), pt::db_busy("x")),
433 (
434 AppError::BatchPartialFailure {
435 total: 10,
436 failed: 3,
437 },
438 pt::batch_partial_failure(10, 3),
439 ),
440 (AppError::LockBusy("x".into()), pt::lock_busy("x")),
441 (
442 AppError::AllSlotsFull {
443 max: 4,
444 waited_secs: 60,
445 },
446 pt::all_slots_full(4, 60),
447 ),
448 (
449 AppError::LowMemory {
450 available_mb: 100,
451 required_mb: 500,
452 },
453 pt::low_memory(100, 500),
454 ),
455 ];
456
457 for (err, expected) in cases {
458 let actual = err.localized_message_for(crate::i18n::Language::Portuguese);
459 assert_eq!(actual, expected, "delegation mismatch");
460 }
461 }
462}