error-engine 0.1.0

A message catalog + presentation layer on top of thiserror and tracing for consistent error/warning/info rendering.
Documentation
use crate::error::EngineError;
use serde::Deserialize;
use std::collections::HashMap;

/// Reserved code returned whenever a diagnostic cannot be resolved normally
/// (catalog failed to load, or the requested code has no entry).
pub const UNKNOWN_CODE: &str = "UNK-000";

#[derive(Debug, Deserialize)]
pub struct CatalogEntry {
    pub template: String,
    pub hint: Option<String>,
}

#[derive(Debug, Deserialize)]
struct RawCatalog {
    #[serde(flatten)]
    entries: HashMap<String, CatalogEntry>,
}

#[derive(Debug)]
pub struct Catalog {
    entries: HashMap<String, CatalogEntry>,
    /// Set when `load_or_fallback` had to fall back because the catalog
    /// itself could not be read/parsed. `None` means the catalog loaded fine.
    load_error: Option<String>,
}

impl Catalog {
    /// Strict loader: fails if the file is missing or invalid. Use this at
    /// startup when you want the application to refuse to run without a
    /// valid catalog (propagate with `?` via `anyhow` in `main`).
    pub fn load(path: &str) -> Result<Self, EngineError> {
        let raw = std::fs::read_to_string(path)
            .map_err(|source| EngineError::CatalogRead { path: path.to_string(), source })?;
        let raw: RawCatalog = toml::from_str(&raw)
            .map_err(|source| EngineError::CatalogParse { path: path.to_string(), source })?;
        Ok(Self { entries: raw.entries, load_error: None })
    }

    /// Infallible loader. If the catalog can't be read or parsed, returns a
    /// `Catalog` with no entries, remembering the error so `render()` can
    /// surface it as `UNK-000` instead of panicking. This is the loader
    /// most applications should use.
    pub fn load_or_fallback(path: &str) -> Self {
        match Self::load(path) {
            Ok(catalog) => catalog,
            Err(err) => Self {
                entries: HashMap::new(),
                load_error: Some(err.summary()),
            },
        }
    }

    /// Never fails. Falls back to `UNK-000` in two cases:
    /// - the catalog itself failed to load (`load_error` is set)
    /// - the code has no entry in an otherwise valid catalog
    pub fn render(&self, code: &str, ctx: &[(&str, String)]) -> String {
        if let Some(load_error) = &self.load_error {
            return format!("[{UNKNOWN_CODE}] catalog failed to load: {load_error}");
        }

        let Some(entry) = self.entries.get(code) else {
            return format!("[{UNKNOWN_CODE}] unknown diagnostic code '{code}'");
        };

        let mut text = entry.template.clone();
        for (key, val) in ctx {
            text = text.replace(&format!("{{{key}}}"), val);
        }
        text
    }

    /// `None` in both `UNK-000` cases (catalog load failure or missing code).
    pub fn hint(&self, code: &str) -> Option<&str> {
        if self.load_error.is_some() {
            return None;
        }
        self.entries.get(code)?.hint.as_deref()
    }
}