Skip to main content

powerio_diag/
registry.rs

1//! Registry entries: how a crate declares the codes it emits.
2
3use std::collections::{BTreeMap, BTreeSet, btree_map::Entry};
4
5use crate::{DiagnosticSeverity, DiagnosticStage, ErrorCategory, code_is_well_formed};
6
7/// Whether a code is still emitted.
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum CodeStatus {
10    Active,
11    /// No longer emitted. The entry stays so the identity is never reassigned
12    /// and a document carrying the code still reads.
13    Retired {
14        since: &'static str,
15    },
16}
17
18/// One registered code.
19///
20/// An emitting crate declares its codes as `DiagnosticInfo` constants and emits
21/// through [`crate::StructuredDiagnostic::of`], so a code literal is written in
22/// exactly one place and "every emitted code is registered" holds by
23/// construction. There is no stage field: the code carries it.
24#[derive(Clone, Copy, Debug, PartialEq)]
25pub struct DiagnosticInfo {
26    pub code: &'static str,
27    /// The default severity. A site may raise or lower it.
28    pub severity: DiagnosticSeverity,
29    /// The coarse projection, for codes that can be fatal.
30    pub category: Option<ErrorCategory>,
31    /// One line: what the finding means.
32    pub summary: &'static str,
33    pub status: CodeStatus,
34}
35
36impl DiagnosticInfo {
37    #[must_use]
38    pub const fn new(
39        code: &'static str,
40        severity: DiagnosticSeverity,
41        summary: &'static str,
42    ) -> Self {
43        Self {
44            code,
45            severity,
46            category: None,
47            summary,
48            status: CodeStatus::Active,
49        }
50    }
51
52    #[must_use]
53    pub const fn with_category(mut self, category: ErrorCategory) -> Self {
54        self.category = Some(category);
55        self
56    }
57
58    #[must_use]
59    pub const fn retired(mut self, since: &'static str) -> Self {
60        self.status = CodeStatus::Retired { since };
61        self
62    }
63
64    /// The namespace segment of the code.
65    #[must_use]
66    pub fn namespace(&self) -> &'static str {
67        self.code.split('.').next().unwrap_or("")
68    }
69
70    /// The stage the code names, or `None` when the namespace is outside the
71    /// ten. A registered code always decodes; the check below enforces it.
72    #[must_use]
73    pub fn stage(&self) -> Option<DiagnosticStage> {
74        DiagnosticStage::from_namespace(self.namespace())
75    }
76}
77
78/// Declare a crate's registry: one `DiagnosticInfo` constant per code, plus the
79/// `ALL` slice the gates run over.
80///
81/// Writing the slice by hand is how a registry drifts from the codes a crate
82/// actually declares, so the macro builds it from the same list.
83///
84/// ```
85/// powerio_diag::diagnostic_codes! {
86///     /// A field the target format has no record for.
87///     EMIT_PSSE_FIELD_DROPPED = "EMIT.PSSE.FIELD_DROPPED", Warning,
88///         "a field with no PSS/E record was dropped";
89///     REQUEST_FORMAT_UNKNOWN = "REQUEST.FORMAT.UNKNOWN", Fatal,
90///         "the named format is not one powerio reads", category = UnknownFormat;
91/// }
92/// assert_eq!(ALL.len(), 2);
93/// ```
94#[macro_export]
95macro_rules! diagnostic_codes {
96    ($(
97        $(#[$attr:meta])*
98        $name:ident = $code:literal, $severity:ident, $summary:literal
99        $(, category = $category:ident)?
100        $(, retired = $since:literal)? ;
101    )*) => {
102        $(
103            $(#[$attr])*
104            pub const $name: $crate::DiagnosticInfo = $crate::DiagnosticInfo::new(
105                $code,
106                $crate::DiagnosticSeverity::$severity,
107                $summary,
108            )
109            $(.with_category($crate::ErrorCategory::$category))?
110            $(.retired($since))?;
111        )*
112
113        /// Every code this registry declares, for the grammar and uniqueness
114        /// gates and for the generated reference.
115        pub const ALL: &[&$crate::DiagnosticInfo] = &[$(&$name),*];
116    };
117}
118
119/// Check a registry: every code matches the grammar, every namespace is one of
120/// the ten, and no code appears twice. Returns one message per problem, empty
121/// when the registry is sound.
122///
123/// A crate gates its own registry with this; the workspace gate runs it over
124/// every registry concatenated, which is where a code shared by two crates
125/// shows up.
126pub fn check_registry<'a, I>(entries: I) -> Vec<String>
127where
128    I: IntoIterator<Item = &'a DiagnosticInfo>,
129{
130    let mut problems = Vec::new();
131    let mut seen: BTreeSet<&'static str> = BTreeSet::new();
132    for entry in entries {
133        // A retired code is a historical identity, kept so it is never
134        // reassigned; some predate the grammar and cannot be made to satisfy
135        // it without reassigning them.
136        let retired = matches!(entry.status, CodeStatus::Retired { .. });
137        if !retired && !code_is_well_formed(entry.code) {
138            problems.push(format!("{}: does not match the code grammar", entry.code));
139        } else if !retired && entry.stage().is_none() {
140            problems.push(format!(
141                "{}: namespace {} is not one of the ten",
142                entry.code,
143                entry.namespace()
144            ));
145        }
146        if entry.summary.is_empty() {
147            problems.push(format!("{}: has no summary", entry.code));
148        }
149        if !seen.insert(entry.code) {
150            problems.push(format!("{}: registered twice", entry.code));
151        }
152    }
153    problems
154}
155
156/// Check that no two crates declare codes under the same scope, i.e. the same
157/// `NAMESPACE.SCOPE` prefix. Returns one message per shared scope.
158///
159/// A scope names one reader, one writer, or one pass, so two crates claiming it
160/// means one of them is emitting from the other's territory. The workspace gate
161/// runs this over every registry at once; a single crate cannot see it.
162///
163/// Retired entries are skipped: they name no live emitter, and a retired code
164/// whose scope moved to another crate is exactly what retirement records.
165pub fn check_scope_ownership(registries: &[(&str, &[&DiagnosticInfo])]) -> Vec<String> {
166    let mut owner: BTreeMap<(&str, &str), &str> = BTreeMap::new();
167    let mut problems = Vec::new();
168    for (crate_name, entries) in registries {
169        for entry in *entries {
170            if matches!(entry.status, CodeStatus::Retired { .. }) {
171                continue;
172            }
173            let mut segments = entry.code.split('.');
174            let (Some(namespace), Some(scope)) = (segments.next(), segments.next()) else {
175                continue;
176            };
177            match owner.entry((namespace, scope)) {
178                Entry::Vacant(slot) => {
179                    slot.insert(crate_name);
180                }
181                Entry::Occupied(slot) if *slot.get() != *crate_name => problems.push(format!(
182                    "{namespace}.{scope}: claimed by both {} and {crate_name}",
183                    slot.get()
184                )),
185                Entry::Occupied(_) => {}
186            }
187        }
188    }
189    problems
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    const GOOD: DiagnosticInfo = DiagnosticInfo::new(
197        "EMIT.PSSE.FIELD_DROPPED",
198        DiagnosticSeverity::Warning,
199        "a field with no PSS/E record was dropped",
200    );
201
202    #[test]
203    fn a_sound_registry_reports_nothing() {
204        let other = DiagnosticInfo::new(
205            "READ.DSS.INCLUDE_REFUSED",
206            DiagnosticSeverity::Error,
207            "an include escaping the case directory was refused",
208        )
209        .with_category(ErrorCategory::Io);
210        assert_eq!(check_registry([&GOOD, &other]), Vec::<String>::new());
211        assert_eq!(GOOD.stage(), Some(DiagnosticStage::Emit));
212        assert_eq!(GOOD.status, CodeStatus::Active);
213    }
214
215    #[test]
216    fn the_check_names_each_way_a_registry_goes_wrong() {
217        let malformed = DiagnosticInfo::new("emit.psse.dropped", DiagnosticSeverity::Info, "s");
218        let unknown_namespace =
219            DiagnosticInfo::new("FIDELITY.PSSE.DROPPED", DiagnosticSeverity::Info, "s");
220        let no_summary = DiagnosticInfo::new("EMIT.PSSE.DEFAULTED", DiagnosticSeverity::Info, "");
221        let problems = check_registry([&GOOD, &GOOD, &malformed, &unknown_namespace, &no_summary]);
222        assert_eq!(problems.len(), 4, "{problems:?}");
223        assert!(problems.iter().any(|p| p.contains("registered twice")));
224        assert!(problems.iter().any(|p| p.contains("code grammar")));
225        assert!(problems.iter().any(|p| p.contains("not one of the ten")));
226        assert!(problems.iter().any(|p| p.contains("no summary")));
227    }
228
229    #[test]
230    fn two_crates_cannot_claim_one_scope() {
231        const OTHER: DiagnosticInfo = DiagnosticInfo::new(
232            "EMIT.PSSE.DOWNGRADED",
233            DiagnosticSeverity::Warning,
234            "a newer revision was written into an older layout",
235        );
236        const ELSEWHERE: DiagnosticInfo = DiagnosticInfo::new(
237            "EMIT.BMOPF.TRANSFORMER_UNSUPPORTED",
238            DiagnosticSeverity::Warning,
239            "a transformer the BMOPF schema cannot state",
240        );
241        assert!(
242            check_scope_ownership(&[
243                ("powerio", &[&GOOD, &OTHER]),
244                ("powerio-dist", &[&ELSEWHERE])
245            ])
246            .is_empty()
247        );
248        let problems = check_scope_ownership(&[("powerio", &[&GOOD]), ("powerio-dist", &[&OTHER])]);
249        assert_eq!(problems.len(), 1);
250        assert!(problems[0].contains("EMIT.PSSE"), "{problems:?}");
251    }
252
253    #[test]
254    fn a_retired_entry_records_when_it_stopped_being_emitted() {
255        const RETIRED: DiagnosticInfo = DiagnosticInfo::new(
256            "READ.DIST.PARSE_WARNING",
257            DiagnosticSeverity::Warning,
258            "a distribution parse warning with no identity of its own",
259        )
260        .retired("0.9.0");
261        assert_eq!(RETIRED.status, CodeStatus::Retired { since: "0.9.0" });
262        assert!(check_registry([&RETIRED]).is_empty());
263    }
264}