error-engine 0.2.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()),
            },
        }
    }

    /// Parse a catalog from an in-memory TOML string. Use this to embed a
    /// catalog into a library at compile time via `include_str!`, so it
    /// travels with the compiled crate instead of depending on a runtime
    /// file path:
    ///
    /// ```ignore
    /// pub fn catalog() -> error_engine::Catalog {
    ///     error_engine::Catalog::from_str(include_str!("../errors.toml"))
    ///         .expect("this crate's own catalog is valid TOML")
    /// }
    /// ```
    pub fn from_str(toml: &str) -> Result<Self, EngineError> {
        let raw: RawCatalog = toml::from_str(toml)
            .map_err(|source| EngineError::CatalogParse { path: "<embedded>".to_string(), source })?;
        Ok(Self { entries: raw.entries, load_error: None })
    }

    /// Combine two catalogs. Entries in `self` take priority over entries
    /// in `other` on code collisions — so an app's own catalog can override
    /// a library's wording, while codes it doesn't define fall through to
    /// the library's defaults instead of `UNK-000`.
    ///
    /// A `load_error` on `self` is preserved as-is: a broken app catalog
    /// still renders `UNK-000` for everything, but merging never poisons
    /// `other` — each layer degrades independently.
    pub fn merged_with(mut self, other: Catalog) -> Self {
        for (code, entry) in other.entries {
            self.entries.entry(code).or_insert(entry);
        }
        self
    }

    /// 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()
    }
}