1use std::collections::{BTreeMap, BTreeSet, btree_map::Entry};
4
5use crate::{DiagnosticSeverity, DiagnosticStage, ErrorCategory, code_is_well_formed};
6
7#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum CodeStatus {
10 Active,
11 Retired {
14 since: &'static str,
15 },
16}
17
18#[derive(Clone, Copy, Debug, PartialEq)]
25pub struct DiagnosticInfo {
26 pub code: &'static str,
27 pub severity: DiagnosticSeverity,
29 pub category: Option<ErrorCategory>,
31 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 #[must_use]
66 pub fn namespace(&self) -> &'static str {
67 self.code.split('.').next().unwrap_or("")
68 }
69
70 #[must_use]
73 pub fn stage(&self) -> Option<DiagnosticStage> {
74 DiagnosticStage::from_namespace(self.namespace())
75 }
76}
77
78#[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 pub const ALL: &[&$crate::DiagnosticInfo] = &[$(&$name),*];
116 };
117}
118
119pub 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 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
156pub 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}