Saltar al contenido

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

Rust
// 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:

  • thiserror genera 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:

Rust
// 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:

Rust
#[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:

Rust
// 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.