Skip to main content

boxferry_engine/
diagnostic.rs

1//! Structured diagnostics with explicit sensitive fields.
2
3use std::{error::Error, fmt};
4
5/// Producer-neutral role of one source location attached to a native finding.
6#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7#[non_exhaustive]
8pub enum NativeFindingLabelKind {
9    /// Location primarily responsible for the finding.
10    Primary,
11    /// Related location that adds context.
12    Secondary,
13}
14
15/// One value-free source location attached to a native-format finding.
16///
17/// Numeric source identities are invocation-local. Adapters map them to caller-owned aliases;
18/// source paths and source contents never enter this DTO.
19#[derive(Clone, Debug, Eq, PartialEq)]
20#[non_exhaustive]
21pub struct NativeFindingLabel {
22    kind: NativeFindingLabelKind,
23    source_id: u32,
24    start: usize,
25    end: usize,
26    message: String,
27}
28
29impl NativeFindingLabel {
30    /// Creates a labelled half-open byte range.
31    #[must_use]
32    pub fn new(
33        kind: NativeFindingLabelKind,
34        source_id: u32,
35        start: usize,
36        end: usize,
37        message: impl Into<String>,
38    ) -> Self {
39        Self {
40            kind,
41            source_id,
42            start,
43            end,
44            message: message.into(),
45        }
46    }
47
48    /// Returns the location role.
49    #[must_use]
50    pub const fn kind(&self) -> NativeFindingLabelKind {
51        self.kind
52    }
53
54    /// Returns the invocation-local numeric source identity.
55    #[must_use]
56    pub const fn source_id(&self) -> u32 {
57        self.source_id
58    }
59
60    /// Returns the inclusive byte offset.
61    #[must_use]
62    pub const fn start(&self) -> usize {
63        self.start
64    }
65
66    /// Returns the exclusive byte offset.
67    #[must_use]
68    pub const fn end(&self) -> usize {
69        self.end
70    }
71
72    /// Returns the value-free location explanation.
73    #[must_use]
74    pub fn message(&self) -> &str {
75        &self.message
76    }
77}
78
79/// Error returned for an invalid machine-readable diagnostic code.
80#[derive(Clone, Debug, Eq, PartialEq)]
81pub struct InvalidDiagnosticCode;
82
83impl fmt::Display for InvalidDiagnosticCode {
84    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
85        formatter.write_str("diagnostic code must contain only uppercase ASCII letters and digits")
86    }
87}
88
89impl Error for InvalidDiagnosticCode {}
90
91/// Stable machine-readable diagnostic identifier.
92#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
93pub struct DiagnosticCode(String);
94
95impl DiagnosticCode {
96    /// Creates an uppercase ASCII alphanumeric code such as `BFE0001`.
97    ///
98    /// # Errors
99    ///
100    /// Returns [`InvalidDiagnosticCode`] for an empty or nonconforming code.
101    pub fn new(value: impl Into<String>) -> Result<Self, InvalidDiagnosticCode> {
102        let value = value.into();
103        if value.is_empty()
104            || !value
105                .bytes()
106                .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit())
107        {
108            return Err(InvalidDiagnosticCode);
109        }
110        Ok(Self(value))
111    }
112
113    /// Returns the code string.
114    #[must_use]
115    pub fn as_str(&self) -> &str {
116        &self.0
117    }
118}
119
120/// Diagnostic severity independent from presentation and process exit codes.
121#[derive(Clone, Copy, Debug, Eq, PartialEq)]
122#[non_exhaustive]
123pub enum Severity {
124    /// Conversion cannot continue safely.
125    Error,
126    /// Conversion can continue only under an explicit loss policy.
127    Warning,
128    /// Context that does not change conversion fidelity.
129    Note,
130}
131
132/// Plain or sensitive diagnostic field value.
133#[derive(Clone, Eq, PartialEq)]
134#[non_exhaustive]
135pub enum DiagnosticValue {
136    /// Non-sensitive text that presentation may show.
137    Plain(String),
138    /// Sensitive text that presentation and debug output must redact.
139    Sensitive(String),
140}
141
142impl DiagnosticValue {
143    /// Creates a non-sensitive value.
144    #[must_use]
145    pub fn plain(value: impl Into<String>) -> Self {
146        Self::Plain(value.into())
147    }
148
149    /// Creates a sensitive value.
150    #[must_use]
151    pub fn sensitive(value: impl Into<String>) -> Self {
152        Self::Sensitive(value.into())
153    }
154
155    /// Returns whether the value is sensitive.
156    #[must_use]
157    pub const fn is_sensitive(&self) -> bool {
158        matches!(self, Self::Sensitive(_))
159    }
160
161    /// Explicitly exposes the original field value.
162    #[must_use]
163    pub fn expose(&self) -> &str {
164        match self {
165            Self::Plain(value) | Self::Sensitive(value) => value,
166        }
167    }
168
169    /// Returns presentation-safe text.
170    #[must_use]
171    pub fn redacted(&self) -> &str {
172        match self {
173            Self::Plain(value) => value,
174            Self::Sensitive(_) => "[REDACTED]",
175        }
176    }
177}
178
179impl fmt::Debug for DiagnosticValue {
180    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
181        formatter
182            .debug_tuple("DiagnosticValue")
183            .field(&self.redacted())
184            .finish()
185    }
186}
187
188/// Named structured context attached to a diagnostic.
189#[derive(Clone, Debug, Eq, PartialEq)]
190pub struct DiagnosticField {
191    name: String,
192    value: DiagnosticValue,
193}
194
195/// A native parser, model, runtime, or platform finding retained at an adapter boundary.
196///
197/// This envelope is intentionally independent of Compose, Quadlet, Docker, Podman, and
198/// Kubernetes types. Native libraries keep ownership of their codes; `BoxFerry` retains those codes
199/// as provenance while applying its own rule and loss policy in [`Diagnostic`].
200#[derive(Clone, Debug, Eq, PartialEq)]
201#[non_exhaustive]
202pub struct NativeFinding {
203    source_format: String,
204    producer: String,
205    producer_version: Option<String>,
206    code: String,
207    stage: String,
208    severity: Severity,
209    summary: String,
210    fields: Vec<DiagnosticField>,
211    labels: Vec<NativeFindingLabel>,
212    notes: Vec<String>,
213    help: Option<String>,
214}
215
216impl NativeFinding {
217    /// Creates a value-free native finding.
218    #[must_use]
219    pub fn new(
220        source_format: impl Into<String>,
221        producer: impl Into<String>,
222        code: impl Into<String>,
223        stage: impl Into<String>,
224        severity: Severity,
225        summary: impl Into<String>,
226    ) -> Self {
227        Self {
228            source_format: source_format.into(),
229            producer: producer.into(),
230            producer_version: None,
231            code: code.into(),
232            stage: stage.into(),
233            severity,
234            summary: summary.into(),
235            fields: Vec::new(),
236            labels: Vec::new(),
237            notes: Vec::new(),
238            help: None,
239        }
240    }
241
242    /// Attaches the exact native producer version when the adapter can prove it.
243    #[must_use]
244    pub fn with_producer_version(mut self, version: impl Into<String>) -> Self {
245        self.producer_version = Some(version.into());
246        self
247    }
248
249    /// Appends protected structured native context.
250    #[must_use]
251    pub fn with_field(mut self, field: DiagnosticField) -> Self {
252        self.fields.push(field);
253        self
254    }
255
256    /// Appends a value-free labelled location.
257    #[must_use]
258    pub fn with_label(mut self, label: NativeFindingLabel) -> Self {
259        self.labels.push(label);
260        self
261    }
262
263    /// Appends value-free native context.
264    #[must_use]
265    pub fn with_note(mut self, note: impl Into<String>) -> Self {
266        self.notes.push(note.into());
267        self
268    }
269
270    /// Attaches native remediation distinct from the `BoxFerry` rule help.
271    #[must_use]
272    pub fn with_help(mut self, help: impl Into<String>) -> Self {
273        self.help = Some(help.into());
274        self
275    }
276
277    /// Returns the source format, such as `compose` or `quadlet`.
278    #[must_use]
279    pub fn source_format(&self) -> &str {
280        &self.source_format
281    }
282
283    /// Returns the native producer, such as `compose-lens`.
284    #[must_use]
285    pub fn producer(&self) -> &str {
286        &self.producer
287    }
288
289    /// Returns the exact producer version when recorded.
290    #[must_use]
291    pub fn producer_version(&self) -> Option<&str> {
292        self.producer_version.as_deref()
293    }
294
295    /// Returns the producer-owned stable code.
296    #[must_use]
297    pub fn code(&self) -> &str {
298        &self.code
299    }
300
301    /// Returns the native processing stage.
302    #[must_use]
303    pub fn stage(&self) -> &str {
304        &self.stage
305    }
306
307    /// Returns the native severity.
308    #[must_use]
309    pub const fn severity(&self) -> Severity {
310        self.severity
311    }
312
313    /// Returns the value-free native summary.
314    #[must_use]
315    pub fn summary(&self) -> &str {
316        &self.summary
317    }
318
319    /// Returns protected structured native context.
320    #[must_use]
321    pub fn fields(&self) -> &[DiagnosticField] {
322        &self.fields
323    }
324
325    /// Returns labelled native locations in producer order.
326    #[must_use]
327    pub fn labels(&self) -> &[NativeFindingLabel] {
328        &self.labels
329    }
330
331    /// Returns value-free producer notes.
332    #[must_use]
333    pub fn notes(&self) -> &[String] {
334        &self.notes
335    }
336
337    /// Returns producer remediation when recorded.
338    #[must_use]
339    pub fn help(&self) -> Option<&str> {
340        self.help.as_deref()
341    }
342}
343
344impl DiagnosticField {
345    /// Creates a named field.
346    #[must_use]
347    pub fn new(name: impl Into<String>, value: DiagnosticValue) -> Self {
348        Self {
349            name: name.into(),
350            value,
351        }
352    }
353
354    /// Returns the field name.
355    #[must_use]
356    pub fn name(&self) -> &str {
357        &self.name
358    }
359
360    /// Returns the protected field value.
361    #[must_use]
362    pub const fn value(&self) -> &DiagnosticValue {
363        &self.value
364    }
365}
366
367/// Structured conversion diagnostic.
368#[derive(Clone, Debug, Eq, PartialEq)]
369pub struct Diagnostic {
370    code: DiagnosticCode,
371    severity: Severity,
372    summary: String,
373    fields: Vec<DiagnosticField>,
374    native_finding: Option<NativeFinding>,
375}
376
377impl Diagnostic {
378    /// Creates a value-free summary with no fields.
379    #[must_use]
380    pub fn new(code: DiagnosticCode, severity: Severity, summary: impl Into<String>) -> Self {
381        Self {
382            code,
383            severity,
384            summary: summary.into(),
385            fields: Vec::new(),
386            native_finding: None,
387        }
388    }
389
390    /// Appends structured context in presentation order.
391    #[must_use]
392    pub fn with_field(mut self, field: DiagnosticField) -> Self {
393        self.fields.push(field);
394        self
395    }
396
397    /// Attaches the native finding that caused this `BoxFerry` rule occurrence.
398    #[must_use]
399    pub fn with_native_finding(mut self, finding: NativeFinding) -> Self {
400        self.native_finding = Some(finding);
401        self
402    }
403
404    /// Returns the stable code.
405    #[must_use]
406    pub const fn code(&self) -> &DiagnosticCode {
407        &self.code
408    }
409
410    /// Returns the severity.
411    #[must_use]
412    pub const fn severity(&self) -> Severity {
413        self.severity
414    }
415
416    /// Returns the human-readable, value-free summary.
417    #[must_use]
418    pub fn summary(&self) -> &str {
419        &self.summary
420    }
421
422    /// Returns structured fields in presentation order.
423    #[must_use]
424    pub fn fields(&self) -> &[DiagnosticField] {
425        &self.fields
426    }
427
428    /// Returns the native finding retained by the source adapter.
429    #[must_use]
430    pub const fn native_finding(&self) -> Option<&NativeFinding> {
431        self.native_finding.as_ref()
432    }
433}
434
435impl fmt::Display for Diagnostic {
436    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
437        write!(formatter, "{}: {}", self.code.as_str(), self.summary)?;
438        for field in &self.fields {
439            write!(formatter, " {}={}", field.name(), field.value().redacted())?;
440        }
441        Ok(())
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::{
448        Diagnostic, DiagnosticCode, DiagnosticField, DiagnosticValue, NativeFinding, NativeFindingLabel,
449        NativeFindingLabelKind, Severity,
450    };
451
452    #[test]
453    fn sensitive_fields_are_redacted_from_debug_and_display() -> Result<(), String> {
454        let finding = NativeFinding::new(
455            "compose",
456            "compose-lens",
457            "compose.example",
458            "model",
459            Severity::Warning,
460            "native value needs review",
461        )
462        .with_field(DiagnosticField::new(
463            "native_value",
464            DiagnosticValue::sensitive("never-print-native-this"),
465        ))
466        .with_label(NativeFindingLabel::new(
467            NativeFindingLabelKind::Primary,
468            1,
469            4,
470            8,
471            "value is here",
472        ));
473        let diagnostic = Diagnostic::new(code("BFE0001")?, Severity::Warning, "value was adjusted")
474            .with_field(DiagnosticField::new(
475                "value",
476                DiagnosticValue::sensitive("never-print-this"),
477            ))
478            .with_native_finding(finding);
479        for rendered in [format!("{diagnostic:?}"), diagnostic.to_string()] {
480            assert!(!rendered.contains("never-print-this"));
481            assert!(!rendered.contains("never-print-native-this"));
482            assert!(rendered.contains("[REDACTED]"));
483        }
484        let native = diagnostic.native_finding().ok_or("missing native finding")?;
485        assert_eq!(native.producer(), "compose-lens");
486        assert_eq!(native.labels()[0].source_id(), 1);
487        Ok(())
488    }
489
490    fn code(value: &str) -> Result<DiagnosticCode, String> {
491        DiagnosticCode::new(value).map_err(|error| error.to_string())
492    }
493}