Skip to main content

boxferry_engine/
diagnostic.rs

1//! Structured diagnostics with explicit sensitive fields.
2
3use std::{error::Error, fmt};
4
5/// Error returned for an invalid machine-readable diagnostic code.
6#[derive(Clone, Debug, Eq, PartialEq)]
7pub struct InvalidDiagnosticCode;
8
9impl fmt::Display for InvalidDiagnosticCode {
10    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
11        formatter.write_str("diagnostic code must contain only uppercase ASCII letters and digits")
12    }
13}
14
15impl Error for InvalidDiagnosticCode {}
16
17/// Stable machine-readable diagnostic identifier.
18#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
19pub struct DiagnosticCode(String);
20
21impl DiagnosticCode {
22    /// Creates an uppercase ASCII alphanumeric code such as `BFE0001`.
23    ///
24    /// # Errors
25    ///
26    /// Returns [`InvalidDiagnosticCode`] for an empty or nonconforming code.
27    pub fn new(value: impl Into<String>) -> Result<Self, InvalidDiagnosticCode> {
28        let value = value.into();
29        if value.is_empty()
30            || !value
31                .bytes()
32                .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit())
33        {
34            return Err(InvalidDiagnosticCode);
35        }
36        Ok(Self(value))
37    }
38
39    /// Returns the code string.
40    #[must_use]
41    pub fn as_str(&self) -> &str {
42        &self.0
43    }
44}
45
46/// Diagnostic severity independent from presentation and process exit codes.
47#[derive(Clone, Copy, Debug, Eq, PartialEq)]
48#[non_exhaustive]
49pub enum Severity {
50    /// Conversion cannot continue safely.
51    Error,
52    /// Conversion can continue only under an explicit loss policy.
53    Warning,
54    /// Context that does not change conversion fidelity.
55    Note,
56}
57
58/// Plain or sensitive diagnostic field value.
59#[derive(Clone, Eq, PartialEq)]
60#[non_exhaustive]
61pub enum DiagnosticValue {
62    /// Non-sensitive text that presentation may show.
63    Plain(String),
64    /// Sensitive text that presentation and debug output must redact.
65    Sensitive(String),
66}
67
68impl DiagnosticValue {
69    /// Creates a non-sensitive value.
70    #[must_use]
71    pub fn plain(value: impl Into<String>) -> Self {
72        Self::Plain(value.into())
73    }
74
75    /// Creates a sensitive value.
76    #[must_use]
77    pub fn sensitive(value: impl Into<String>) -> Self {
78        Self::Sensitive(value.into())
79    }
80
81    /// Returns whether the value is sensitive.
82    #[must_use]
83    pub const fn is_sensitive(&self) -> bool {
84        matches!(self, Self::Sensitive(_))
85    }
86
87    /// Explicitly exposes the original field value.
88    #[must_use]
89    pub fn expose(&self) -> &str {
90        match self {
91            Self::Plain(value) | Self::Sensitive(value) => value,
92        }
93    }
94
95    /// Returns presentation-safe text.
96    #[must_use]
97    pub fn redacted(&self) -> &str {
98        match self {
99            Self::Plain(value) => value,
100            Self::Sensitive(_) => "[REDACTED]",
101        }
102    }
103}
104
105impl fmt::Debug for DiagnosticValue {
106    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
107        formatter
108            .debug_tuple("DiagnosticValue")
109            .field(&self.redacted())
110            .finish()
111    }
112}
113
114/// Named structured context attached to a diagnostic.
115#[derive(Clone, Debug, Eq, PartialEq)]
116pub struct DiagnosticField {
117    name: String,
118    value: DiagnosticValue,
119}
120
121impl DiagnosticField {
122    /// Creates a named field.
123    #[must_use]
124    pub fn new(name: impl Into<String>, value: DiagnosticValue) -> Self {
125        Self {
126            name: name.into(),
127            value,
128        }
129    }
130
131    /// Returns the field name.
132    #[must_use]
133    pub fn name(&self) -> &str {
134        &self.name
135    }
136
137    /// Returns the protected field value.
138    #[must_use]
139    pub const fn value(&self) -> &DiagnosticValue {
140        &self.value
141    }
142}
143
144/// Structured conversion diagnostic.
145#[derive(Clone, Debug, Eq, PartialEq)]
146pub struct Diagnostic {
147    code: DiagnosticCode,
148    severity: Severity,
149    summary: String,
150    fields: Vec<DiagnosticField>,
151}
152
153impl Diagnostic {
154    /// Creates a value-free summary with no fields.
155    #[must_use]
156    pub fn new(code: DiagnosticCode, severity: Severity, summary: impl Into<String>) -> Self {
157        Self {
158            code,
159            severity,
160            summary: summary.into(),
161            fields: Vec::new(),
162        }
163    }
164
165    /// Appends structured context in presentation order.
166    #[must_use]
167    pub fn with_field(mut self, field: DiagnosticField) -> Self {
168        self.fields.push(field);
169        self
170    }
171
172    /// Returns the stable code.
173    #[must_use]
174    pub const fn code(&self) -> &DiagnosticCode {
175        &self.code
176    }
177
178    /// Returns the severity.
179    #[must_use]
180    pub const fn severity(&self) -> Severity {
181        self.severity
182    }
183
184    /// Returns the human-readable, value-free summary.
185    #[must_use]
186    pub fn summary(&self) -> &str {
187        &self.summary
188    }
189
190    /// Returns structured fields in presentation order.
191    #[must_use]
192    pub fn fields(&self) -> &[DiagnosticField] {
193        &self.fields
194    }
195}
196
197impl fmt::Display for Diagnostic {
198    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
199        write!(formatter, "{}: {}", self.code.as_str(), self.summary)?;
200        for field in &self.fields {
201            write!(formatter, " {}={}", field.name(), field.value().redacted())?;
202        }
203        Ok(())
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::{Diagnostic, DiagnosticCode, DiagnosticField, DiagnosticValue, Severity};
210
211    #[test]
212    fn sensitive_fields_are_redacted_from_debug_and_display() -> Result<(), String> {
213        let diagnostic = Diagnostic::new(code("BFE0001")?, Severity::Warning, "value was adjusted").with_field(
214            DiagnosticField::new("value", DiagnosticValue::sensitive("never-print-this")),
215        );
216        for rendered in [format!("{diagnostic:?}"), diagnostic.to_string()] {
217            assert!(!rendered.contains("never-print-this"));
218            assert!(rendered.contains("[REDACTED]"));
219        }
220        Ok(())
221    }
222
223    fn code(value: &str) -> Result<DiagnosticCode, String> {
224        DiagnosticCode::new(value).map_err(|error| error.to_string())
225    }
226}