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::Portugues => self.to_string_pt(),
182 }
183 }
184
185 fn to_string_pt(&self) -> String {
186 match self {
187 Self::Validation(msg) => format!("erro de validação: {msg}"),
188 Self::Duplicate(msg) => format!("duplicata detectada: {msg}"),
189 Self::Conflict(msg) => format!("conflito: {msg}"),
190 Self::NotFound(msg) => format!("não encontrado: {msg}"),
191 Self::NamespaceError(msg) => format!("namespace não resolvido: {msg}"),
192 Self::LimitExceeded(msg) => format!("limite excedido: {msg}"),
193 Self::Database(e) => format!("erro de banco de dados: {e}"),
194 Self::Embedding(msg) => format!("erro de embedding: {msg}"),
195 Self::VecExtension(msg) => format!("extensão sqlite-vec falhou: {msg}"),
196 Self::DbBusy(msg) => format!("banco ocupado: {msg}"),
197 Self::BatchPartialFailure { total, failed } => {
198 format!("falha parcial em batch: {failed} de {total} itens falharam")
199 }
200 Self::Io(e) => format!("erro de I/O: {e}"),
201 Self::Internal(e) => format!("erro interno: {e}"),
202 Self::Json(e) => format!("erro de JSON: {e}"),
203 Self::LockBusy(msg) => format!("lock ocupado: {msg}"),
204 Self::AllSlotsFull { max, waited_secs } => format!(
205 "todos os {max} slots de concorrência ocupados após aguardar {waited_secs}s \
206 (exit 75); use --max-concurrency ou aguarde outras invocações terminarem"
207 ),
208 Self::LowMemory {
209 available_mb,
210 required_mb,
211 } => format!(
212 "memória disponível ({available_mb}MB) abaixo do mínimo requerido ({required_mb}MB) \
213 para carregar o modelo; aborte outras cargas ou use --skip-memory-guard (exit 77)"
214 ),
215 }
216 }
217}
218
219#[cfg(test)]
220mod testes {
221 use super::*;
222 use std::io;
223
224 #[test]
225 fn exit_code_validation_retorna_1() {
226 assert_eq!(AppError::Validation("campo inválido".into()).exit_code(), 1);
227 }
228
229 #[test]
230 fn exit_code_duplicate_retorna_2() {
231 assert_eq!(AppError::Duplicate("namespace/nome".into()).exit_code(), 2);
232 }
233
234 #[test]
235 fn exit_code_conflict_retorna_3() {
236 assert_eq!(AppError::Conflict("updated_at mudou".into()).exit_code(), 3);
237 }
238
239 #[test]
240 fn exit_code_not_found_retorna_4() {
241 assert_eq!(AppError::NotFound("memória ausente".into()).exit_code(), 4);
242 }
243
244 #[test]
245 fn exit_code_namespace_error_retorna_5() {
246 assert_eq!(
247 AppError::NamespaceError("não resolvido".into()).exit_code(),
248 5
249 );
250 }
251
252 #[test]
253 fn exit_code_limit_exceeded_retorna_6() {
254 assert_eq!(
255 AppError::LimitExceeded("corpo muito grande".into()).exit_code(),
256 6
257 );
258 }
259
260 #[test]
261 fn exit_code_embedding_retorna_11() {
262 assert_eq!(
263 AppError::Embedding("falha de modelo".into()).exit_code(),
264 11
265 );
266 }
267
268 #[test]
269 fn exit_code_vec_extension_retorna_12() {
270 assert_eq!(
271 AppError::VecExtension("extensão não carregou".into()).exit_code(),
272 12
273 );
274 }
275
276 #[test]
277 fn exit_code_db_busy_retorna_15() {
278 assert_eq!(AppError::DbBusy("esgotou retries".into()).exit_code(), 15);
279 }
280
281 #[test]
282 fn exit_code_batch_partial_failure_retorna_13() {
283 assert_eq!(
284 AppError::BatchPartialFailure {
285 total: 10,
286 failed: 3
287 }
288 .exit_code(),
289 13
290 );
291 }
292
293 #[test]
294 fn display_batch_partial_failure_inclui_contagens() {
295 let err = AppError::BatchPartialFailure {
296 total: 50,
297 failed: 7,
298 };
299 let msg = err.to_string();
300 assert!(msg.contains("7"));
301 assert!(msg.contains("50"));
302 assert!(msg.contains("batch partial failure"));
304 }
305
306 #[test]
307 fn exit_code_io_retorna_14() {
308 let io_err = io::Error::new(io::ErrorKind::NotFound, "arquivo ausente");
309 assert_eq!(AppError::Io(io_err).exit_code(), 14);
310 }
311
312 #[test]
313 fn exit_code_internal_retorna_20() {
314 let anyhow_err = anyhow::anyhow!("erro interno inesperado");
315 assert_eq!(AppError::Internal(anyhow_err).exit_code(), 20);
316 }
317
318 #[test]
319 fn exit_code_json_retorna_20() {
320 let json_err = serde_json::from_str::<serde_json::Value>("json inválido {{").unwrap_err();
321 assert_eq!(AppError::Json(json_err).exit_code(), 20);
322 }
323
324 #[test]
325 fn exit_code_lock_busy_retorna_75() {
326 assert_eq!(
327 AppError::LockBusy("outra instância ativa".into()).exit_code(),
328 75
329 );
330 }
331
332 #[test]
333 fn display_validation_inclui_mensagem() {
334 let err = AppError::Validation("cpf inválido".into());
335 assert!(err.to_string().contains("cpf inválido"));
336 assert!(err.to_string().contains("validation error"));
337 }
338
339 #[test]
340 fn display_duplicate_inclui_mensagem() {
341 let err = AppError::Duplicate("proj/mem".into());
342 assert!(err.to_string().contains("proj/mem"));
343 assert!(err.to_string().contains("duplicate detected"));
344 }
345
346 #[test]
347 fn display_not_found_inclui_mensagem() {
348 let err = AppError::NotFound("id 42".into());
349 assert!(err.to_string().contains("id 42"));
350 assert!(err.to_string().contains("not found"));
351 }
352
353 #[test]
354 fn display_embedding_inclui_mensagem() {
355 let err = AppError::Embedding("dimensão errada".into());
356 assert!(err.to_string().contains("dimensão errada"));
357 assert!(err.to_string().contains("embedding error"));
358 }
359
360 #[test]
361 fn display_lock_busy_inclui_mensagem() {
362 let err = AppError::LockBusy("pid 1234".into());
363 assert!(err.to_string().contains("pid 1234"));
364 assert!(err.to_string().contains("lock busy"));
365 }
366
367 #[test]
368 fn from_io_error_converte_corretamente() {
369 let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "sem permissão");
370 let app_err: AppError = io_err.into();
371 assert_eq!(app_err.exit_code(), 14);
372 assert!(app_err.to_string().contains("IO error"));
373 }
374
375 #[test]
376 fn from_anyhow_error_converte_corretamente() {
377 let anyhow_err = anyhow::anyhow!("detalhe interno");
378 let app_err: AppError = anyhow_err.into();
379 assert_eq!(app_err.exit_code(), 20);
380 assert!(app_err.to_string().contains("internal error"));
381 }
382
383 #[test]
384 fn from_serde_json_error_converte_corretamente() {
385 let json_err = serde_json::from_str::<serde_json::Value>("{campo_ruim}").unwrap_err();
386 let app_err: AppError = json_err.into();
387 assert_eq!(app_err.exit_code(), 20);
388 assert!(app_err.to_string().contains("json error"));
389 }
390
391 #[test]
392 fn exit_code_lock_busy_bate_com_constante() {
393 assert_eq!(
394 AppError::LockBusy("test".into()).exit_code(),
395 crate::constants::CLI_LOCK_EXIT_CODE
396 );
397 }
398
399 #[test]
400 fn localized_message_en_igual_to_string() {
401 let err = AppError::NotFound("mem-x".into());
402 assert_eq!(
403 err.localized_message_for(crate::i18n::Language::English),
404 err.to_string()
405 );
406 }
407
408 #[test]
409 fn localized_message_pt_not_found_em_portugues() {
410 let err = AppError::NotFound("mem-x".into());
411 let msg = err.localized_message_for(crate::i18n::Language::Portugues);
412 assert!(msg.contains("não encontrado"), "esperado PT, obtido: {msg}");
413 assert!(msg.contains("mem-x"));
414 }
415
416 #[test]
417 fn localized_message_pt_validation_em_portugues() {
418 let err = AppError::Validation("campo x".into());
419 let msg = err.localized_message_for(crate::i18n::Language::Portugues);
420 assert!(
421 msg.contains("erro de validação"),
422 "esperado PT, obtido: {msg}"
423 );
424 }
425
426 #[test]
427 fn localized_message_pt_duplicate_em_portugues() {
428 let err = AppError::Duplicate("ns/mem".into());
429 let msg = err.localized_message_for(crate::i18n::Language::Portugues);
430 assert!(
431 msg.contains("duplicata detectada"),
432 "esperado PT, obtido: {msg}"
433 );
434 }
435
436 #[test]
437 fn localized_message_pt_conflict_em_portugues() {
438 let err = AppError::Conflict("ts mudou".into());
439 let msg = err.localized_message_for(crate::i18n::Language::Portugues);
440 assert!(msg.contains("conflito"), "esperado PT, obtido: {msg}");
441 }
442
443 #[test]
444 fn localized_message_pt_namespace_em_portugues() {
445 let err = AppError::NamespaceError("sem marcador".into());
446 let msg = err.localized_message_for(crate::i18n::Language::Portugues);
447 assert!(
448 msg.contains("namespace não resolvido"),
449 "esperado PT, obtido: {msg}"
450 );
451 }
452
453 #[test]
454 fn localized_message_pt_limit_exceeded_em_portugues() {
455 let err = AppError::LimitExceeded("corpo enorme".into());
456 let msg = err.localized_message_for(crate::i18n::Language::Portugues);
457 assert!(
458 msg.contains("limite excedido"),
459 "esperado PT, obtido: {msg}"
460 );
461 }
462
463 #[test]
464 fn localized_message_pt_embedding_em_portugues() {
465 let err = AppError::Embedding("dim errada".into());
466 let msg = err.localized_message_for(crate::i18n::Language::Portugues);
467 assert!(
468 msg.contains("erro de embedding"),
469 "esperado PT, obtido: {msg}"
470 );
471 }
472
473 #[test]
474 fn localized_message_pt_db_busy_em_portugues() {
475 let err = AppError::DbBusy("retries esgotados".into());
476 let msg = err.localized_message_for(crate::i18n::Language::Portugues);
477 assert!(msg.contains("banco ocupado"), "esperado PT, obtido: {msg}");
478 }
479
480 #[test]
481 fn localized_message_pt_batch_partial_failure_em_portugues() {
482 let err = AppError::BatchPartialFailure {
483 total: 10,
484 failed: 3,
485 };
486 let msg = err.localized_message_for(crate::i18n::Language::Portugues);
487 assert!(msg.contains("falha parcial"), "esperado PT, obtido: {msg}");
488 assert!(msg.contains("3"));
489 assert!(msg.contains("10"));
490 }
491
492 #[test]
493 fn localized_message_pt_lock_busy_em_portugues() {
494 let err = AppError::LockBusy("pid 42".into());
495 let msg = err.localized_message_for(crate::i18n::Language::Portugues);
496 assert!(msg.contains("lock ocupado"), "esperado PT, obtido: {msg}");
497 }
498
499 #[test]
500 fn localized_message_pt_all_slots_full_em_portugues() {
501 let err = AppError::AllSlotsFull {
502 max: 4,
503 waited_secs: 60,
504 };
505 let msg = err.localized_message_for(crate::i18n::Language::Portugues);
506 assert!(
507 msg.contains("slots de concorrência"),
508 "esperado PT, obtido: {msg}"
509 );
510 }
511
512 #[test]
513 fn localized_message_pt_low_memory_em_portugues() {
514 let err = AppError::LowMemory {
515 available_mb: 100,
516 required_mb: 500,
517 };
518 let msg = err.localized_message_for(crate::i18n::Language::Portugues);
519 assert!(
520 msg.contains("memória disponível"),
521 "esperado PT, obtido: {msg}"
522 );
523 }
524}