Os erros nunca foram o problema. As strings eram.
Semana passada eu liguei as chaves de mensagem do Paraglide por todo o frontend do Greenhouse para que a interface pudesse falar espanhol e português um dia. Deu tudo certo, na maior parte, assim que encontrei a única chave de configuração que silenciosamente descarta toda mensagem que você escreve. Mas no meio desse trabalho esbarrei numa parede que eu vinha evitando: toda mensagem de erro que o app mostra ao usuário começa a vida como uma string em inglês formatada à mão em Rust.
Err(EngineError::Invalid(format!("{item_id} is not active")))
Essa string viaja direto de uma chamada format!() em Rust até um banner role="alert" no navegador, sem mudar. Eu poderia traduzir todo rótulo de botão e todo título de diálogo do app, e o único lugar onde os usuários realmente veem algo dar errado continuaria falando só inglês. Pior, algumas dessas leem como saída de debug, não como texto para usuário - “{item_id} is already at the last stage; use release_project” é uma anotação para um desenvolvedor, não uma frase para alguém guardando a ideia de um beat.
Consertar isso significou admitir que a string nunca foi o problema de verdade. O problema era que o erro não carregava nenhum significado além do ponto onde foi construído. Quando chegava ao frontend, tudo que existia era texto em inglês, nenhum jeito de perguntar “o que realmente aconteceu aqui” sem fazer parsing de prosa com regex.
A correção: enviar o formato do problema, não uma frase sobre ele
O Tauri v2 permite que um command retorne qualquer tipo de erro que implemente Serialize, não só uma string. Então a correção foi parar de formatar frases em Rust e passar a descrever condições:
#[derive(Serialize)]
#[serde(tag = "kind", content = "params", rename_all = "camelCase")]
pub enum ErrorKind {
ItemNotActive { item_id: String },
NameEmpty,
Internal { detail: String },
// ...twenty-some more
}
Pela conexão isso vira {"kind": "itemNotActive", "params": {"itemId": "..."}}. O frontend tem uma tabela de consulta de kind para uma mensagem do Paraglide e pronto. Nenhum parsing, nenhum chute, nenhum inglês embutido na camada de transporte.
Nem todo erro merece esse tratamento, porém. Um erro de corrupção do SQLite ou uma busca de diretório do sistema operacional que falha não é algo que uma frase traduzida ajuda, ninguém lê “the database disk image is malformed” em nenhum idioma e sabe o que fazer a seguir. Esses colapsam num único tipo Internal { detail } com uma mensagem genérica de “algo deu errado” no frontend, e o diagnóstico bruto fica num log de console de desenvolvimento em vez de na cara do usuário.
A pegadinha que teria sido lançada silenciosamente quebrada
Escrevi #[serde(rename_all = "camelCase")] no enum e assumi que isso cobria tudo, os nomes das variantes e os campos dentro de cada variante. Não cobre. rename_all só renomeia a tag (ItemNotActive → itemNotActive). O campo interno continuou item_id, em snake_case, sentado bem ao lado de uma tag em camelCase como se nada estivesse errado.
Nenhum erro de compilação. Nenhum erro em runtime. Só um payload JSON que parecia quase certo. Se eu tivesse só checado “isso compila” ou “o teste passa”, isso ia para produção, e toda mensagem de erro parametrizada falharia silenciosamente em ler o próprio parâmetro no frontend. O único jeito que eu peguei isso foi escrevendo um teste de verdade que comparava a saída serializada com o JSON que eu esperava:
assert_eq!(
serde_json::to_value(&err).unwrap(),
json!({ "kind": "itemNotActive", "params": { "itemId": "abc-123" } })
);
Esse teste falhou imediatamente, com a divergência sentada bem ali no diff. A correção foi mais um atributo, rename_all_fields, que faz o que eu tinha assumido que rename_all já fazia.
Provando que a conexão realmente concorda com o teste
Um teste unitário prova que o lado Rust serializa corretamente. Não prova que a ponte IPC real, o webview de verdade, o marshaling de JSON de verdade, entrega esse mesmo formato para uma aba de navegador de verdade. Então controlei o app rodando com o harness de WebDriver construído para uma issue anterior, chamei a ponte de baixo nível do Tauri diretamente contra um item ID propositalmente inválido, e li de volta exatamente o que o navegador recebeu:
{ "kind": "itemNotFound", "params": { "itemId": "does-not-exist" } }
Depois passei esse mesmo objeto pela função de tradução do frontend e vi ele virar “This item couldn’t be found. It may have been moved or removed.” Não [object Object], não uma string JSON crua enfiada num banner, a frase de verdade que uma pessoa deveria ver.
Essa última checagem importou mais do que eu esperava no começo. É fácil provar que cada metade de um pipeline funciona isolada e nunca de fato ver as duas metades conversando entre si.
Leitura relacionada
A config que ficou embutida em todo vault, para sempre
Nomes de estágio gravados em cada config.yaml na criação transformavam tradução e templates numa migração por vault, até as palavras deixarem de ser dado.
Duas notas que ninguém nunca viu
O Greenhouse salvava fielmente uma nota de captura e uma de transição, sem mostrar nenhuma: faltava o campo a uma, a outra tinha um comando sem uso.
O scan que se ofereceu para importar a si mesmo
Um scanner de importação encontrou a própria estrutura interna do app, e voltou a oferecer uma pasta já importada - a mesma conversa faltando entre camadas.
Você também pode achar útil
Proton Pass
Gerenciador de senhas focado em privacidade, da equipe por trás do Proton Mail.
Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).
Saiba maisRackNerd VPS
Hospedagem VPS econômica para serviços leves que funcionam continuamente.
Como afiliado da RackNerd, ganho com compras qualificadas.
Saiba maisProton Drive
Armazenamento em nuvem criptografado, da equipe por trás do Proton Mail.
Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).
Saiba mais