Saltar al contenido
Development

Los errores nunca fueron el problema. Las cadenas de texto sí.

Por Victor Da Luz
rusttaurii18ndev-loggreenhouse

La semana pasada conecté las claves de mensaje de Paraglide en todo el frontend de Greenhouse para que la interfaz pudiera hablar español y portugués algún día. Salió bien, en general, una vez que encontré la única clave de configuración que descarta en silencio cada mensaje que se escribe. Pero a mitad de ese trabajo me topé con una pared que había estado evitando: cada mensaje de error que la app le muestra al usuario nace como una cadena de texto en inglés formateada a mano en Rust.

Err(EngineError::Invalid(format!("{item_id} is not active")))

Esa cadena viaja directo de una llamada a format!() en Rust hasta un banner role="alert" en el navegador, sin cambios. Podía traducir cada etiqueta de botón y cada título de diálogo en la app, y el único lugar donde los usuarios realmente ven que algo salió mal seguiría hablando solo en inglés. Peor aún, algunos de estos se leen como salida de depuración, no como texto para el usuario: “{item_id} is already at the last stage; use release_project” es una nota para un desarrollador, no una oración para alguien archivando la idea de un beat.

Arreglarlo significaba admitir que la cadena nunca fue el verdadero problema. El problema era que el error no llevaba ningún significado más allá del punto donde se construía. Para cuando llegaba al frontend, lo único que existía era texto en inglés, sin forma de preguntar “qué pasó realmente acá” sin parsear la prosa con expresiones regulares.

El arreglo: enviar la forma del problema, no una oración sobre él

Tauri v2 permite que un comando devuelva cualquier tipo de error que implemente Serialize, no solo una cadena de texto. Así que el arreglo fue dejar de formatear oraciones en Rust y empezar a describir condiciones en su lugar:

#[derive(Serialize)]
#[serde(tag = "kind", content = "params", rename_all = "camelCase")]
pub enum ErrorKind {
    ItemNotActive { item_id: String },
    NameEmpty,
    Internal { detail: String },
    // ...twenty-some more
}

Por el cable eso se convierte en {"kind": "itemNotActive", "params": {"itemId": "..."}}. El frontend tiene una tabla de búsqueda de kind a un mensaje de Paraglide y con eso alcanza. Nada de parseo, nada de adivinar, nada de inglés incrustado en la capa de transporte.

Aunque no todos los errores merecen este tratamiento. Un error de corrupción de SQLite o una búsqueda de directorio del sistema operativo que falla no es algo que una oración traducida ayude a resolver, nadie lee “the database disk image is malformed” en ningún idioma y sabe qué hacer después. Esos colapsan en un solo tipo Internal { detail } con un mensaje genérico de “algo salió mal” en el frontend, y el diagnóstico crudo se queda en un log de la consola de desarrollo en vez de en la cara del usuario.

El detalle que se habría publicado roto en silencio

Escribí #[serde(rename_all = "camelCase")] en el enum y asumí que cubría todo, los nombres de variante y los campos dentro de cada variante. No es así. rename_all solo renombra la etiqueta (ItemNotActiveitemNotActive). El campo interno se quedó como item_id, en snake_case, justo al lado de una etiqueta en camelCase como si nada estuviera mal.

Sin error de compilación. Sin error en tiempo de ejecución. Solo un payload JSON que se veía casi correcto. Si solo se hubiera verificado “¿esto compila?” o “¿la prueba pasa?”, esto se publica, y cada mensaje de error con parámetros falla en silencio al leer su propio parámetro en el frontend. La única forma en que lo detecté fue escribiendo una prueba real que comparara la salida serializada contra el JSON esperado:

assert_eq!(
    serde_json::to_value(&err).unwrap(),
    json!({ "kind": "itemNotActive", "params": { "itemId": "abc-123" } })
);

Esa prueba falló de inmediato, con la discrepancia ahí mismo en el diff. El arreglo fue un atributo más, rename_all_fields, que hace lo que había asumido que rename_all ya hacía.

Comprobar que el cable realmente coincide con la prueba

Una prueba unitaria demuestra que el lado de Rust serializa correctamente. No demuestra que el puente IPC real, el webview real, el marshaling de JSON real, entregue esa misma forma a una pestaña de navegador real. Así que manejé la app en ejecución con el arnés de WebDriver construido para un issue anterior, llamé al puente de bajo nivel de Tauri directamente contra un ID de elemento deliberadamente inválido, y leí exactamente lo que recibió el navegador:

{"kind":"itemNotFound","params":{"itemId":"does-not-exist"}}

Después metí ese mismo objeto por la función de traducción del frontend y vi cómo se convertía en “This item couldn’t be found. It may have been moved or removed.” No [object Object], no una cadena JSON cruda metida a la fuerza en un banner, sino la oración real que una persona debería ver.

Esa última verificación importó más de lo que esperaba al principio. Es fácil demostrar que cada mitad de un pipeline funciona por separado y nunca llegar a ver realmente cómo se comunican las dos mitades.

Lecturas relacionadas

Development

Dos notas que nadie vio jamás

Greenhouse guardaba fielmente una nota de captura y una nota de traspaso, y no mostraba ninguna de las dos: una no tenía campo en el tipo de wire, la otra tenía un comando funcional que nadie llamaba.

Leer
Development

El escaneo que se ofreció a importarse a sí mismo

Un escaneo de importación que encontró la propia estructura interna de la app, y luego volvió a ofrecer una carpeta que ya había importado - dos versiones de la misma conversación faltante entre capas.

Leer