El enum de error por módulo
Cómo se produce el payload sin ensuciar la lógica: un enum de error rico por dominio, una conversión hacia la forma plana, y el operador de propagación haciendo el trabajo intermedio.
Dos tipos, dos propósitos
El enum del módulo
// proyectos/error.rs#[derive(thiserror::Error, Debug)]pub enum ProyectoError { #[error("el proyecto {0} no existe")] NoExiste(ProyectoId), #[error("el proyecto {id} está bloqueado por otra ventana")] Bloqueado { id: ProyectoId }, #[error("el formato del proyecto es de una versión más nueva ({encontrada})")] VersionFutura { encontrada: u32 }, // De aquí para abajo, errores de otros que envolvemos #[error("error de entrada/salida")] Io(#[from] std::io::Error), #[error("el fichero del proyecto está corrupto")] Formato(#[from] serde_json::Error),}Cuatro cosas que hace ese fragmento y merecen verse:
thiserrorgenera el mensaje a partir del atributo, con los campos interpolados. No hay que escribir la implementación a mano.- Las variantes llevan datos. El identificador, la versión encontrada. Eso es contexto que un mensaje de texto habría perdido.
#[from]genera la conversión desde el error de otra biblioteca, y es lo que permite usar el operador de propagación sobre ellos dentro del módulo.- Las variantes propias van primero y las envueltas al final. Es una convención pequeña que ayuda a leer: arriba, los fallos que tu dominio conoce; abajo, los que vienen de fuera.
La conversión hacia el payload
En un solo sitio, y es donde se decide el código:
// error.rs — la fronteraimpl From<ProyectoError> for ErrorPayload { fn from(e: ProyectoError) -> Self { use ProyectoError::*; let code = match &e { NoExiste(_) => "PROYECTO_NO_EXISTE", Bloqueado { .. } => "PROYECTO_BLOQUEADO", VersionFutura { .. } => "PROYECTO_VERSION_FUTURA", Io(io) => match io.kind() { std::io::ErrorKind::NotFound => "NO_EXISTE", std::io::ErrorKind::PermissionDenied => "SIN_PERMISO", _ => "IO", }, Formato(_) => "PROYECTO_CORRUPTO", }; ErrorPayload { code: code.into(), message: e.to_string() } }}Y con eso, la frontera del nivel 14.5 vuelve a caber en tres líneas:
#[tauri::command]pub async fn cargar_proyecto(app: AppHandle, id: ProyectoId) -> Result<Proyecto, ErrorPayload> { Ok(logica::cargar(&app.path().app_data_dir()?, id).await?) // ^ el ? convierte}El contexto que se pierde, y dónde recuperarlo
Aplanar tira información: el error de origen, la ruta, el identificador. Eso está bien para lo que cruza y mal para el registro.
La solución es registrar antes de convertir, en la lógica y no en la frontera:
// proyectos/logica.rspub async fn cargar(base: &Path, id: ProyectoId) -> Result<Proyecto, ProyectoError> { let ruta = base.join(id.como_fichero()); let texto = std::fs::read_to_string(&ruta).map_err(|e| { // aquí todavía tenemos la ruta y el error original log::warn!("no se pudo leer {}: {e}", ruta.display()); ProyectoError::Io(e) })?; Ok(serde_json::from_str(&texto)?)}Así el registro tiene la ruta, la clase de error del sistema y el identificador, y el frontend recibe dos campos. Cada uno con lo que necesita.