Skip to main content

powerio_core/
diagnostic.rs

1use std::collections::{BTreeMap, BTreeSet, btree_map::Entry};
2use std::fmt;
3
4use serde_json::{Map, Value};
5
6use crate::Error;
7use crate::records::{DiagnosticId, SourceSpan};
8use crate::validation::{MAX_DIAGNOSTIC_CODE_BYTES, sanitize_message, valid_nonempty_text};
9
10fn record_too_large(what: &str, limit: usize) -> Error {
11    Error::new(
12        &crate::codes::REQUEST_RECORD_TOO_LARGE,
13        format!("a diagnostic carries more than {limit} {what}"),
14    )
15}
16
17fn check_details(details: &Map<String, Value>) -> Result<(), Error> {
18    if details.len() > crate::validation::MAX_DIAGNOSTIC_DETAIL_KEYS {
19        return Err(record_too_large(
20            "detail keys",
21            crate::validation::MAX_DIAGNOSTIC_DETAIL_KEYS,
22        ));
23    }
24    if let Some((key, _)) = details
25        .iter()
26        .find(|(key, _)| !crate::validation::valid_nonempty_text(key))
27    {
28        return Err(Error::new(
29            &crate::codes::REQUEST_RECORD_INVALID_IDENTIFIER,
30            format!(
31                "diagnostic detail key `{}` is empty, contains NUL, or exceeds its bound",
32                bounded_identifier(key)
33            ),
34        ));
35    }
36    Ok(())
37}
38
39/// Severity of one user facing finding.
40///
41/// Declared least to most severe so the derived `Ord` gives the dominant
42/// severity of a set and a `>= Error` filter keeps meaning.
43#[derive(
44    Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
45)]
46#[serde(rename_all = "snake_case")]
47pub enum DiagnosticSeverity {
48    /// Adds context to another diagnostic.
49    Note,
50    /// Reports useful information about a successful operation.
51    Remark,
52    /// Reports usable data whose semantics were defaulted, approximated, or lost.
53    Warning,
54    /// Reports invalid data or the finding that ended an operation.
55    Error,
56}
57
58impl DiagnosticSeverity {
59    /// The stable stored and binding spelling.
60    #[must_use]
61    pub const fn as_str(self) -> &'static str {
62        match self {
63            Self::Note => "note",
64            Self::Remark => "remark",
65            Self::Warning => "warning",
66            Self::Error => "error",
67        }
68    }
69}
70
71/// Coarse projection of an operation failure for bindings and exit statuses.
72#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
73pub enum ErrorCategory {
74    Io,
75    Request,
76    Parse,
77    Data,
78    Output,
79}
80
81impl ErrorCategory {
82    pub const ALL: [Self; 5] = [
83        Self::Io,
84        Self::Request,
85        Self::Parse,
86        Self::Data,
87        Self::Output,
88    ];
89
90    pub const TOKENS: [&'static str; 5] = ["io", "request", "parse", "data", "output"];
91
92    #[must_use]
93    pub const fn as_str(self) -> &'static str {
94        match self {
95            Self::Io => "io",
96            Self::Request => "request",
97            Self::Parse => "parse",
98            Self::Data => "data",
99            Self::Output => "output",
100        }
101    }
102}
103
104/// Pipeline stage encoded by the first segment of a diagnostic code.
105#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
106#[non_exhaustive]
107pub enum DiagnosticStage {
108    Parse,
109    Read,
110    Canonicalize,
111    Validate,
112    Transform,
113    Build,
114    Emit,
115    Bind,
116    Partner,
117    Request,
118}
119
120impl DiagnosticStage {
121    pub const ALL: &'static [Self] = &[
122        Self::Parse,
123        Self::Read,
124        Self::Canonicalize,
125        Self::Validate,
126        Self::Transform,
127        Self::Build,
128        Self::Emit,
129        Self::Bind,
130        Self::Partner,
131        Self::Request,
132    ];
133
134    pub const NAMESPACES: &'static [&'static str] = &[
135        "PARSE",
136        "READ",
137        "CANONICALIZE",
138        "VALIDATE",
139        "TRANSFORM",
140        "BUILD",
141        "EMIT",
142        "BIND",
143        "PARTNER",
144        "REQUEST",
145    ];
146
147    #[must_use]
148    pub const fn namespace(self) -> &'static str {
149        match self {
150            Self::Parse => "PARSE",
151            Self::Read => "READ",
152            Self::Canonicalize => "CANONICALIZE",
153            Self::Validate => "VALIDATE",
154            Self::Transform => "TRANSFORM",
155            Self::Build => "BUILD",
156            Self::Emit => "EMIT",
157            Self::Bind => "BIND",
158            Self::Partner => "PARTNER",
159            Self::Request => "REQUEST",
160        }
161    }
162
163    #[must_use]
164    pub fn from_namespace(namespace: &str) -> Option<Self> {
165        Self::ALL
166            .iter()
167            .copied()
168            .find(|stage| stage.namespace() == namespace)
169    }
170}
171
172/// Stable dotted identity of one diagnostic.
173#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
174pub struct DiagnosticCode(Box<str>);
175
176impl DiagnosticCode {
177    /// Validate a code read from an external producer.
178    pub fn new(code: impl Into<String>) -> Result<Self, crate::Error> {
179        let code = code.into();
180        if !code_is_well_formed(&code) || code.len() > MAX_DIAGNOSTIC_CODE_BYTES {
181            return Err(crate::Error::new(
182                &crate::codes::REQUEST_DIAGNOSTIC_INVALID_CODE,
183                format!("invalid diagnostic code `{}`", bounded_identifier(&code)),
184            ));
185        }
186        Ok(Self(code.into_boxed_str()))
187    }
188
189    #[must_use]
190    pub fn as_str(&self) -> &str {
191        &self.0
192    }
193
194    #[must_use]
195    pub fn namespace(&self) -> &str {
196        self.0.split('.').next().unwrap_or("")
197    }
198
199    #[must_use]
200    pub fn stage(&self) -> Option<DiagnosticStage> {
201        DiagnosticStage::from_namespace(self.namespace())
202    }
203}
204
205impl fmt::Display for DiagnosticCode {
206    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
207        formatter.write_str(&self.0)
208    }
209}
210
211/// Whether a diagnostic identity is still emitted.
212#[derive(Clone, Copy, Debug, PartialEq, Eq)]
213pub enum CodeStatus {
214    Active,
215    Retired { since: &'static str },
216}
217
218/// One entry in a crate's diagnostic registry.
219#[derive(Clone, Copy, Debug, PartialEq, Eq)]
220pub struct DiagnosticInfo {
221    pub code: &'static str,
222    pub severity: DiagnosticSeverity,
223    pub category: Option<ErrorCategory>,
224    pub summary: &'static str,
225    pub status: CodeStatus,
226}
227
228impl DiagnosticInfo {
229    #[must_use]
230    pub const fn new(
231        code: &'static str,
232        severity: DiagnosticSeverity,
233        summary: &'static str,
234    ) -> Self {
235        Self {
236            code,
237            severity,
238            category: None,
239            summary,
240            status: CodeStatus::Active,
241        }
242    }
243
244    #[must_use]
245    pub const fn with_category(mut self, category: ErrorCategory) -> Self {
246        self.category = Some(category);
247        self
248    }
249
250    #[must_use]
251    pub const fn retired(mut self, since: &'static str) -> Self {
252        self.status = CodeStatus::Retired { since };
253        self
254    }
255
256    #[must_use]
257    pub fn namespace(&self) -> &'static str {
258        self.code.split('.').next().unwrap_or("")
259    }
260
261    #[must_use]
262    pub fn stage(&self) -> Option<DiagnosticStage> {
263        DiagnosticStage::from_namespace(self.namespace())
264    }
265}
266
267#[derive(Clone, Debug)]
268enum DiagnosticIdentity {
269    Registered(&'static DiagnosticInfo),
270    External(DiagnosticCode),
271}
272
273/// One coded user facing finding.
274#[derive(Clone, Debug)]
275pub struct Diagnostic {
276    identity: DiagnosticIdentity,
277    id: Option<DiagnosticId>,
278    severity: DiagnosticSeverity,
279    message: String,
280    target: Option<String>,
281    spans: Vec<SourceSpan>,
282    related: Vec<DiagnosticId>,
283    details: Map<String, Value>,
284    suggested_action: Option<String>,
285}
286
287impl Diagnostic {
288    /// Create a finding carrying a code from an external producer.
289    #[must_use]
290    pub fn new(
291        code: DiagnosticCode,
292        severity: DiagnosticSeverity,
293        message: impl Into<String>,
294    ) -> Self {
295        Self {
296            identity: DiagnosticIdentity::External(code),
297            id: None,
298            severity,
299            message: sanitize_message(message),
300            target: None,
301            spans: Vec::new(),
302            related: Vec::new(),
303            details: Map::new(),
304            suggested_action: None,
305        }
306    }
307
308    /// Create a finding from its registered identity and default severity.
309    #[must_use]
310    pub fn of(info: &'static DiagnosticInfo, message: impl Into<String>) -> Self {
311        Self {
312            identity: DiagnosticIdentity::Registered(info),
313            id: None,
314            severity: info.severity,
315            message: sanitize_message(message),
316            target: None,
317            spans: Vec::new(),
318            related: Vec::new(),
319            details: Map::new(),
320            suggested_action: None,
321        }
322    }
323
324    #[must_use]
325    pub fn code(&self) -> &str {
326        match &self.identity {
327            DiagnosticIdentity::Registered(info) => info.code,
328            DiagnosticIdentity::External(code) => code.as_str(),
329        }
330    }
331
332    #[must_use]
333    pub fn registered_info(&self) -> Option<&'static DiagnosticInfo> {
334        match self.identity {
335            DiagnosticIdentity::Registered(info) => Some(info),
336            DiagnosticIdentity::External(_) => None,
337        }
338    }
339
340    #[must_use]
341    pub fn stage(&self) -> Option<DiagnosticStage> {
342        DiagnosticStage::from_namespace(self.code().split('.').next().unwrap_or(""))
343    }
344
345    #[must_use]
346    pub const fn id(&self) -> Option<&DiagnosticId> {
347        self.id.as_ref()
348    }
349
350    #[must_use]
351    pub const fn severity(&self) -> DiagnosticSeverity {
352        self.severity
353    }
354
355    #[must_use]
356    pub fn message(&self) -> &str {
357        &self.message
358    }
359
360    #[must_use]
361    pub fn target(&self) -> Option<&str> {
362        self.target.as_deref()
363    }
364
365    #[must_use]
366    pub fn spans(&self) -> &[SourceSpan] {
367        &self.spans
368    }
369
370    #[must_use]
371    pub fn related(&self) -> &[DiagnosticId] {
372        &self.related
373    }
374
375    #[must_use]
376    pub const fn details(&self) -> &Map<String, Value> {
377        &self.details
378    }
379
380    #[must_use]
381    pub fn with_id(mut self, id: DiagnosticId) -> Self {
382        self.id = Some(id);
383        self
384    }
385
386    #[must_use]
387    pub fn with_severity(mut self, severity: DiagnosticSeverity) -> Self {
388        self.severity = severity;
389        self
390    }
391
392    /// Name the element this finding is about.
393    ///
394    /// A locator is identity, so it is stored complete or refused: a truncated
395    /// RFC 6901 pointer names a different element, or none. An empty,
396    /// oversized, or NUL-bearing locator is a visible error, and the caller
397    /// decides whether to keep the finding without one.
398    pub fn with_target(mut self, target: impl Into<String>) -> Result<Self, Error> {
399        self.set_target(target)?;
400        Ok(self)
401    }
402
403    /// True when the target is a pointer the stored document can write as is.
404    #[must_use]
405    pub fn target_is_pointer(&self) -> bool {
406        self.target
407            .as_deref()
408            .is_some_and(crate::validation::valid_rfc6901_pointer)
409    }
410
411    pub fn with_span(mut self, span: SourceSpan) -> Result<Self, Error> {
412        if self.spans.len() >= crate::validation::MAX_DIAGNOSTIC_SPANS {
413            return Err(record_too_large(
414                "source spans",
415                crate::validation::MAX_DIAGNOSTIC_SPANS,
416            ));
417        }
418        self.spans.push(span);
419        Ok(self)
420    }
421
422    pub fn with_related(mut self, related: DiagnosticId) -> Result<Self, Error> {
423        if self.related.len() >= crate::validation::MAX_DIAGNOSTIC_RELATED {
424            return Err(record_too_large(
425                "related records",
426                crate::validation::MAX_DIAGNOSTIC_RELATED,
427            ));
428        }
429        self.related.push(related);
430        Ok(self)
431    }
432
433    /// Add one detail entry to a finding already built, under the same key and
434    /// count limits the decoder enforces.
435    pub fn insert_detail(&mut self, key: impl Into<String>, value: Value) -> Result<(), Error> {
436        let key = key.into();
437        if !crate::validation::valid_nonempty_text(&key) {
438            return Err(Error::new(
439                &crate::codes::REQUEST_RECORD_INVALID_IDENTIFIER,
440                "a diagnostic detail key must be nonempty and bounded",
441            ));
442        }
443        if !self.details.contains_key(&key)
444            && self.details.len() >= crate::validation::MAX_DIAGNOSTIC_DETAIL_KEYS
445        {
446            return Err(record_too_large(
447                "detail keys",
448                crate::validation::MAX_DIAGNOSTIC_DETAIL_KEYS,
449            ));
450        }
451        self.details.insert(key, value);
452        Ok(())
453    }
454
455    /// Drop the target of a finding already built: the operation that calls
456    /// this changed the value it pointed into, so the locator no longer
457    /// identifies anything. Code, message, severity, and spans stay.
458    pub fn clear_target(&mut self) {
459        self.target = None;
460    }
461
462    /// Replace the target of a finding already built. Same rule as
463    /// [`Diagnostic::with_target`]: the complete locator is stored, or the
464    /// call fails visibly.
465    pub fn set_target(&mut self, target: impl Into<String>) -> Result<(), Error> {
466        let target = target.into();
467        if !crate::validation::valid_diagnostic_target(&target) {
468            return Err(Error::new(
469                &crate::codes::REQUEST_RECORD_INVALID_IDENTIFIER,
470                "a diagnostic target must be a nonempty bounded locator",
471            ));
472        }
473        self.target = Some(target);
474        Ok(())
475    }
476
477    pub fn with_details(mut self, details: Map<String, Value>) -> Result<Self, Error> {
478        self.set_details(details)?;
479        Ok(self)
480    }
481
482    /// Replace the details of a finding already built, under the same key and
483    /// count limits the decoder enforces.
484    pub fn set_details(&mut self, details: Map<String, Value>) -> Result<(), Error> {
485        check_details(&details)?;
486        self.details = details;
487        Ok(())
488    }
489
490    /// What a user can do about this finding.
491    #[must_use]
492    pub fn suggested_action(&self) -> Option<&str> {
493        self.suggested_action.as_deref()
494    }
495
496    #[must_use]
497    pub fn with_suggested_action(mut self, action: impl Into<String>) -> Self {
498        self.suggested_action = Some(sanitize_message(action));
499        self
500    }
501}
502
503impl PartialEq for Diagnostic {
504    fn eq(&self, other: &Self) -> bool {
505        self.code() == other.code()
506            && self.id == other.id
507            && self.severity == other.severity
508            && self.message == other.message
509            && self.target == other.target
510            && self.spans == other.spans
511            && self.related == other.related
512            && self.details == other.details
513            && self.suggested_action == other.suggested_action
514    }
515}
516
517/// Check grammar, namespace, uniqueness, summaries, and fatal category data.
518pub fn check_registry<'a, I>(entries: I) -> Vec<String>
519where
520    I: IntoIterator<Item = &'a DiagnosticInfo>,
521{
522    let mut problems = Vec::new();
523    let mut seen = BTreeSet::new();
524    for entry in entries {
525        let retired = matches!(entry.status, CodeStatus::Retired { .. });
526        if !retired
527            && (!code_is_well_formed(entry.code) || entry.code.len() > MAX_DIAGNOSTIC_CODE_BYTES)
528        {
529            problems.push(format!("{}: does not match the code grammar", entry.code));
530        } else if !retired && entry.stage().is_none() {
531            problems.push(format!(
532                "{}: namespace {} is not one PowerIO emits",
533                entry.code,
534                entry.namespace()
535            ));
536        }
537        if !valid_nonempty_text(entry.summary) {
538            problems.push(format!("{}: has no bounded summary", entry.code));
539        }
540        // A category is the binding and exit status projection of a failure, so
541        // it belongs only to a code that can end an operation. An error
542        // severity diagnostic does not by itself mean the operation failed, so
543        // the reverse is not required.
544        if entry.category.is_some() && entry.severity != DiagnosticSeverity::Error {
545            problems.push(format!(
546                "{}: {} severity declares an error category",
547                entry.code,
548                entry.severity.as_str()
549            ));
550        }
551        // The REQUEST namespace states, by construction, that the caller
552        // asked for something the library refuses; a code registered there
553        // cannot declare any other category. Namespace/category consistency
554        // is checked only where it holds across the whole workspace today
555        // (survey before adding another namespace here).
556        if !retired
557            && entry.namespace() == "REQUEST"
558            && let Some(category) = entry.category
559            && category != ErrorCategory::Request
560        {
561            problems.push(format!(
562                "{}: REQUEST namespace declares category {category:?}, not Request",
563                entry.code
564            ));
565        }
566        if !seen.insert(entry.code) {
567            problems.push(format!("{}: registered twice", entry.code));
568        }
569    }
570    problems
571}
572
573/// Check that two crates do not claim the same `NAMESPACE.SCOPE` prefix.
574pub fn check_scope_ownership(registries: &[(&str, &[&DiagnosticInfo])]) -> Vec<String> {
575    let mut owners: BTreeMap<(&str, &str), &str> = BTreeMap::new();
576    let mut problems = Vec::new();
577    for (crate_name, entries) in registries {
578        for entry in *entries {
579            if matches!(entry.status, CodeStatus::Retired { .. }) {
580                continue;
581            }
582            let mut segments = entry.code.split('.');
583            let (Some(namespace), Some(scope)) = (segments.next(), segments.next()) else {
584                continue;
585            };
586            match owners.entry((namespace, scope)) {
587                Entry::Vacant(slot) => {
588                    slot.insert(crate_name);
589                }
590                Entry::Occupied(slot) if *slot.get() != *crate_name => problems.push(format!(
591                    "{namespace}.{scope}: claimed by both {} and {crate_name}",
592                    slot.get()
593                )),
594                Entry::Occupied(_) => {}
595            }
596        }
597    }
598    problems
599}
600
601/// Check `[A-Z][A-Z0-9_]*(\.[A-Z0-9_]+){2,}`.
602#[must_use]
603pub fn code_is_well_formed(code: &str) -> bool {
604    let mut segments = 0usize;
605    for (index, segment) in code.split('.').enumerate() {
606        segments += 1;
607        if segment.is_empty() {
608            return false;
609        }
610        if index == 0 && !segment.starts_with(|character: char| character.is_ascii_uppercase()) {
611            return false;
612        }
613        if !segment
614            .bytes()
615            .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_')
616        {
617            return false;
618        }
619    }
620    segments >= 3
621}
622
623#[must_use]
624pub fn render_diagnostic(diagnostic: &Diagnostic) -> String {
625    format!("{}: {}", diagnostic.code(), diagnostic.message())
626}
627
628#[must_use]
629pub fn render_diagnostics(diagnostics: &[Diagnostic]) -> Vec<String> {
630    diagnostics.iter().map(render_diagnostic).collect()
631}
632
633fn bounded_identifier(value: &str) -> String {
634    const LIMIT: usize = 160;
635    if value.len() <= LIMIT {
636        return value.to_owned();
637    }
638    let mut end = LIMIT;
639    while !value.is_char_boundary(end) {
640        end -= 1;
641    }
642    format!("{}…", &value[..end])
643}
644
645#[cfg(test)]
646mod tests {
647
648    #[test]
649    fn suggested_action_is_part_of_a_finding_identity() {
650        let base = Diagnostic::of(&crate::codes::VALIDATE_TIME_SERIES_SHAPE, "same message");
651        let advised = Diagnostic::of(&crate::codes::VALIDATE_TIME_SERIES_SHAPE, "same message")
652            .with_suggested_action("rebuild the series");
653        assert_ne!(base, advised, "advice changes what the record says to do");
654        assert_eq!(
655            advised,
656            Diagnostic::of(&crate::codes::VALIDATE_TIME_SERIES_SHAPE, "same message")
657                .with_suggested_action("rebuild the series")
658        );
659    }
660
661    #[test]
662    fn a_target_is_stored_complete_or_refused() {
663        let long = format!("/model/buses/{}", "a".repeat(4_000));
664        let kept = Diagnostic::of(&crate::codes::VALIDATE_TIME_SERIES_SHAPE, "long target")
665            .with_target(long.clone())
666            .unwrap();
667        assert_eq!(kept.target(), Some(long.as_str()));
668
669        // Past the bound the call fails visibly, and the record is unchanged:
670        // a shortened pointer would name a different element, and a silent
671        // drop would change the finding's identity behind the caller's back.
672        let oversize = "/".repeat(crate::validation::MAX_DIAGNOSTIC_TARGET_BYTES + 1);
673        let mut record = Diagnostic::of(&crate::codes::VALIDATE_TIME_SERIES_SHAPE, "oversize");
674        assert!(record.set_target(oversize).is_err());
675        assert!(record.set_target("").is_err());
676        assert!(record.set_target("with\0nul").is_err());
677        assert_eq!(record.target(), None);
678        assert!(
679            Diagnostic::of(&crate::codes::VALIDATE_TIME_SERIES_SHAPE, "oversize")
680                .with_target("/".repeat(crate::validation::MAX_DIAGNOSTIC_TARGET_BYTES + 1))
681                .is_err()
682        );
683    }
684
685    #[test]
686    fn builder_paths_enforce_the_same_count_limits_as_the_decoder() {
687        let mut record = Diagnostic::of(&crate::codes::VALIDATE_TIME_SERIES_SHAPE, "caps");
688        for index in 0..crate::validation::MAX_DIAGNOSTIC_RELATED {
689            record = record
690                .with_related(DiagnosticId::new(format!("d{index}")).unwrap())
691                .unwrap();
692        }
693        assert!(
694            record
695                .clone()
696                .with_related(DiagnosticId::new("one-too-many").unwrap())
697                .is_err()
698        );
699
700        for index in 0..crate::validation::MAX_DIAGNOSTIC_DETAIL_KEYS {
701            record
702                .insert_detail(format!("k{index}"), serde_json::Value::Null)
703                .unwrap();
704        }
705        assert!(
706            record
707                .insert_detail("one-too-many", serde_json::Value::Null)
708                .is_err()
709        );
710        // Replacing an existing key is not growth.
711        assert!(
712            record
713                .insert_detail("k0", serde_json::Value::Bool(true))
714                .is_ok()
715        );
716        assert!(
717            record
718                .insert_detail("bad\0key", serde_json::Value::Null)
719                .is_err()
720        );
721    }
722
723    #[test]
724    fn stored_findings_meet_the_limits_the_constructors_enforce() {
725        let mut document = serde_json::json!({
726            "code": "PARTNER.TEST.FINDING",
727            "severity": "warning",
728            "message": "line one\nline two",
729        });
730        let record: Diagnostic = serde_json::from_value(document.clone()).unwrap();
731        assert_eq!(record.message(), "line one line two");
732
733        document["related"] = serde_json::Value::Array(
734            (0..=crate::validation::MAX_DIAGNOSTIC_RELATED)
735                .map(|index| serde_json::Value::String(format!("d{index}")))
736                .collect(),
737        );
738        assert!(serde_json::from_value::<Diagnostic>(document.clone()).is_err());
739
740        document["related"] = serde_json::Value::Array(Vec::new());
741        document["target"] = serde_json::Value::String(String::new());
742        assert!(serde_json::from_value::<Diagnostic>(document).is_err());
743    }
744
745    #[test]
746    fn stored_detail_keys_meet_the_key_predicate_the_constructors_enforce() {
747        let decode = |details: &str| {
748            serde_json::from_str::<Diagnostic>(&format!(
749                r#"{{"code":"PARTNER.TEST.FINDING","severity":"warning","message":"m","details":{details}}}"#
750            ))
751        };
752        // An empty key and a NUL key are refused at decode, exactly as
753        // `insert_detail` refuses them.
754        assert!(decode(r#"{"":1}"#).is_err());
755        assert!(decode("{\"bad\\u0000key\":1}").is_err());
756
757        // The key count limit holds at the boundary.
758        let full: Vec<String> = (0..crate::validation::MAX_DIAGNOSTIC_DETAIL_KEYS)
759            .map(|index| format!(r#""k{index}":{index}"#))
760            .collect();
761        let at_limit = decode(&format!("{{{}}}", full.join(","))).unwrap();
762        assert_eq!(
763            at_limit.details().len(),
764            crate::validation::MAX_DIAGNOSTIC_DETAIL_KEYS
765        );
766        let over: Vec<String> = (0..=crate::validation::MAX_DIAGNOSTIC_DETAIL_KEYS)
767            .map(|index| format!(r#""k{index}":{index}"#))
768            .collect();
769        assert!(decode(&format!("{{{}}}", over.join(","))).is_err());
770
771        // Anything that decodes can be rebuilt through the constructors.
772        let rebuilt = Diagnostic::of(&crate::codes::REQUEST_RECORD_TOO_LARGE, "m")
773            .with_details(at_limit.details().clone());
774        assert!(rebuilt.is_ok());
775    }
776    use super::*;
777
778    #[test]
779    fn exact_severity_and_category_tokens_are_closed() {
780        assert_eq!(
781            ErrorCategory::ALL.map(ErrorCategory::as_str),
782            ["io", "request", "parse", "data", "output"]
783        );
784        let severities = [
785            DiagnosticSeverity::Error,
786            DiagnosticSeverity::Warning,
787            DiagnosticSeverity::Remark,
788            DiagnosticSeverity::Note,
789        ];
790        assert_eq!(severities.len(), 4);
791    }
792
793    #[test]
794    fn transform_namespace_is_exact() {
795        assert_eq!(DiagnosticStage::Transform.namespace(), "TRANSFORM");
796        assert_eq!(DiagnosticStage::from_namespace("EXTERNAL"), None);
797        assert_eq!(DiagnosticStage::ALL.len(), 10);
798    }
799
800    #[test]
801    fn code_grammar_rejects_malformed_and_oversized_input() {
802        for code in [
803            "TRANSFORM.DIST.UNKNOWN_BUS",
804            "READ.DSS.INCLUDE_REFUSED",
805            "EMIT.BMOPF.TRANSFORMER.TAP_COLLAPSED",
806        ] {
807            assert!(code_is_well_formed(code), "{code}");
808            assert!(DiagnosticCode::new(code).is_ok());
809        }
810        for code in ["", "READ.DSS", "read.dss.bad", "READ..BAD", "1READ.DSS.BAD"] {
811            assert!(DiagnosticCode::new(code).is_err(), "{code}");
812        }
813        let external_namespace = DiagnosticCode::new("EXTERNAL.DIST.UNKNOWN_BUS").unwrap();
814        assert_eq!(external_namespace.stage(), None);
815        let long = format!("READ.DSS.{}", "A".repeat(MAX_DIAGNOSTIC_CODE_BYTES));
816        assert!(DiagnosticCode::new(long).is_err());
817    }
818
819    #[test]
820    fn rendering_bounds_external_text_and_keeps_one_line() {
821        let diagnostic = Diagnostic::of(
822            &crate::codes::REQUEST_DIAGNOSTIC_INVALID_CODE,
823            format!("first\n{}", "x".repeat(20_000)),
824        );
825        let line = render_diagnostic(&diagnostic);
826        assert!(!line.contains(['\n', '\r']));
827        assert!(line.len() < 17_000);
828    }
829}
830
831// Serialization of a `Diagnostic` is the binding and response form: MCP
832// replies, the C ABI's JSON channel, and Python all render the same record.
833// It is not the `.pio.json` stored form, which has its own versioned DTO in
834// the facade. A record read back carries an external code, because a registry
835// entry is a compile time item of whichever crate declared it.
836mod wire {
837    use serde::de::DeserializeSeed;
838    use serde::{Deserialize, Deserializer, Serialize};
839    use serde_json::{Map, Value};
840
841    use super::{Diagnostic, DiagnosticCode, DiagnosticIdentity, DiagnosticSeverity};
842    use crate::bounded::{BoundedStr, TruncatedStr, bounded_json_map, bounded_vec};
843    use crate::validation::{
844        MAX_DIAGNOSTIC_CODE_BYTES, MAX_DIAGNOSTIC_DETAIL_KEYS, MAX_DIAGNOSTIC_MESSAGE_DECODE_BYTES,
845        MAX_DIAGNOSTIC_RELATED, MAX_DIAGNOSTIC_SPANS, MAX_DIAGNOSTIC_TARGET_BYTES,
846        MAX_IDENTIFIER_BYTES,
847    };
848    use crate::{DiagnosticId, SourceSpan};
849
850    // Each field applies its limit while it decodes, so a hostile document
851    // fails at the first excess element or byte instead of after an unbounded
852    // `Vec`, map, or `String` has been built.
853    #[derive(Serialize, Deserialize)]
854    pub(super) struct DiagnosticWire {
855        #[serde(deserialize_with = "de_code")]
856        code: String,
857        severity: DiagnosticSeverity,
858        #[serde(deserialize_with = "de_message")]
859        message: String,
860        #[serde(default, skip_serializing_if = "Option::is_none")]
861        id: Option<DiagnosticId>,
862        #[serde(
863            default,
864            skip_serializing_if = "Option::is_none",
865            deserialize_with = "de_target"
866        )]
867        target: Option<String>,
868        #[serde(
869            default,
870            skip_serializing_if = "Vec::is_empty",
871            deserialize_with = "de_spans"
872        )]
873        spans: Vec<SourceSpan>,
874        #[serde(
875            default,
876            skip_serializing_if = "Vec::is_empty",
877            deserialize_with = "de_related"
878        )]
879        related: Vec<DiagnosticId>,
880        #[serde(
881            default,
882            skip_serializing_if = "Map::is_empty",
883            deserialize_with = "de_details"
884        )]
885        details: Map<String, Value>,
886        #[serde(
887            default,
888            skip_serializing_if = "Option::is_none",
889            deserialize_with = "de_action"
890        )]
891        suggested_action: Option<String>,
892    }
893
894    fn de_code<'de, D: Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
895        BoundedStr {
896            what: "diagnostic code",
897            max_bytes: MAX_DIAGNOSTIC_CODE_BYTES,
898        }
899        .deserialize(deserializer)
900    }
901
902    fn de_message<'de, D: Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
903        deserializer.deserialize_str(TruncatedStr {
904            max_bytes: MAX_DIAGNOSTIC_MESSAGE_DECODE_BYTES,
905        })
906    }
907
908    fn de_target<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<String>, D::Error> {
909        BoundedStr {
910            what: "diagnostic target",
911            max_bytes: MAX_DIAGNOSTIC_TARGET_BYTES,
912        }
913        .deserialize(deserializer)
914        .map(Some)
915    }
916
917    fn de_action<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<String>, D::Error> {
918        deserializer
919            .deserialize_str(TruncatedStr {
920                max_bytes: MAX_DIAGNOSTIC_MESSAGE_DECODE_BYTES,
921            })
922            .map(Some)
923    }
924
925    fn de_spans<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<SourceSpan>, D::Error> {
926        bounded_vec(deserializer, "source spans", MAX_DIAGNOSTIC_SPANS)
927    }
928
929    fn de_related<'de, D: Deserializer<'de>>(
930        deserializer: D,
931    ) -> Result<Vec<DiagnosticId>, D::Error> {
932        bounded_vec(deserializer, "related records", MAX_DIAGNOSTIC_RELATED)
933    }
934
935    fn de_details<'de, D: Deserializer<'de>>(
936        deserializer: D,
937    ) -> Result<Map<String, Value>, D::Error> {
938        bounded_json_map(
939            deserializer,
940            "detail keys",
941            MAX_DIAGNOSTIC_DETAIL_KEYS,
942            MAX_IDENTIFIER_BYTES,
943            crate::validation::valid_nonempty_text,
944        )
945    }
946
947    impl From<&Diagnostic> for DiagnosticWire {
948        fn from(diagnostic: &Diagnostic) -> Self {
949            Self {
950                code: diagnostic.code().to_owned(),
951                severity: diagnostic.severity,
952                message: diagnostic.message.clone(),
953                id: diagnostic.id.clone(),
954                target: diagnostic.target.clone(),
955                spans: diagnostic.spans.clone(),
956                related: diagnostic.related.clone(),
957                details: diagnostic.details.clone(),
958                suggested_action: diagnostic.suggested_action.clone(),
959            }
960        }
961    }
962
963    impl TryFrom<DiagnosticWire> for Diagnostic {
964        type Error = crate::Error;
965
966        // Serialized input is untrusted. It goes through the same limits the
967        // constructors apply, so a document cannot introduce a record the API
968        // could not have built.
969        fn try_from(wire: DiagnosticWire) -> Result<Self, Self::Error> {
970            use crate::validation::{
971                MAX_DIAGNOSTIC_RELATED, MAX_DIAGNOSTIC_SPANS, sanitize_message,
972                valid_diagnostic_target,
973            };
974
975            let refuse = |what: &str, limit: usize| {
976                crate::Error::new(
977                    &crate::codes::REQUEST_RECORD_TOO_LARGE,
978                    format!("a stored diagnostic carries more than {limit} {what}"),
979                )
980            };
981            if wire.spans.len() > MAX_DIAGNOSTIC_SPANS {
982                return Err(refuse("source spans", MAX_DIAGNOSTIC_SPANS));
983            }
984            if wire.related.len() > MAX_DIAGNOSTIC_RELATED {
985                return Err(refuse("related records", MAX_DIAGNOSTIC_RELATED));
986            }
987            // The one details predicate the constructors apply, so the decode
988            // limit and the construction limit cannot drift apart.
989            super::check_details(&wire.details)?;
990            let target = match wire.target {
991                Some(target) if !valid_diagnostic_target(&target) => {
992                    return Err(crate::Error::new(
993                        &crate::codes::REQUEST_RECORD_INVALID_IDENTIFIER,
994                        "a stored diagnostic target is empty or oversized",
995                    ));
996                }
997                other => other,
998            };
999            Ok(Diagnostic {
1000                identity: DiagnosticIdentity::External(DiagnosticCode::new(wire.code)?),
1001                id: wire.id,
1002                severity: wire.severity,
1003                message: sanitize_message(wire.message),
1004                target,
1005                spans: wire.spans,
1006                related: wire.related,
1007                details: wire.details,
1008                suggested_action: wire.suggested_action.map(sanitize_message),
1009            })
1010        }
1011    }
1012}
1013
1014impl serde::Serialize for Diagnostic {
1015    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1016        wire::DiagnosticWire::from(self).serialize(serializer)
1017    }
1018}
1019
1020impl<'de> serde::Deserialize<'de> for Diagnostic {
1021    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1022        let wire = wire::DiagnosticWire::deserialize(deserializer)?;
1023        Self::try_from(wire).map_err(serde::de::Error::custom)
1024    }
1025}