Skip to main content

a3s_box_core/compose/
diagnostic.rs

1//! Structured diagnostics produced while parsing and normalizing Compose input.
2
3use std::error::Error;
4use std::fmt;
5
6use serde::{Deserialize, Serialize};
7
8/// Stable machine-readable category for a Compose diagnostic.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
10pub enum ComposeDiagnosticCode {
11    /// The source is not syntactically valid YAML or ACL.
12    #[serde(rename = "compose.syntax")]
13    Syntax,
14    /// Environment interpolation could not be completed.
15    #[serde(rename = "compose.interpolation")]
16    Interpolation,
17    /// The source contains a field outside Box's closed Compose schema.
18    #[serde(rename = "compose.unsupported_field")]
19    UnsupportedField,
20    /// A recognized field contains a value that Box cannot implement.
21    #[serde(rename = "compose.unsupported_value")]
22    UnsupportedValue,
23    /// A recognized field contains an invalid value.
24    #[serde(rename = "compose.invalid_value")]
25    InvalidValue,
26}
27
28impl ComposeDiagnosticCode {
29    /// Stable dotted representation suitable for logs and API responses.
30    pub const fn as_str(self) -> &'static str {
31        match self {
32            Self::Syntax => "compose.syntax",
33            Self::Interpolation => "compose.interpolation",
34            Self::UnsupportedField => "compose.unsupported_field",
35            Self::UnsupportedValue => "compose.unsupported_value",
36            Self::InvalidValue => "compose.invalid_value",
37        }
38    }
39}
40
41impl fmt::Display for ComposeDiagnosticCode {
42    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
43        formatter.write_str(self.as_str())
44    }
45}
46
47/// One actionable Compose parsing or normalization failure.
48///
49/// `path` uses JSON Pointer-style segments so callers do not need to parse a
50/// human error string. ACL blocks are projected onto the equivalent Compose
51/// object path (for example, `service "api"` becomes `/services/api`).
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct ComposeDiagnostic {
54    /// Stable machine-readable failure category.
55    pub code: ComposeDiagnosticCode,
56    /// JSON Pointer-style path to the rejected field or value.
57    pub path: String,
58    /// Human-readable explanation.
59    pub message: String,
60    /// One-based source line when the parser exposes it.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub line: Option<usize>,
63    /// One-based source column when the parser exposes it.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub column: Option<usize>,
66}
67
68impl ComposeDiagnostic {
69    pub(super) fn new(
70        code: ComposeDiagnosticCode,
71        path: impl Into<String>,
72        message: impl Into<String>,
73    ) -> Self {
74        Self {
75            code,
76            path: path.into(),
77            message: message.into(),
78            line: None,
79            column: None,
80        }
81    }
82
83    pub(super) fn with_location(mut self, line: usize, column: usize) -> Self {
84        self.line = Some(line);
85        self.column = Some(column);
86        self
87    }
88
89    pub(super) fn unsupported_field(path: impl Into<String>, field: &str) -> Self {
90        Self::new(
91            ComposeDiagnosticCode::UnsupportedField,
92            path,
93            format!("unsupported Compose field {field:?}"),
94        )
95    }
96}
97
98impl fmt::Display for ComposeDiagnostic {
99    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
100        write!(
101            formatter,
102            "{} at {}: {}",
103            self.code, self.path, self.message
104        )?;
105        if let (Some(line), Some(column)) = (self.line, self.column) {
106            write!(formatter, " (line {line}, column {column})")?;
107        }
108        Ok(())
109    }
110}
111
112/// One or more structured failures from a pure Compose normalization pass.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct ComposeNormalizationError {
115    diagnostics: Vec<ComposeDiagnostic>,
116}
117
118impl ComposeNormalizationError {
119    pub(super) fn new(mut diagnostics: Vec<ComposeDiagnostic>) -> Self {
120        diagnostics.sort_by(|left, right| {
121            left.path
122                .cmp(&right.path)
123                .then_with(|| left.code.cmp(&right.code))
124                .then_with(|| left.message.cmp(&right.message))
125        });
126        diagnostics.dedup();
127        Self { diagnostics }
128    }
129
130    pub(super) fn one(diagnostic: ComposeDiagnostic) -> Self {
131        Self::new(vec![diagnostic])
132    }
133
134    /// Structured diagnostics in deterministic path/code/message order.
135    pub fn diagnostics(&self) -> &[ComposeDiagnostic] {
136        &self.diagnostics
137    }
138
139    /// Consume the error and return its structured diagnostics.
140    pub fn into_diagnostics(self) -> Vec<ComposeDiagnostic> {
141        self.diagnostics
142    }
143}
144
145impl fmt::Display for ComposeNormalizationError {
146    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
147        match self.diagnostics.as_slice() {
148            [] => formatter.write_str("Compose normalization failed"),
149            [diagnostic] => diagnostic.fmt(formatter),
150            diagnostics => {
151                write!(
152                    formatter,
153                    "Compose normalization failed with {} diagnostics: ",
154                    diagnostics.len()
155                )?;
156                for (index, diagnostic) in diagnostics.iter().enumerate() {
157                    if index > 0 {
158                        formatter.write_str("; ")?;
159                    }
160                    diagnostic.fmt(formatter)?;
161                }
162                Ok(())
163            }
164        }
165    }
166}
167
168impl Error for ComposeNormalizationError {}
169
170pub(super) fn pointer_segment(value: &str) -> String {
171    value.replace('~', "~0").replace('/', "~1")
172}
173
174pub(super) fn child_path(parent: &str, child: &str) -> String {
175    let child = pointer_segment(child);
176    if parent == "/" {
177        format!("/{child}")
178    } else {
179        format!("{parent}/{child}")
180    }
181}