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}