Skip to main content

quarto_error_reporting/
catalog.rs

1//! Pluggable error-code catalog.
2//!
3//! `quarto-error-reporting` is **catalog-agnostic**: it defines the catalog
4//! *shape* ([`ErrorCodeInfo`]) and a [`CatalogProvider`] seam, but ships no
5//! catalog *data*. An embedding product installs its own catalog once, early,
6//! via [`install_catalog`]; in Quarto this is done by the `quarto-error-catalog`
7//! crate (`quarto_error_catalog::install()`), which carries the `Q-*`
8//! `error_catalog.json`. With nothing installed, every lookup returns `None`
9//! (see [`EmptyCatalog`]).
10//!
11//! This is the host side of the cross-package error-code discipline; see
12//! `claude-notes/designs/cross-package-error-codes.md`.
13
14use serde::{Deserialize, Serialize};
15use std::sync::OnceLock;
16
17/// Metadata for an error code.
18///
19/// Each catalog entry describes a specific error code, including its
20/// subsystem, title, default message, and documentation URL.
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub struct ErrorCodeInfo {
23    /// Subsystem name (e.g., "yaml", "markdown", "engine")
24    pub subsystem: String,
25
26    /// Short title for the error
27    pub title: String,
28
29    /// Default message template (may include placeholders)
30    pub message_template: String,
31
32    /// URL to documentation (optional)
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub docs_url: Option<String>,
35
36    /// When this error was introduced (version)
37    pub since_version: String,
38}
39
40/// A source of error-code metadata, supplied by the embedding product.
41///
42/// Implementors return metadata for a given code, or `None` if the code is not
43/// in their catalog. The returned reference is tied to `&self`, which lets the
44/// installed-global path (see [`install_catalog`]) hand out `&'static`
45/// references — the installed provider lives for the rest of the process.
46///
47/// `Send + Sync` is required so the provider can live in a process-wide
48/// [`OnceLock`]; it costs nothing for the data-only providers in practice.
49pub trait CatalogProvider: Send + Sync {
50    /// Look up an error code's metadata, or `None` if it is not in this catalog.
51    fn lookup(&self, code: &str) -> Option<&ErrorCodeInfo>;
52}
53
54/// The default provider used when none has been installed: every lookup is
55/// `None`. This is what makes the crate usable standalone with zero config — a
56/// non-Quarto consumer that installs nothing simply gets code-less, URL-less
57/// diagnostics (tier-2 "passthrough" in the discipline's terms).
58pub struct EmptyCatalog;
59
60impl CatalogProvider for EmptyCatalog {
61    fn lookup(&self, _code: &str) -> Option<&ErrorCodeInfo> {
62        None
63    }
64}
65
66/// The process-wide installed catalog. Written at most once, by the embedder.
67static CATALOG: OnceLock<Box<dyn CatalogProvider>> = OnceLock::new();
68
69/// Install the process-wide catalog provider.
70///
71/// The **first** call wins; later calls are no-ops (so a double install — e.g.
72/// a binary's `main` plus a test helper — is harmless). Embedders should call
73/// this once, as early as possible, at binary / WASM startup, *before* any
74/// diagnostic's docs URL is resolved.
75pub fn install_catalog(provider: Box<dyn CatalogProvider>) {
76    let _ = CATALOG.set(provider);
77}
78
79/// The installed provider, or a shared [`EmptyCatalog`] if none was installed.
80fn catalog() -> &'static dyn CatalogProvider {
81    static EMPTY: EmptyCatalog = EmptyCatalog;
82    match CATALOG.get() {
83        Some(provider) => &**provider,
84        None => &EMPTY,
85    }
86}
87
88/// Look up full metadata for an error code via the installed catalog.
89///
90/// Returns `None` if no catalog is installed, or the code is not in it.
91///
92/// # Example
93///
94/// ```
95/// use quarto_error_reporting::catalog::get_error_info;
96///
97/// // With a `CatalogProvider` installed (e.g. via `quarto-error-catalog`),
98/// // this resolves to the code's metadata; with none installed it is `None`.
99/// let _ = get_error_info("Q-0-1");
100/// ```
101pub fn get_error_info(code: &str) -> Option<&'static ErrorCodeInfo> {
102    catalog().lookup(code)
103}
104
105/// Get the documentation URL for an error code, if the installed catalog has one.
106///
107/// Returns `None` if no catalog is installed, the code is unknown, or the entry
108/// has no documentation URL.
109///
110/// # Example
111///
112/// ```
113/// use quarto_error_reporting::catalog::get_docs_url;
114///
115/// // `Some(url)` iff a catalog mapping this code (with a docs URL) is installed.
116/// let _ = get_docs_url("Q-0-1");
117/// ```
118pub fn get_docs_url(code: &str) -> Option<&'static str> {
119    catalog()
120        .lookup(code)
121        .and_then(|info| info.docs_url.as_deref())
122}
123
124/// Get the subsystem name for an error code, if the installed catalog knows it.
125///
126/// Returns `None` if no catalog is installed or the code is unknown.
127///
128/// # Example
129///
130/// ```
131/// use quarto_error_reporting::catalog::get_subsystem;
132///
133/// // With a catalog installed this returns e.g. `Some("internal")` for "Q-0-1".
134/// let _ = get_subsystem("Q-0-1");
135/// ```
136pub fn get_subsystem(code: &str) -> Option<&'static str> {
137    catalog().lookup(code).map(|info| info.subsystem.as_str())
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    fn sample_info(subsystem: &str, docs_url: Option<&str>) -> ErrorCodeInfo {
145        ErrorCodeInfo {
146            subsystem: subsystem.to_string(),
147            title: "Sample".to_string(),
148            message_template: "sample".to_string(),
149            docs_url: docs_url.map(str::to_string),
150            since_version: "0.0.0".to_string(),
151        }
152    }
153
154    /// The default provider returns `None` for everything. Tested *directly*
155    /// (no global state) so it is robust regardless of test runner — this is
156    /// the canonical "no catalog installed → None" behaviour.
157    #[test]
158    fn empty_catalog_returns_none() {
159        let empty = EmptyCatalog;
160        assert!(empty.lookup("Q-0-1").is_none());
161        assert!(empty.lookup("anything").is_none());
162    }
163
164    /// A trivial mock provider implements the trait and is found by lookup.
165    struct MockCatalog {
166        entry: ErrorCodeInfo,
167    }
168    impl CatalogProvider for MockCatalog {
169        fn lookup(&self, code: &str) -> Option<&ErrorCodeInfo> {
170            (code == "Q-0-1").then_some(&self.entry)
171        }
172    }
173
174    /// The **single** test in this crate that mutates the process-global
175    /// catalog: install a mock and assert the free functions delegate to it for
176    /// a known code, and return `None` for an unknown one. Keeping this the only
177    /// global-mutating test means there is no intra-process install conflict,
178    /// even under `cargo test` (threads) rather than nextest (process-per-test).
179    #[test]
180    fn installed_catalog_is_used_by_lookups() {
181        install_catalog(Box::new(MockCatalog {
182            entry: sample_info("internal", Some("https://example.test/docs/Q-0-1")),
183        }));
184
185        assert_eq!(get_subsystem("Q-0-1"), Some("internal"));
186        assert_eq!(
187            get_docs_url("Q-0-1"),
188            Some("https://example.test/docs/Q-0-1")
189        );
190        assert!(get_error_info("Q-0-1").is_some());
191
192        // Unknown code, even with a catalog installed, is `None`.
193        assert!(get_subsystem("Q-9-9").is_none());
194        assert!(get_docs_url("Q-9-9").is_none());
195        assert!(get_error_info("Q-9-9").is_none());
196    }
197}