Skip to main content

error_engine/
catalog.rs

1use crate::error::EngineError;
2use serde::Deserialize;
3use std::collections::HashMap;
4
5/// Reserved code returned whenever a diagnostic cannot be resolved normally
6/// (catalog failed to load, or the requested code has no entry).
7pub const UNKNOWN_CODE: &str = "UNK-000";
8
9#[derive(Debug, Deserialize)]
10pub struct CatalogEntry {
11    pub template: String,
12    pub hint: Option<String>,
13}
14
15#[derive(Debug, Deserialize)]
16struct RawCatalog {
17    #[serde(flatten)]
18    entries: HashMap<String, CatalogEntry>,
19}
20
21#[derive(Debug)]
22pub struct Catalog {
23    entries: HashMap<String, CatalogEntry>,
24    /// Set when `load_or_fallback` had to fall back because the catalog
25    /// itself could not be read/parsed. `None` means the catalog loaded fine.
26    load_error: Option<String>,
27}
28
29impl Catalog {
30    /// Strict loader: fails if the file is missing or invalid. Use this at
31    /// startup when you want the application to refuse to run without a
32    /// valid catalog (propagate with `?` via `anyhow` in `main`).
33    pub fn load(path: &str) -> Result<Self, EngineError> {
34        let raw = std::fs::read_to_string(path)
35            .map_err(|source| EngineError::CatalogRead { path: path.to_string(), source })?;
36        let raw: RawCatalog = toml::from_str(&raw)
37            .map_err(|source| EngineError::CatalogParse { path: path.to_string(), source })?;
38        Ok(Self { entries: raw.entries, load_error: None })
39    }
40
41    /// Infallible loader. If the catalog can't be read or parsed, returns a
42    /// `Catalog` with no entries, remembering the error so `render()` can
43    /// surface it as `UNK-000` instead of panicking. This is the loader
44    /// most applications should use.
45    pub fn load_or_fallback(path: &str) -> Self {
46        match Self::load(path) {
47            Ok(catalog) => catalog,
48            Err(err) => Self {
49                entries: HashMap::new(),
50                load_error: Some(err.summary()),
51            },
52        }
53    }
54
55    /// Parse a catalog from an in-memory TOML string. Use this to embed a
56    /// catalog into a library at compile time via `include_str!`, so it
57    /// travels with the compiled crate instead of depending on a runtime
58    /// file path:
59    ///
60    /// ```ignore
61    /// pub fn catalog() -> error_engine::Catalog {
62    ///     error_engine::Catalog::from_str(include_str!("../errors.toml"))
63    ///         .expect("this crate's own catalog is valid TOML")
64    /// }
65    /// ```
66    pub fn from_str(toml: &str) -> Result<Self, EngineError> {
67        let raw: RawCatalog = toml::from_str(toml)
68            .map_err(|source| EngineError::CatalogParse { path: "<embedded>".to_string(), source })?;
69        Ok(Self { entries: raw.entries, load_error: None })
70    }
71
72    /// Combine two catalogs. Entries in `self` take priority over entries
73    /// in `other` on code collisions — so an app's own catalog can override
74    /// a library's wording, while codes it doesn't define fall through to
75    /// the library's defaults instead of `UNK-000`.
76    ///
77    /// A `load_error` on `self` is preserved as-is: a broken app catalog
78    /// still renders `UNK-000` for everything, but merging never poisons
79    /// `other` — each layer degrades independently.
80    pub fn merged_with(mut self, other: Catalog) -> Self {
81        for (code, entry) in other.entries {
82            self.entries.entry(code).or_insert(entry);
83        }
84        self
85    }
86
87    /// Never fails. Falls back to `UNK-000` in two cases:
88    /// - the catalog itself failed to load (`load_error` is set)
89    /// - the code has no entry in an otherwise valid catalog
90    pub fn render(&self, code: &str, ctx: &[(&str, String)]) -> String {
91        if let Some(load_error) = &self.load_error {
92            return format!("[{UNKNOWN_CODE}] catalog failed to load: {load_error}");
93        }
94
95        let Some(entry) = self.entries.get(code) else {
96            return format!("[{UNKNOWN_CODE}] unknown diagnostic code '{code}'");
97        };
98
99        let mut text = entry.template.clone();
100        for (key, val) in ctx {
101            text = text.replace(&format!("{{{key}}}"), val);
102        }
103        text
104    }
105
106    /// `None` in both `UNK-000` cases (catalog load failure or missing code).
107    pub fn hint(&self, code: &str) -> Option<&str> {
108        if self.load_error.is_some() {
109            return None;
110        }
111        self.entries.get(code)?.hint.as_deref()
112    }
113}