Skip to main content

powerio_pkg/
diagnostics.rs

1//! Structured diagnostics.
2//!
3//! A free-form `Vec<String>` warning is useful for a human but opaque to CI, an
4//! agent, or a downstream solver. Every finding a frontend, lowering pass, or
5//! backend records carries a stable [`DiagnosticCode`], a [`DiagnosticSeverity`],
6//! the [`DiagnosticStage`] it came from, a human message, and (where known) the
7//! element path and [`SourceRef`] it refers to. Human-readable warnings should
8//! be rendered from these, not the other way around.
9
10use serde::{Deserialize, Serialize};
11
12use crate::provenance::SourceRef;
13
14/// A stable, dotted diagnostic code, e.g. `EMIT.PSSE.DROP_ANGLE_LIMITS`.
15///
16/// The leading segment is the namespace and names the stage family:
17/// `PARSE`, `READ`, `IR`, `VALIDATE`, `FIDELITY`, `LOWER`, `EMIT`, `BINDING`,
18/// `PARTNER`, `PERF`. The conventional shape is `NAMESPACE.SOURCE_OR_TARGET.SPECIFIC`.
19#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
20#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
21#[serde(transparent)]
22pub struct DiagnosticCode(pub String);
23
24impl DiagnosticCode {
25    pub fn new(code: impl Into<String>) -> Self {
26        Self(code.into())
27    }
28
29    /// The leading dotted segment (the namespace), e.g. `EMIT` for
30    /// `EMIT.PSSE.DROP_ANGLE_LIMITS`.
31    pub fn namespace(&self) -> &str {
32        self.0.split('.').next().unwrap_or("")
33    }
34
35    pub fn as_str(&self) -> &str {
36        &self.0
37    }
38}
39
40impl std::fmt::Display for DiagnosticCode {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        f.write_str(&self.0)
43    }
44}
45
46impl From<&str> for DiagnosticCode {
47    fn from(s: &str) -> Self {
48        Self(s.to_owned())
49    }
50}
51
52impl From<String> for DiagnosticCode {
53    fn from(s: String) -> Self {
54        Self(s)
55    }
56}
57
58/// Severity, ordered worst-last so [`Ord`] gives the dominant severity of a set.
59#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
60#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
61#[serde(rename_all = "snake_case")]
62pub enum DiagnosticSeverity {
63    /// Useful in development; normally hidden.
64    Debug,
65    /// A provenance or normalization event worth recording.
66    Info,
67    /// Usable, but semantics were defaulted, approximated, lost, or the target
68    /// is incomplete.
69    Warning,
70    /// The package exists but the model is not valid for the intended use
71    /// without repair.
72    Error,
73    /// The package could not be produced.
74    Fatal,
75}
76
77/// The compiler stage that emitted a diagnostic.
78#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
79#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
80#[serde(rename_all = "snake_case")]
81#[non_exhaustive]
82pub enum DiagnosticStage {
83    Parse,
84    Read,
85    Canonicalize,
86    Validate,
87    Lower,
88    Emit,
89    Bind,
90    Partner,
91}
92
93/// One structured finding.
94#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
95#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
96pub struct StructuredDiagnostic {
97    pub code: DiagnosticCode,
98    pub severity: DiagnosticSeverity,
99    pub stage: DiagnosticStage,
100    pub message: String,
101    /// JSON pointer (or best-effort locator) of the element the finding is about.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub element_path: Option<String>,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub source_ref: Option<SourceRef>,
106    /// Code-specific structured payload, e.g. `{"dropped_fields": ["angmin"]}`.
107    #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
108    pub details: serde_json::Map<String, serde_json::Value>,
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub suggested_action: Option<String>,
111    /// Workflows for which this finding is safe to ignore, e.g.
112    /// `["power_flow", "opf"]`. Empty means "no such assurance".
113    #[serde(default, skip_serializing_if = "Vec::is_empty")]
114    pub safe_to_ignore: Vec<String>,
115}
116
117impl StructuredDiagnostic {
118    /// A minimal finding; fill the optional locators with the builder methods.
119    pub fn new(
120        code: impl Into<DiagnosticCode>,
121        severity: DiagnosticSeverity,
122        stage: DiagnosticStage,
123        message: impl Into<String>,
124    ) -> Self {
125        Self {
126            code: code.into(),
127            severity,
128            stage,
129            message: message.into(),
130            element_path: None,
131            source_ref: None,
132            details: serde_json::Map::new(),
133            suggested_action: None,
134            safe_to_ignore: Vec::new(),
135        }
136    }
137
138    #[must_use]
139    pub fn with_element_path(mut self, path: impl Into<String>) -> Self {
140        self.element_path = Some(path.into());
141        self
142    }
143
144    #[must_use]
145    pub fn with_source_ref(mut self, source_ref: SourceRef) -> Self {
146        self.source_ref = Some(source_ref);
147        self
148    }
149
150    #[must_use]
151    pub fn with_suggested_action(mut self, action: impl Into<String>) -> Self {
152        self.suggested_action = Some(action.into());
153        self
154    }
155}