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}