Skip to main content

canic_host/diagnostics/
mod.rs

1//! Module: canic_host::diagnostics
2//!
3//! Responsibility: render compact Canic diagnostic identities for host/operator surfaces.
4//! Does not own: runtime reason construction, producer mappings, retry policy, or public projection.
5//! Boundary: this host-only catalogue contains only name, origin, summary, and universally safe guidance.
6
7mod generated;
8
9use canic_core::diagnostics::DiagnosticCode;
10
11///
12/// DiagnosticEntry
13///
14/// Host-owned presentation for one current registered diagnostic reason.
15///
16
17#[derive(Clone, Copy, Debug, Eq, PartialEq)]
18pub struct DiagnosticEntry {
19    pub code: DiagnosticCode,
20    pub name: &'static str,
21    pub origin: &'static str,
22    pub summary: &'static str,
23    pub guidance: Option<&'static str>,
24}
25
26impl DiagnosticEntry {
27    pub(super) const fn new(
28        code: u16,
29        name: &'static str,
30        origin: &'static str,
31        summary: &'static str,
32        guidance: Option<&'static str>,
33    ) -> Self {
34        Self {
35            code: DiagnosticCode::from_raw(code),
36            name,
37            origin,
38            summary,
39            guidance,
40        }
41    }
42}
43
44///
45/// RetiredDiagnosticEntry
46///
47/// Minimal host identity retained after a released reason loses its final producer.
48///
49
50#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51pub struct RetiredDiagnosticEntry {
52    pub code: DiagnosticCode,
53    pub name: &'static str,
54}
55
56impl RetiredDiagnosticEntry {
57    #[cfg_attr(
58        not(test),
59        expect(
60            dead_code,
61            reason = "the initial unreleased register has no retired rows"
62        )
63    )]
64    pub(super) const fn new(code: u16, name: &'static str) -> Self {
65        Self {
66            code: DiagnosticCode::from_raw(code),
67            name,
68        }
69    }
70}
71
72///
73/// DiagnosticLookup
74///
75/// Lossless lookup outcome for a current, retired, or unknown raw identity.
76///
77
78#[derive(Clone, Copy, Debug, Eq, PartialEq)]
79pub enum DiagnosticLookup<'a> {
80    Current(&'a DiagnosticEntry),
81    Retired(&'a RetiredDiagnosticEntry),
82    Unknown(DiagnosticCode),
83}
84
85///
86/// DiagnosticCatalog
87///
88/// Static host catalogue generated from the reason ledger.
89///
90
91#[derive(Debug)]
92pub struct DiagnosticCatalog {
93    current: &'static [DiagnosticEntry],
94    retired: &'static [RetiredDiagnosticEntry],
95}
96
97impl DiagnosticCatalog {
98    const fn new(
99        current: &'static [DiagnosticEntry],
100        retired: &'static [RetiredDiagnosticEntry],
101    ) -> Self {
102        Self { current, retired }
103    }
104
105    /// Look up a raw identity without guessing metadata for unknown values.
106    #[must_use]
107    pub fn lookup(&self, code: DiagnosticCode) -> DiagnosticLookup<'_> {
108        if let Ok(index) = self
109            .current
110            .binary_search_by_key(&code.raw(), |entry| entry.code.raw())
111        {
112            return DiagnosticLookup::Current(&self.current[index]);
113        }
114        if let Ok(index) = self
115            .retired
116            .binary_search_by_key(&code.raw(), |entry| entry.code.raw())
117        {
118            return DiagnosticLookup::Retired(&self.retired[index]);
119        }
120        DiagnosticLookup::Unknown(code)
121    }
122
123    /// Iterate over current entries in numeric order.
124    #[must_use]
125    pub fn current_entries(&self) -> impl ExactSizeIterator<Item = &DiagnosticEntry> {
126        self.current.iter()
127    }
128
129    /// Iterate over retired entries in numeric order.
130    #[must_use]
131    pub fn retired_entries(&self) -> impl ExactSizeIterator<Item = &RetiredDiagnosticEntry> {
132        self.retired.iter()
133    }
134}
135
136static CATALOG: DiagnosticCatalog =
137    DiagnosticCatalog::new(generated::CURRENT_REASONS, generated::RETIRED_REASONS);
138
139/// Return the generated host catalogue.
140#[must_use]
141pub const fn diagnostic_catalog() -> &'static DiagnosticCatalog {
142    &CATALOG
143}
144
145/// Look up one lossless raw diagnostic identity.
146#[must_use]
147pub fn lookup_diagnostic(code: DiagnosticCode) -> DiagnosticLookup<'static> {
148    diagnostic_catalog().lookup(code)
149}
150
151/// Render one compact diagnostic with host-owned prose.
152#[must_use]
153pub fn render_diagnostic(code: DiagnosticCode) -> String {
154    match lookup_diagnostic(code) {
155        DiagnosticLookup::Current(entry) => {
156            let mut rendered = format!(
157                "{} {} (origin: {}): {}",
158                entry.code, entry.name, entry.origin, entry.summary
159            );
160            if let Some(guidance) = entry.guidance {
161                rendered.push(' ');
162                rendered.push_str(guidance);
163            }
164            rendered
165        }
166        DiagnosticLookup::Retired(entry) => {
167            format!("{} (retired diagnostic: {})", entry.code, entry.name)
168        }
169        DiagnosticLookup::Unknown(code) => format!("{code} (unknown diagnostic)"),
170    }
171}
172
173// -----------------------------------------------------------------------------
174// Tests
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn generated_catalogue_is_sorted_unique_and_complete() {
182        let catalog = diagnostic_catalog();
183        let current_codes = catalog
184            .current_entries()
185            .map(|entry| entry.code.raw())
186            .collect::<Vec<_>>();
187        let retired_codes = catalog
188            .retired_entries()
189            .map(|entry| entry.code.raw())
190            .collect::<Vec<_>>();
191        let current_names = catalog
192            .current_entries()
193            .map(|entry| entry.name)
194            .collect::<Vec<_>>();
195        let retired_names = catalog
196            .retired_entries()
197            .map(|entry| entry.name)
198            .collect::<Vec<_>>();
199        let entry_count = current_codes.len() + retired_codes.len();
200        let unique_codes = current_codes
201            .iter()
202            .chain(&retired_codes)
203            .copied()
204            .collect::<std::collections::BTreeSet<_>>();
205        let unique_names = current_names
206            .iter()
207            .chain(&retired_names)
208            .copied()
209            .collect::<std::collections::BTreeSet<_>>();
210
211        assert_eq!(unique_codes.len(), entry_count);
212        assert_eq!(unique_names.len(), entry_count);
213        assert!(unique_codes.iter().all(|code| *code != 0));
214        assert!(current_codes.windows(2).all(|pair| pair[0] < pair[1]));
215        assert!(retired_codes.windows(2).all(|pair| pair[0] < pair[1]));
216    }
217
218    #[test]
219    fn lookup_distinguishes_current_retired_and_unknown_codes() {
220        static RETIRED: &[RetiredDiagnosticEntry] =
221            &[RetiredDiagnosticEntry::new(23, "FORMER_IDENTITY")];
222
223        let current = diagnostic_catalog().lookup(DiagnosticCode::from_raw(1));
224        assert!(
225            matches!(current, DiagnosticLookup::Current(entry) if entry.name == "ACCESS_UNAVAILABLE")
226        );
227
228        let fixture = DiagnosticCatalog::new(&[], RETIRED);
229        let retired = fixture.lookup(DiagnosticCode::from_raw(23));
230        assert!(
231            matches!(retired, DiagnosticLookup::Retired(entry) if entry.name == "FORMER_IDENTITY")
232        );
233
234        let unknown = fixture.lookup(DiagnosticCode::from_raw(65_000));
235        assert_eq!(
236            unknown,
237            DiagnosticLookup::Unknown(DiagnosticCode::from_raw(65_000))
238        );
239    }
240}