use std::sync::OnceLock;
#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
pub enum Language {
#[value(name = "en", aliases = ["english", "EN"])]
English,
#[value(name = "pt", aliases = ["portugues", "portuguese", "pt-BR", "pt-br", "PT"])]
Portuguese,
}
impl Language {
pub fn from_str_opt(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"en" | "english" => Some(Language::English),
"pt" | "pt-br" | "portugues" | "portuguese" => Some(Language::Portuguese),
_ => None,
}
}
pub fn from_env_or_locale() -> Self {
if let Ok(v) = std::env::var("SQLITE_GRAPHRAG_LANG") {
if !v.is_empty() {
let lower = v.to_lowercase();
if lower.starts_with("pt") {
return Language::Portuguese;
}
if lower.starts_with("en") {
return Language::English;
}
tracing::warn!(
value = %v,
"SQLITE_GRAPHRAG_LANG value not recognized, falling back to locale detection"
);
}
}
for var in &["LC_ALL", "LC_MESSAGES", "LANG"] {
if let Ok(v) = std::env::var(var) {
if v.is_empty() {
continue;
}
let lower = v.to_lowercase();
if lower.starts_with("pt") {
return Language::Portuguese;
}
if lower.starts_with("en") {
return Language::English;
}
break;
}
}
Language::English
}
}
static GLOBAL_LANGUAGE: OnceLock<Language> = OnceLock::new();
pub fn init(explicit: Option<Language>) {
if GLOBAL_LANGUAGE.get().is_some() {
return;
}
let resolved = explicit.unwrap_or_else(Language::from_env_or_locale);
let _ = GLOBAL_LANGUAGE.set(resolved);
}
pub fn current() -> Language {
*GLOBAL_LANGUAGE.get_or_init(Language::from_env_or_locale)
}
pub fn tr(en: &'static str, pt: &'static str) -> &'static str {
match current() {
Language::English => en,
Language::Portuguese => pt,
}
}
pub fn relations_pruned(count: usize, relation: &str, namespace: &str) -> String {
format!("pruned {count} '{relation}' relationships in namespace '{namespace}'")
}
pub fn prune_dry_run(count: usize, relation: &str) -> String {
format!("dry run: {count} '{relation}' relationships would be removed")
}
pub fn prune_requires_yes() -> String {
"destructive operation requires --yes flag; use --dry-run to preview".to_string()
}
pub fn error_prefix() -> &'static str {
match current() {
Language::English => "Error",
Language::Portuguese => "Erro",
}
}
pub mod errors_msg {
pub fn memory_not_found(nome: &str, namespace: &str) -> String {
format!("memory '{nome}' not found in namespace '{namespace}'")
}
pub fn memory_or_entity_not_found(name: &str, namespace: &str) -> String {
format!("memory or entity '{name}' not found in namespace '{namespace}'")
}
pub fn database_not_found(path: &str) -> String {
format!("database not found at {path}. Run 'sqlite-graphrag init' first.")
}
pub fn entity_not_found(nome: &str, namespace: &str) -> String {
format!("entity \"{nome}\" does not exist in namespace \"{namespace}\"")
}
pub fn relationship_not_found(de: &str, rel: &str, para: &str, namespace: &str) -> String {
format!(
"relationship \"{de}\" --[{rel}]--> \"{para}\" does not exist in namespace \"{namespace}\""
)
}
pub fn duplicate_memory(nome: &str, namespace: &str) -> String {
format!(
"memory '{nome}' already exists in namespace '{namespace}'. Use --force-merge to update."
)
}
pub fn duplicate_memory_soft_deleted(name: &str, namespace: &str) -> String {
format!(
"memory '{name}' exists but is soft-deleted in namespace '{namespace}'; \
use --force-merge to restore and update, or `restore` to revive it"
)
}
pub fn optimistic_lock_conflict(expected: i64, current_ts: i64) -> String {
format!(
"optimistic lock conflict: expected updated_at={expected}, but current is {current_ts}"
)
}
pub fn version_not_found(versao: i64, nome: &str) -> String {
format!("version {versao} not found for memory '{nome}'")
}
pub fn no_recall_results(max_distance: f32, query: &str, namespace: &str) -> String {
format!(
"no results within --max-distance {max_distance} for query '{query}' in namespace '{namespace}'"
)
}
pub fn soft_deleted_memory_not_found(nome: &str, namespace: &str) -> String {
format!("soft-deleted memory '{nome}' not found in namespace '{namespace}'")
}
pub fn concurrent_process_conflict() -> String {
"optimistic lock conflict: memory was modified by another process".to_string()
}
pub fn entity_limit_exceeded(max: usize) -> String {
format!("entities exceed limit of {max}")
}
pub fn relationship_limit_exceeded(max: usize) -> String {
format!("relationships exceed limit of {max}")
}
}
pub mod validation {
use super::current;
use crate::i18n::Language;
pub fn name_length(max: usize) -> String {
match current() {
Language::English => format!("name must be 1-{max} chars"),
Language::Portuguese => format!("nome deve ter entre 1 e {max} caracteres"),
}
}
pub fn reserved_name() -> String {
match current() {
Language::English => {
"names and namespaces starting with __ are reserved for internal use".to_string()
}
Language::Portuguese => {
"nomes e namespaces iniciados com __ são reservados para uso interno".to_string()
}
}
}
pub fn name_kebab(nome: &str) -> String {
match current() {
Language::English => format!(
"name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
),
Language::Portuguese => {
format!("nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'")
}
}
}
pub fn description_exceeds(max: usize) -> String {
match current() {
Language::English => format!("description must be <= {max} chars"),
Language::Portuguese => format!("descrição deve ter no máximo {max} caracteres"),
}
}
pub fn body_exceeds(max: usize) -> String {
match current() {
Language::English => format!("body exceeds {max} bytes"),
Language::Portuguese => format!("corpo excede {max} bytes"),
}
}
pub fn new_name_length(max: usize) -> String {
match current() {
Language::English => format!("new-name must be 1-{max} chars"),
Language::Portuguese => format!("novo nome deve ter entre 1 e {max} caracteres"),
}
}
pub fn new_name_kebab(nome: &str) -> String {
match current() {
Language::English => format!(
"new-name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
),
Language::Portuguese => format!(
"novo nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'"
),
}
}
pub fn namespace_length() -> String {
match current() {
Language::English => "namespace must be 1-80 chars".to_string(),
Language::Portuguese => "namespace deve ter entre 1 e 80 caracteres".to_string(),
}
}
pub fn namespace_format() -> String {
match current() {
Language::English => "namespace must be alphanumeric + hyphens/underscores".to_string(),
Language::Portuguese => {
"namespace deve ser alfanumérico com hífens/sublinhados".to_string()
}
}
}
pub fn path_traversal(p: &str) -> String {
match current() {
Language::English => format!("path traversal rejected: {p}"),
Language::Portuguese => format!("traversal de caminho rejeitado: {p}"),
}
}
pub fn invalid_tz(v: &str) -> String {
match current() {
Language::English => format!(
"SQLITE_GRAPHRAG_DISPLAY_TZ invalid: '{v}'; use an IANA name like 'America/Sao_Paulo'"
),
Language::Portuguese => format!(
"SQLITE_GRAPHRAG_DISPLAY_TZ inválido: '{v}'; use um nome IANA como 'America/Sao_Paulo'"
),
}
}
pub fn empty_query() -> String {
match current() {
Language::English => "query cannot be empty".to_string(),
Language::Portuguese => "a consulta não pode estar vazia".to_string(),
}
}
pub fn empty_body() -> String {
match current() {
Language::English => "body cannot be empty: provide --body, --body-file, or --body-stdin with content, or supply a graph via --entities-file/--graph-stdin".to_string(),
Language::Portuguese => "o corpo não pode estar vazio: forneça --body, --body-file ou --body-stdin com conteúdo, ou um grafo via --entities-file/--graph-stdin".to_string(),
}
}
pub fn invalid_namespace_config(path: &str, err: &str) -> String {
match current() {
Language::English => {
format!("invalid project namespace config '{path}': {err}")
}
Language::Portuguese => {
format!("configuração de namespace de projeto inválida '{path}': {err}")
}
}
}
pub fn invalid_projects_mapping(path: &str, err: &str) -> String {
match current() {
Language::English => format!("invalid projects mapping '{path}': {err}"),
Language::Portuguese => format!("mapeamento de projetos inválido '{path}': {err}"),
}
}
pub fn self_referential_link() -> String {
match current() {
Language::English => "--from and --to must be different entities — self-referential relationships are not supported".to_string(),
Language::Portuguese => "--from e --to devem ser entidades diferentes — relacionamentos auto-referenciais não são suportados".to_string(),
}
}
pub fn invalid_link_weight(weight: f64) -> String {
match current() {
Language::English => {
format!("--weight: must be between 0.0 and 1.0 (actual: {weight})")
}
Language::Portuguese => {
format!("--weight: deve estar entre 0.0 e 1.0 (atual: {weight})")
}
}
}
pub fn sync_destination_equals_source() -> String {
match current() {
Language::English => {
"destination path must differ from the source database path".to_string()
}
Language::Portuguese => {
"caminho de destino deve ser diferente do caminho do banco de dados fonte"
.to_string()
}
}
}
pub mod app_error_pt {
pub fn validation(msg: &str) -> String {
format!("erro de validação: {msg}")
}
pub fn duplicate(msg: &str) -> String {
let translated = msg
.replace("already exists in namespace", "já existe no namespace")
.replace(
"exists but is soft-deleted in namespace",
"existe mas está excluída temporariamente no namespace",
)
.replace(
"Use --force-merge to update.",
"Use --force-merge para atualizar.",
)
.replace(
"use --force-merge to restore and update, or `restore` to revive it",
"use --force-merge para restaurar e atualizar, ou `restore` para revivê-la",
)
.replace("memory", "memória");
format!("duplicata detectada: {translated}")
}
pub fn conflict(msg: &str) -> String {
let translated = msg
.replace("optimistic lock conflict", "conflito de lock otimista")
.replace("but current is", "mas atual é")
.replace(
"was modified by another process",
"foi modificada por outro processo",
);
format!("conflito: {translated}")
}
pub fn not_found(msg: &str) -> String {
let translated = msg
.replace("not found in namespace", "não encontrada no namespace")
.replace("not found for memory", "não encontrada para memória")
.replace("does not exist in namespace", "não existe no namespace")
.replace("memory or entity", "memória ou entidade")
.replace("memory", "memória")
.replace("entity", "entidade")
.replace("version", "versão")
.replace("soft-deleted", "excluída temporariamente");
format!("não encontrado: {translated}")
}
pub fn namespace_error(msg: &str) -> String {
format!("namespace não resolvido: {msg}")
}
pub fn limit_exceeded(msg: &str) -> String {
let translated = msg
.replace("exceeds limit of", "excede limite de")
.replace("body exceeds", "corpo excede")
.replace("entities exceed limit", "entidades excedem limite")
.replace(
"relationships exceed limit",
"relacionamentos excedem limite",
);
format!("limite excedido: {translated}")
}
pub fn database(err: &str) -> String {
format!("erro de banco de dados: {err}")
}
pub fn embedding(msg: &str) -> String {
format!("erro de embedding: {msg}")
}
pub fn vec_extension(msg: &str) -> String {
format!("extensão sqlite-vec falhou: {msg}")
}
pub fn db_busy(msg: &str) -> String {
format!("banco ocupado: {msg}")
}
pub fn batch_partial_failure(total: usize, failed: usize) -> String {
format!("falha parcial em batch: {failed} de {total} itens falharam")
}
pub fn io(err: &str) -> String {
format!("erro de I/O: {err}")
}
pub fn internal(err: &str) -> String {
format!("erro interno: {err}")
}
pub fn json(err: &str) -> String {
format!("erro de JSON: {err}")
}
pub fn lock_busy(msg: &str) -> String {
format!("lock ocupado: {msg}")
}
pub fn all_slots_full(max: usize, waited_secs: u64) -> String {
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"
)
}
pub fn low_memory(available_mb: u64, required_mb: u64) -> String {
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)"
)
}
}
pub mod runtime_pt {
pub fn embedding_heavy_must_measure_ram() -> String {
"comando intensivo em embedding precisa medir RAM disponível".to_string()
}
pub fn heavy_command_detected(available_mb: u64, safe_concurrency: usize) -> String {
format!(
"Comando pesado detectado; memória disponível: {available_mb} MB; \
concorrência segura: {safe_concurrency}"
)
}
pub fn reducing_concurrency(
requested_concurrency: usize,
effective_concurrency: usize,
) -> String {
format!(
"Reduzindo a concorrência solicitada de {requested_concurrency} para \
{effective_concurrency} para evitar oversubscription de memória"
)
}
pub fn initializing_embedding_model() -> &'static str {
"Inicializando modelo de embedding (pode baixar na primeira execução)..."
}
pub fn embedding_chunks_serially(count: usize) -> String {
format!("Embedando {count} chunks serialmente para manter memória limitada...")
}
pub fn remember_step_input_validated(available_mb: u64) -> String {
format!("Etapa remember: entrada validada; memória disponível {available_mb} MB")
}
pub fn remember_step_chunking_completed(
total_passage_tokens: usize,
model_max_length: usize,
chunks_count: usize,
rss_mb: u64,
) -> String {
format!(
"Etapa remember: tokenizer contou {total_passage_tokens} tokens de passagem \
(máximo do modelo {model_max_length}); chunking gerou {chunks_count} chunks; \
RSS do processo {rss_mb} MB"
)
}
pub fn remember_step_embeddings_completed(rss_mb: u64) -> String {
format!("Etapa remember: embeddings dos chunks concluídos; RSS do processo {rss_mb} MB")
}
pub fn restore_recomputing_embedding() -> &'static str {
"Recalculando embedding da memória restaurada..."
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
#[test]
#[serial]
fn fallback_english_when_env_absent() {
std::env::remove_var("SQLITE_GRAPHRAG_LANG");
std::env::set_var("LC_ALL", "C");
std::env::set_var("LANG", "C");
assert_eq!(Language::from_env_or_locale(), Language::English);
std::env::remove_var("LC_ALL");
std::env::remove_var("LANG");
}
#[test]
#[serial]
fn env_pt_selects_portuguese() {
std::env::remove_var("LC_ALL");
std::env::remove_var("LANG");
std::env::set_var("SQLITE_GRAPHRAG_LANG", "pt");
assert_eq!(Language::from_env_or_locale(), Language::Portuguese);
std::env::remove_var("SQLITE_GRAPHRAG_LANG");
}
#[test]
#[serial]
fn env_pt_br_selects_portuguese() {
std::env::remove_var("LC_ALL");
std::env::remove_var("LANG");
std::env::set_var("SQLITE_GRAPHRAG_LANG", "pt-BR");
assert_eq!(Language::from_env_or_locale(), Language::Portuguese);
std::env::remove_var("SQLITE_GRAPHRAG_LANG");
}
#[test]
#[serial]
fn locale_ptbr_utf8_selects_portuguese() {
std::env::remove_var("SQLITE_GRAPHRAG_LANG");
std::env::set_var("LC_ALL", "pt_BR.UTF-8");
assert_eq!(Language::from_env_or_locale(), Language::Portuguese);
std::env::remove_var("LC_ALL");
}
#[test]
#[serial]
fn posix_precedence_lc_all_overrides_lang() {
std::env::remove_var("SQLITE_GRAPHRAG_LANG");
std::env::remove_var("LC_MESSAGES");
std::env::set_var("LC_ALL", "en_US.UTF-8");
std::env::set_var("LANG", "pt_BR.UTF-8");
assert_eq!(
Language::from_env_or_locale(),
Language::English,
"LC_ALL=en_US must override LANG=pt_BR per POSIX"
);
std::env::remove_var("LC_ALL");
std::env::remove_var("LANG");
}
#[test]
#[serial]
fn posix_precedence_lc_all_unrecognized_stops_iteration() {
std::env::remove_var("SQLITE_GRAPHRAG_LANG");
std::env::remove_var("LC_MESSAGES");
std::env::set_var("LC_ALL", "ja_JP.UTF-8");
std::env::set_var("LANG", "pt_BR.UTF-8");
assert_eq!(
Language::from_env_or_locale(),
Language::English,
"LC_ALL=ja_JP set must stop iteration; falls back to English default"
);
std::env::remove_var("LC_ALL");
std::env::remove_var("LANG");
}
#[test]
#[serial]
fn lang_pt_selects_portuguese_when_lc_all_unset() {
std::env::remove_var("SQLITE_GRAPHRAG_LANG");
std::env::remove_var("LC_ALL");
std::env::remove_var("LC_MESSAGES");
std::env::set_var("LANG", "pt_BR.UTF-8");
assert_eq!(Language::from_env_or_locale(), Language::Portuguese);
std::env::remove_var("LANG");
}
mod validation_tests {
use super::*;
#[test]
fn name_length_en() {
let msg = match Language::English {
Language::English => format!("name must be 1-{} chars", 80),
Language::Portuguese => format!("nome deve ter entre 1 e {} caracteres", 80),
};
assert!(msg.contains("name must be 1-80 chars"), "obtido: {msg}");
}
#[test]
fn name_length_pt() {
let msg = match Language::Portuguese {
Language::English => format!("name must be 1-{} chars", 80),
Language::Portuguese => format!("nome deve ter entre 1 e {} caracteres", 80),
};
assert!(
msg.contains("nome deve ter entre 1 e 80 caracteres"),
"obtido: {msg}"
);
}
#[test]
fn name_kebab_en() {
let nome = "Invalid_Name";
let msg = match Language::English {
Language::English => format!(
"name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
),
Language::Portuguese => {
format!("nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'")
}
};
assert!(msg.contains("kebab-case slug"), "obtido: {msg}");
assert!(msg.contains("Invalid_Name"), "obtido: {msg}");
}
#[test]
fn name_kebab_pt() {
let nome = "Invalid_Name";
let msg = match Language::Portuguese {
Language::English => format!(
"name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
),
Language::Portuguese => {
format!("nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'")
}
};
assert!(msg.contains("kebab-case"), "obtido: {msg}");
assert!(msg.contains("minúsculas"), "obtido: {msg}");
assert!(msg.contains("Invalid_Name"), "obtido: {msg}");
}
#[test]
fn description_exceeds_en() {
let msg = match Language::English {
Language::English => format!("description must be <= {} chars", 500),
Language::Portuguese => format!("descrição deve ter no máximo {} caracteres", 500),
};
assert!(msg.contains("description must be <= 500"), "obtido: {msg}");
}
#[test]
fn description_exceeds_pt() {
let msg = match Language::Portuguese {
Language::English => format!("description must be <= {} chars", 500),
Language::Portuguese => format!("descrição deve ter no máximo {} caracteres", 500),
};
assert!(
msg.contains("descrição deve ter no máximo 500"),
"obtido: {msg}"
);
}
#[test]
fn body_exceeds_en() {
let limite = crate::constants::MAX_MEMORY_BODY_LEN;
let msg = match Language::English {
Language::English => format!("body exceeds {limite} bytes"),
Language::Portuguese => format!("corpo excede {limite} bytes"),
};
assert!(msg.contains("body exceeds 512000"), "obtido: {msg}");
}
#[test]
fn body_exceeds_pt() {
let limite = crate::constants::MAX_MEMORY_BODY_LEN;
let msg = match Language::Portuguese {
Language::English => format!("body exceeds {limite} bytes"),
Language::Portuguese => format!("corpo excede {limite} bytes"),
};
assert!(msg.contains("corpo excede 512000"), "obtido: {msg}");
}
#[test]
fn new_name_length_en() {
let msg = match Language::English {
Language::English => format!("new-name must be 1-{} chars", 80),
Language::Portuguese => format!("novo nome deve ter entre 1 e {} caracteres", 80),
};
assert!(msg.contains("new-name must be 1-80"), "obtido: {msg}");
}
#[test]
fn new_name_length_pt() {
let msg = match Language::Portuguese {
Language::English => format!("new-name must be 1-{} chars", 80),
Language::Portuguese => format!("novo nome deve ter entre 1 e {} caracteres", 80),
};
assert!(
msg.contains("novo nome deve ter entre 1 e 80"),
"obtido: {msg}"
);
}
#[test]
fn new_name_kebab_en() {
let nome = "Bad Name";
let msg = match Language::English {
Language::English => format!(
"new-name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
),
Language::Portuguese => format!(
"novo nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'"
),
};
assert!(msg.contains("new-name must be kebab-case"), "obtido: {msg}");
}
#[test]
fn new_name_kebab_pt() {
let nome = "Bad Name";
let msg = match Language::Portuguese {
Language::English => format!(
"new-name must be kebab-case slug (lowercase letters, digits, hyphens): '{nome}'"
),
Language::Portuguese => format!(
"novo nome deve estar em kebab-case (minúsculas, dígitos, hífens): '{nome}'"
),
};
assert!(
msg.contains("novo nome deve estar em kebab-case"),
"obtido: {msg}"
);
}
#[test]
fn reserved_name_en() {
let msg = match Language::English {
Language::English => {
"names and namespaces starting with __ are reserved for internal use"
.to_string()
}
Language::Portuguese => {
"nomes e namespaces iniciados com __ são reservados para uso interno"
.to_string()
}
};
assert!(msg.contains("reserved for internal use"), "obtido: {msg}");
}
#[test]
fn reserved_name_pt() {
let msg = match Language::Portuguese {
Language::English => {
"names and namespaces starting with __ are reserved for internal use"
.to_string()
}
Language::Portuguese => {
"nomes e namespaces iniciados com __ são reservados para uso interno"
.to_string()
}
};
assert!(msg.contains("reservados para uso interno"), "obtido: {msg}");
}
}
mod app_error_pt_translation_tests {
use crate::errors::AppError;
#[test]
fn localized_message_pt_not_found_fully_translated() {
let err =
AppError::NotFound("memory 'test-mem' not found in namespace 'global'".into());
let pt = err.localized_message_for(crate::i18n::Language::Portuguese);
assert!(
pt.contains("memória"),
"PT must translate 'memory' to 'memória': {pt}"
);
assert!(
pt.contains("não encontrada no namespace"),
"PT must translate full phrase: {pt}"
);
assert!(
!pt.contains("not found in namespace"),
"PT must not contain English phrase: {pt}"
);
}
#[test]
fn localized_message_pt_duplicate_fully_translated() {
let err = AppError::Duplicate(
"memory 'x' already exists in namespace 'global'. Use --force-merge to update."
.into(),
);
let pt = err.localized_message_for(crate::i18n::Language::Portuguese);
assert!(pt.contains("memória"), "PT must translate 'memory': {pt}");
assert!(
pt.contains("já existe no namespace"),
"PT must translate 'already exists': {pt}"
);
}
}
}