use crate::i18n::{current, Language};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("validation error: {0}")]
Validation(String),
#[error("duplicate detected: {0}")]
Duplicate(String),
#[error("conflict: {0}")]
Conflict(String),
#[error("not found: {0}")]
NotFound(String),
#[error("namespace not resolved: {0}")]
NamespaceError(String),
#[error("limit exceeded: {0}")]
LimitExceeded(String),
#[error("database error: {0}")]
Database(#[from] rusqlite::Error),
#[error("embedding error: {0}")]
Embedding(String),
#[error("sqlite-vec extension failed: {0}")]
VecExtension(String),
#[error("database busy: {0}")]
DbBusy(String),
#[error("batch partial failure: {failed} of {total} items failed")]
BatchPartialFailure { total: usize, failed: usize },
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("internal error: {0}")]
Internal(#[from] anyhow::Error),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("lock busy: {0}")]
LockBusy(String),
#[error(
"all {max} concurrency slots occupied after waiting {waited_secs}s (exit 75); \
use --max-concurrency or wait for other invocations to finish"
)]
AllSlotsFull { max: usize, waited_secs: u64 },
#[error(
"available memory ({available_mb}MB) below required minimum ({required_mb}MB) \
to load the model; abort other loads or use --skip-memory-guard (exit 77)"
)]
LowMemory { available_mb: u64, required_mb: u64 },
}
impl AppError {
pub fn exit_code(&self) -> i32 {
match self {
Self::Validation(_) => 1,
Self::Duplicate(_) => 2,
Self::Conflict(_) => 3,
Self::NotFound(_) => 4,
Self::NamespaceError(_) => 5,
Self::LimitExceeded(_) => 6,
Self::Database(_) => 10,
Self::Embedding(_) => 11,
Self::VecExtension(_) => 12,
Self::BatchPartialFailure { .. } => crate::constants::BATCH_PARTIAL_FAILURE_EXIT_CODE,
Self::DbBusy(_) => crate::constants::DB_BUSY_EXIT_CODE,
Self::Io(_) => 14,
Self::Internal(_) => 20,
Self::Json(_) => 20,
Self::LockBusy(_) => crate::constants::CLI_LOCK_EXIT_CODE,
Self::AllSlotsFull { .. } => crate::constants::CLI_LOCK_EXIT_CODE,
Self::LowMemory { .. } => crate::constants::LOW_MEMORY_EXIT_CODE,
}
}
pub fn localized_message(&self) -> String {
self.localized_message_for(current())
}
pub fn localized_message_for(&self, lang: Language) -> String {
match lang {
Language::English => self.to_string(),
Language::Portugues => self.to_string_pt(),
}
}
fn to_string_pt(&self) -> String {
match self {
Self::Validation(msg) => format!("erro de validação: {msg}"),
Self::Duplicate(msg) => format!("duplicata detectada: {msg}"),
Self::Conflict(msg) => format!("conflito: {msg}"),
Self::NotFound(msg) => format!("não encontrado: {msg}"),
Self::NamespaceError(msg) => format!("namespace não resolvido: {msg}"),
Self::LimitExceeded(msg) => format!("limite excedido: {msg}"),
Self::Database(e) => format!("erro de banco de dados: {e}"),
Self::Embedding(msg) => format!("erro de embedding: {msg}"),
Self::VecExtension(msg) => format!("extensão sqlite-vec falhou: {msg}"),
Self::DbBusy(msg) => format!("banco ocupado: {msg}"),
Self::BatchPartialFailure { total, failed } => {
format!("falha parcial em batch: {failed} de {total} itens falharam")
}
Self::Io(e) => format!("erro de I/O: {e}"),
Self::Internal(e) => format!("erro interno: {e}"),
Self::Json(e) => format!("erro de JSON: {e}"),
Self::LockBusy(msg) => format!("lock ocupado: {msg}"),
Self::AllSlotsFull { max, waited_secs } => format!(
"todos os {max} slots de concorrência ocupados após aguardar {waited_secs}s \
(exit 75); use --max-concurrency ou aguarde outras invocações terminarem"
),
Self::LowMemory {
available_mb,
required_mb,
} => format!(
"memória disponível ({available_mb}MB) abaixo do mínimo requerido ({required_mb}MB) \
para carregar o modelo; aborte outras cargas ou use --skip-memory-guard (exit 77)"
),
}
}
}
#[cfg(test)]
mod testes {
use super::*;
use std::io;
#[test]
fn exit_code_validation_retorna_1() {
assert_eq!(AppError::Validation("campo inválido".into()).exit_code(), 1);
}
#[test]
fn exit_code_duplicate_retorna_2() {
assert_eq!(AppError::Duplicate("namespace/nome".into()).exit_code(), 2);
}
#[test]
fn exit_code_conflict_retorna_3() {
assert_eq!(AppError::Conflict("updated_at mudou".into()).exit_code(), 3);
}
#[test]
fn exit_code_not_found_retorna_4() {
assert_eq!(AppError::NotFound("memória ausente".into()).exit_code(), 4);
}
#[test]
fn exit_code_namespace_error_retorna_5() {
assert_eq!(
AppError::NamespaceError("não resolvido".into()).exit_code(),
5
);
}
#[test]
fn exit_code_limit_exceeded_retorna_6() {
assert_eq!(
AppError::LimitExceeded("corpo muito grande".into()).exit_code(),
6
);
}
#[test]
fn exit_code_embedding_retorna_11() {
assert_eq!(
AppError::Embedding("falha de modelo".into()).exit_code(),
11
);
}
#[test]
fn exit_code_vec_extension_retorna_12() {
assert_eq!(
AppError::VecExtension("extensão não carregou".into()).exit_code(),
12
);
}
#[test]
fn exit_code_db_busy_retorna_15() {
assert_eq!(AppError::DbBusy("esgotou retries".into()).exit_code(), 15);
}
#[test]
fn exit_code_batch_partial_failure_retorna_13() {
assert_eq!(
AppError::BatchPartialFailure {
total: 10,
failed: 3
}
.exit_code(),
13
);
}
#[test]
fn display_batch_partial_failure_inclui_contagens() {
let err = AppError::BatchPartialFailure {
total: 50,
failed: 7,
};
let msg = err.to_string();
assert!(msg.contains("7"));
assert!(msg.contains("50"));
assert!(msg.contains("batch partial failure"));
}
#[test]
fn exit_code_io_retorna_14() {
let io_err = io::Error::new(io::ErrorKind::NotFound, "arquivo ausente");
assert_eq!(AppError::Io(io_err).exit_code(), 14);
}
#[test]
fn exit_code_internal_retorna_20() {
let anyhow_err = anyhow::anyhow!("erro interno inesperado");
assert_eq!(AppError::Internal(anyhow_err).exit_code(), 20);
}
#[test]
fn exit_code_json_retorna_20() {
let json_err = serde_json::from_str::<serde_json::Value>("json inválido {{").unwrap_err();
assert_eq!(AppError::Json(json_err).exit_code(), 20);
}
#[test]
fn exit_code_lock_busy_retorna_75() {
assert_eq!(
AppError::LockBusy("outra instância ativa".into()).exit_code(),
75
);
}
#[test]
fn display_validation_inclui_mensagem() {
let err = AppError::Validation("cpf inválido".into());
assert!(err.to_string().contains("cpf inválido"));
assert!(err.to_string().contains("validation error"));
}
#[test]
fn display_duplicate_inclui_mensagem() {
let err = AppError::Duplicate("proj/mem".into());
assert!(err.to_string().contains("proj/mem"));
assert!(err.to_string().contains("duplicate detected"));
}
#[test]
fn display_not_found_inclui_mensagem() {
let err = AppError::NotFound("id 42".into());
assert!(err.to_string().contains("id 42"));
assert!(err.to_string().contains("not found"));
}
#[test]
fn display_embedding_inclui_mensagem() {
let err = AppError::Embedding("dimensão errada".into());
assert!(err.to_string().contains("dimensão errada"));
assert!(err.to_string().contains("embedding error"));
}
#[test]
fn display_lock_busy_inclui_mensagem() {
let err = AppError::LockBusy("pid 1234".into());
assert!(err.to_string().contains("pid 1234"));
assert!(err.to_string().contains("lock busy"));
}
#[test]
fn from_io_error_converte_corretamente() {
let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "sem permissão");
let app_err: AppError = io_err.into();
assert_eq!(app_err.exit_code(), 14);
assert!(app_err.to_string().contains("IO error"));
}
#[test]
fn from_anyhow_error_converte_corretamente() {
let anyhow_err = anyhow::anyhow!("detalhe interno");
let app_err: AppError = anyhow_err.into();
assert_eq!(app_err.exit_code(), 20);
assert!(app_err.to_string().contains("internal error"));
}
#[test]
fn from_serde_json_error_converte_corretamente() {
let json_err = serde_json::from_str::<serde_json::Value>("{campo_ruim}").unwrap_err();
let app_err: AppError = json_err.into();
assert_eq!(app_err.exit_code(), 20);
assert!(app_err.to_string().contains("json error"));
}
#[test]
fn exit_code_lock_busy_bate_com_constante() {
assert_eq!(
AppError::LockBusy("test".into()).exit_code(),
crate::constants::CLI_LOCK_EXIT_CODE
);
}
#[test]
fn localized_message_en_igual_to_string() {
let err = AppError::NotFound("mem-x".into());
assert_eq!(
err.localized_message_for(crate::i18n::Language::English),
err.to_string()
);
}
#[test]
fn localized_message_pt_not_found_em_portugues() {
let err = AppError::NotFound("mem-x".into());
let msg = err.localized_message_for(crate::i18n::Language::Portugues);
assert!(msg.contains("não encontrado"), "esperado PT, obtido: {msg}");
assert!(msg.contains("mem-x"));
}
#[test]
fn localized_message_pt_validation_em_portugues() {
let err = AppError::Validation("campo x".into());
let msg = err.localized_message_for(crate::i18n::Language::Portugues);
assert!(
msg.contains("erro de validação"),
"esperado PT, obtido: {msg}"
);
}
#[test]
fn localized_message_pt_duplicate_em_portugues() {
let err = AppError::Duplicate("ns/mem".into());
let msg = err.localized_message_for(crate::i18n::Language::Portugues);
assert!(
msg.contains("duplicata detectada"),
"esperado PT, obtido: {msg}"
);
}
#[test]
fn localized_message_pt_conflict_em_portugues() {
let err = AppError::Conflict("ts mudou".into());
let msg = err.localized_message_for(crate::i18n::Language::Portugues);
assert!(msg.contains("conflito"), "esperado PT, obtido: {msg}");
}
#[test]
fn localized_message_pt_namespace_em_portugues() {
let err = AppError::NamespaceError("sem marcador".into());
let msg = err.localized_message_for(crate::i18n::Language::Portugues);
assert!(
msg.contains("namespace não resolvido"),
"esperado PT, obtido: {msg}"
);
}
#[test]
fn localized_message_pt_limit_exceeded_em_portugues() {
let err = AppError::LimitExceeded("corpo enorme".into());
let msg = err.localized_message_for(crate::i18n::Language::Portugues);
assert!(
msg.contains("limite excedido"),
"esperado PT, obtido: {msg}"
);
}
#[test]
fn localized_message_pt_embedding_em_portugues() {
let err = AppError::Embedding("dim errada".into());
let msg = err.localized_message_for(crate::i18n::Language::Portugues);
assert!(
msg.contains("erro de embedding"),
"esperado PT, obtido: {msg}"
);
}
#[test]
fn localized_message_pt_db_busy_em_portugues() {
let err = AppError::DbBusy("retries esgotados".into());
let msg = err.localized_message_for(crate::i18n::Language::Portugues);
assert!(msg.contains("banco ocupado"), "esperado PT, obtido: {msg}");
}
#[test]
fn localized_message_pt_batch_partial_failure_em_portugues() {
let err = AppError::BatchPartialFailure {
total: 10,
failed: 3,
};
let msg = err.localized_message_for(crate::i18n::Language::Portugues);
assert!(msg.contains("falha parcial"), "esperado PT, obtido: {msg}");
assert!(msg.contains("3"));
assert!(msg.contains("10"));
}
#[test]
fn localized_message_pt_lock_busy_em_portugues() {
let err = AppError::LockBusy("pid 42".into());
let msg = err.localized_message_for(crate::i18n::Language::Portugues);
assert!(msg.contains("lock ocupado"), "esperado PT, obtido: {msg}");
}
#[test]
fn localized_message_pt_all_slots_full_em_portugues() {
let err = AppError::AllSlotsFull {
max: 4,
waited_secs: 60,
};
let msg = err.localized_message_for(crate::i18n::Language::Portugues);
assert!(
msg.contains("slots de concorrência"),
"esperado PT, obtido: {msg}"
);
}
#[test]
fn localized_message_pt_low_memory_em_portugues() {
let err = AppError::LowMemory {
available_mb: 100,
required_mb: 500,
};
let msg = err.localized_message_for(crate::i18n::Language::Portugues);
assert!(
msg.contains("memória disponível"),
"esperado PT, obtido: {msg}"
);
}
}