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_contracts::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    pub fn current_entries(&self) -> impl ExactSizeIterator<Item = &DiagnosticEntry> {
125        self.current.iter()
126    }
127
128    /// Iterate over retired entries in numeric order.
129    pub fn retired_entries(&self) -> impl ExactSizeIterator<Item = &RetiredDiagnosticEntry> {
130        self.retired.iter()
131    }
132}
133
134static CATALOG: DiagnosticCatalog =
135    DiagnosticCatalog::new(generated::CURRENT_REASONS, generated::RETIRED_REASONS);
136
137/// Return the generated host catalogue.
138#[must_use]
139pub const fn diagnostic_catalog() -> &'static DiagnosticCatalog {
140    &CATALOG
141}
142
143/// Look up one lossless raw diagnostic identity.
144#[must_use]
145pub fn lookup_diagnostic(code: DiagnosticCode) -> DiagnosticLookup<'static> {
146    diagnostic_catalog().lookup(code)
147}
148
149/// Render one compact diagnostic with host-owned prose.
150#[must_use]
151pub fn render_diagnostic(code: DiagnosticCode) -> String {
152    match lookup_diagnostic(code) {
153        DiagnosticLookup::Current(entry) => {
154            let mut rendered = format!(
155                "{} {} (origin: {}): {}",
156                entry.code, entry.name, entry.origin, entry.summary
157            );
158            if let Some(guidance) = entry.guidance {
159                rendered.push(' ');
160                rendered.push_str(guidance);
161            }
162            rendered
163        }
164        DiagnosticLookup::Retired(entry) => {
165            format!("{} (retired diagnostic: {})", entry.code, entry.name)
166        }
167        DiagnosticLookup::Unknown(code) => format!("{code} (unknown diagnostic)"),
168    }
169}
170
171// -----------------------------------------------------------------------------
172// Tests
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn generated_catalogue_is_sorted_unique_and_complete() {
180        let catalog = diagnostic_catalog();
181        let current_codes = catalog
182            .current_entries()
183            .map(|entry| entry.code.raw())
184            .collect::<Vec<_>>();
185        let retired_codes = catalog
186            .retired_entries()
187            .map(|entry| entry.code.raw())
188            .collect::<Vec<_>>();
189        let current_names = catalog
190            .current_entries()
191            .map(|entry| entry.name)
192            .collect::<Vec<_>>();
193        let retired_names = catalog
194            .retired_entries()
195            .map(|entry| entry.name)
196            .collect::<Vec<_>>();
197        let entry_count = current_codes.len() + retired_codes.len();
198        let unique_codes = current_codes
199            .iter()
200            .chain(&retired_codes)
201            .copied()
202            .collect::<std::collections::BTreeSet<_>>();
203        let unique_names = current_names
204            .iter()
205            .chain(&retired_names)
206            .copied()
207            .collect::<std::collections::BTreeSet<_>>();
208
209        assert_eq!(unique_codes.len(), entry_count);
210        assert_eq!(unique_names.len(), entry_count);
211        assert!(unique_codes.iter().all(|code| *code != 0));
212        assert!(current_codes.windows(2).all(|pair| pair[0] < pair[1]));
213        assert!(retired_codes.windows(2).all(|pair| pair[0] < pair[1]));
214    }
215
216    #[test]
217    fn lookup_distinguishes_current_retired_and_unknown_codes() {
218        static RETIRED: &[RetiredDiagnosticEntry] =
219            &[RetiredDiagnosticEntry::new(23, "FORMER_IDENTITY")];
220
221        let current = diagnostic_catalog().lookup(DiagnosticCode::from_raw(1));
222        assert!(
223            matches!(current, DiagnosticLookup::Current(entry) if entry.name == "ACCESS_UNAVAILABLE")
224        );
225
226        let fixture = DiagnosticCatalog::new(&[], RETIRED);
227        let retired = fixture.lookup(DiagnosticCode::from_raw(23));
228        assert!(
229            matches!(retired, DiagnosticLookup::Retired(entry) if entry.name == "FORMER_IDENTITY")
230        );
231
232        let unknown = fixture.lookup(DiagnosticCode::from_raw(65_000));
233        assert_eq!(
234            unknown,
235            DiagnosticLookup::Unknown(DiagnosticCode::from_raw(65_000))
236        );
237    }
238}