Skip to main content

arvalez_openapi/
diagnostic.rs

1use arvalez_ir::CoreIr;
2
3/// parsing the human-readable error string.
4#[derive(Debug, Clone)]
5pub struct OpenApiDiagnostic {
6    /// Machine-readable classification of the issue.
7    pub kind: DiagnosticKind,
8    /// JSON pointer into the document where the issue was detected.
9    pub pointer: Option<String>,
10    /// A snippet of the document at the `pointer` location.
11    pub source_preview: Option<String>,
12    /// Human-readable context when there is no pointer (e.g. `"parameter \`foo\`"`).
13    pub context: Option<String>,
14    /// Approximate 1-based source line for the node at `pointer`, when resolvable.
15    pub line: Option<usize>,
16}
17
18impl OpenApiDiagnostic {
19    pub fn from_pointer(
20        kind: DiagnosticKind,
21        pointer: impl Into<String>,
22        source_preview: Option<String>,
23        line: Option<usize>,
24    ) -> Self {
25        OpenApiDiagnostic {
26            kind,
27            pointer: Some(pointer.into()),
28            source_preview,
29            context: None,
30            line,
31        }
32    }
33
34    pub fn from_named_context(kind: DiagnosticKind, context: impl Into<String>) -> Self {
35        OpenApiDiagnostic {
36            kind,
37            pointer: None,
38            source_preview: None,
39            context: Some(context.into()),
40            line: None,
41        }
42    }
43
44    pub fn simple(kind: DiagnosticKind) -> Self {
45        OpenApiDiagnostic {
46            kind,
47            pointer: None,
48            source_preview: None,
49            context: None,
50            line: None,
51        }
52    }
53
54    /// Returns the human-readable note text for this diagnostic, if any.
55    pub fn note(&self) -> Option<&str> {
56        let note = self.kind.note_text();
57        if note.is_empty() { None } else { Some(note) }
58    }
59
60    /// Returns the corpus `(kind, feature)` classification for this diagnostic.
61    ///
62    /// This is the canonical mapping from [`DiagnosticKind`] to the string
63    /// identifiers used in corpus reports.  Keeping it here means adding a new
64    /// variant produces a compile error at the definition site.
65    pub fn classify(&self) -> (&'static str, String) {
66        match &self.kind {
67            DiagnosticKind::UnknownSchemaKeyword { keyword } => {
68                ("unsupported_schema_keyword", keyword.clone())
69            }
70            DiagnosticKind::UnsupportedSchemaKeyword { keyword } => (
71                Self::unsupported_kind_for_pointer(self.pointer.as_deref(), keyword),
72                keyword.clone(),
73            ),
74            DiagnosticKind::UnsupportedSchemaType { schema_type } => {
75                ("unsupported_schema_type", schema_type.clone())
76            }
77            DiagnosticKind::UnsupportedSchemaShape => (
78                "unsupported_schema_shape",
79                self.pointer
80                    .as_deref()
81                    .map(diagnostic_pointer_tail)
82                    .unwrap_or_else(|| "schema_shape".into()),
83            ),
84            DiagnosticKind::UnsupportedReference { reference } => {
85                ("unsupported_reference", categorize_reference(reference))
86            }
87            DiagnosticKind::AllOfRecursiveCycle { .. } => {
88                ("unsupported_all_of_merge", "recursive_cycle".into())
89            }
90            DiagnosticKind::RecursiveParameterCycle { .. } => (
91                "invalid_openapi_document",
92                "recursive_parameter_cycle".into(),
93            ),
94            DiagnosticKind::RecursiveRequestBodyCycle { .. } => (
95                "invalid_openapi_document",
96                "recursive_request_body_cycle".into(),
97            ),
98            DiagnosticKind::IncompatibleAllOfField { field } => {
99                ("unsupported_all_of_merge", field.clone())
100            }
101            DiagnosticKind::EmptyRequestBodyContent => {
102                ("unsupported_request_body_shape", "empty_content".into())
103            }
104            DiagnosticKind::EmptyParameterName { .. } => {
105                ("invalid_openapi_document", "empty_parameter_name".into())
106            }
107            DiagnosticKind::EmptyPropertyKey { .. } => {
108                ("invalid_openapi_document", "empty_property_key".into())
109            }
110            DiagnosticKind::ParameterMissingSchema { name } => (
111                "invalid_openapi_document",
112                normalize_diagnostic_feature(name),
113            ),
114            DiagnosticKind::UnsupportedParameterLocation { name } => (
115                "invalid_openapi_document",
116                normalize_diagnostic_feature(name),
117            ),
118            DiagnosticKind::MultipleRequestBodyDeclarations { .. } => (
119                "invalid_openapi_document",
120                "multiple_request_body_declarations".into(),
121            ),
122            DiagnosticKind::BodyParameterMissingSchema { name } => (
123                "invalid_openapi_document",
124                normalize_diagnostic_feature(name),
125            ),
126            DiagnosticKind::FormDataParameterMissingSchema { name } => (
127                "invalid_openapi_document",
128                normalize_diagnostic_feature(name),
129            ),
130        }
131    }
132
133    pub fn unsupported_kind_for_pointer(pointer: Option<&str>, feature: &str) -> &'static str {
134        if matches!(
135            feature,
136            "allOf" | "anyOf" | "oneOf" | "not" | "discriminator" | "const"
137        ) {
138            return "unsupported_schema_keyword";
139        }
140        match pointer {
141            Some(p)
142                if p.contains("/components/schemas/")
143                    || p.contains("/properties/")
144                    || p.ends_with("/schema")
145                    || p.contains("/items/") =>
146            {
147                "unsupported_schema_keyword"
148            }
149            Some(p) if p.contains("/parameters/") => "unsupported_parameter_feature",
150            Some(p) if p.contains("/responses/") => "unsupported_response_feature",
151            Some(p) if p.contains("/requestBody/") => "unsupported_request_body_feature",
152            _ => "unsupported_feature",
153        }
154    }
155}
156
157pub fn categorize_reference(reference: &str) -> String {
158    // External references (http/https/relative paths without #) are their own category.
159    if !reference.starts_with('#') {
160        return "external".into();
161    }
162    // Strip `#/` and split into path segments, ignoring percent-encoded path globs
163    // and numeric indices so only structural keywords remain.
164    let inner = reference.strip_prefix("#/").unwrap_or("");
165    let structural: Vec<&str> = inner
166        .split('/')
167        .filter(|s| !s.is_empty() && !s.chars().all(|c| c.is_ascii_digit()) && !s.contains('~') && !s.contains('%'))
168        .take(2)
169        .collect();
170    if structural.is_empty() {
171        return "unknown".into();
172    }
173    structural.join("_")
174}
175
176pub fn diagnostic_pointer_tail(pointer: &str) -> String {
177    pointer
178        .trim_end_matches('/')
179        .rsplit('/')
180        .next()
181        .map(normalize_diagnostic_feature)
182        .unwrap_or_else(|| "schema_shape".into())
183}
184
185pub fn normalize_diagnostic_feature(value: &str) -> String {
186    value
187        .replace("~1", "/")
188        .replace("~0", "~")
189        .replace('.', "_")
190        .replace('/', "_")
191        .replace('`', "")
192}
193
194impl std::fmt::Display for OpenApiDiagnostic {
195    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
196        let message = self.kind.message_text();
197        let note = self.kind.note_text();
198
199        if let Some(pointer) = &self.pointer {
200            write!(f, "OpenAPI document issue\nCaused by:\n  {message}")?;
201            write!(f, "\n  location: {pointer}")?;
202            if let Some(preview) = &self.source_preview {
203                write!(f, "\n  preview:")?;
204                for line in preview.lines() {
205                    write!(f, "\n    {line}")?;
206                }
207            }
208            if !note.is_empty() {
209                write!(f, "\n  note: {note}")?;
210            }
211        } else if let Some(context) = &self.context {
212            write!(f, "{context}: {message}")?;
213            if !note.is_empty() {
214                write!(f, "\nnote: {note}")?;
215            }
216        } else {
217            write!(f, "{message}")?;
218            if !note.is_empty() {
219                write!(f, "\nnote: {note}")?;
220            }
221        }
222        Ok(())
223    }
224}
225
226impl std::error::Error for OpenApiDiagnostic {}
227
228/// Machine-readable classification of an [`OpenApiDiagnostic`].
229#[derive(Debug, Clone)]
230pub enum DiagnosticKind {
231    // ── Schema keyword issues ────────────────────────────────────────────────
232    /// An unrecognised keyword was found in a schema object.
233    UnknownSchemaKeyword { keyword: String },
234    /// A recognised but unsupported schema keyword was found.
235    UnsupportedSchemaKeyword { keyword: String },
236    /// A schema declared a type string that Arvalez cannot map.
237    UnsupportedSchemaType { schema_type: String },
238    /// A schema's overall shape could not be mapped to a type.
239    UnsupportedSchemaShape,
240    // ── Reference issues ─────────────────────────────────────────────────────
241    /// A `$ref` value points to a location Arvalez cannot resolve.
242    UnsupportedReference { reference: String },
243    /// A `$ref` chain inside `allOf` (or object view collection) is cyclic.
244    AllOfRecursiveCycle { reference: String },
245    /// A parameter `$ref` is cyclic.
246    RecursiveParameterCycle { reference: String },
247    /// A `requestBody` `$ref` is cyclic.
248    RecursiveRequestBodyCycle { reference: String },
249    // ── allOf merge issues ───────────────────────────────────────────────────
250    /// Two `allOf` members declare incompatible values for the same keyword.
251    IncompatibleAllOfField { field: String },
252    // ── Structural document issues ───────────────────────────────────────────
253    /// A `requestBody` object has an empty `content` map.
254    EmptyRequestBodyContent,
255    /// A parameter's `name` field is the empty string.
256    EmptyParameterName { counter: usize },
257    /// An object schema property key is the empty string.
258    EmptyPropertyKey { counter: usize },
259    /// A non-body parameter has neither a `schema` nor a `type`.
260    ParameterMissingSchema { name: String },
261    /// A parameter uses a location (`in`) value Arvalez cannot handle.
262    UnsupportedParameterLocation { name: String },
263    /// An operation has multiple conflicting request-body sources.
264    MultipleRequestBodyDeclarations { note: String },
265    /// A Swagger 2 `in: body` parameter has no `schema`.
266    BodyParameterMissingSchema { name: String },
267    /// A Swagger 2 `in: formData` parameter has no schema/type.
268    FormDataParameterMissingSchema { name: String },
269}
270
271impl DiagnosticKind {
272    fn message_text(&self) -> String {
273        match self {
274            Self::UnknownSchemaKeyword { keyword } => format!("unknown schema keyword `{keyword}`"),
275            Self::UnsupportedSchemaKeyword { keyword } => {
276                format!("`{keyword}` is not supported yet")
277            }
278            Self::UnsupportedSchemaType { schema_type } => {
279                format!("unsupported schema type `{schema_type}`")
280            }
281            Self::UnsupportedSchemaShape => "schema shape is not supported yet".into(),
282            Self::UnsupportedReference { reference } => {
283                format!("unsupported reference `{reference}`")
284            }
285            Self::AllOfRecursiveCycle { reference } => {
286                format!("`allOf` contains a recursive reference cycle involving `{reference}`")
287            }
288            Self::RecursiveParameterCycle { reference } => {
289                format!("parameter reference contains a recursive cycle involving `{reference}`")
290            }
291            Self::RecursiveRequestBodyCycle { reference } => {
292                format!("request body reference contains a recursive cycle involving `{reference}`")
293            }
294            Self::IncompatibleAllOfField { field } => {
295                format!("`allOf` contains incompatible `{field}` declarations")
296            }
297            Self::EmptyRequestBodyContent => "request body has no content entries".into(),
298            Self::EmptyParameterName { counter } => {
299                format!("parameter #{counter} has an empty name")
300            }
301            Self::EmptyPropertyKey { counter } => format!("property #{counter} has an empty name"),
302            Self::ParameterMissingSchema { .. } => "parameter has no schema or type".into(),
303            Self::UnsupportedParameterLocation { .. } => "unsupported parameter location".into(),
304            Self::MultipleRequestBodyDeclarations { .. } => {
305                "multiple request body declarations are not supported".into()
306            }
307            Self::BodyParameterMissingSchema { .. } => "body parameter has no schema".into(),
308            Self::FormDataParameterMissingSchema { .. } => {
309                "formData parameter has no schema or type".into()
310            }
311        }
312    }
313
314    fn note_text(&self) -> &str {
315        match self {
316            Self::UnknownSchemaKeyword { .. }
317            | Self::UnsupportedSchemaKeyword { .. }
318            | Self::UnsupportedSchemaType { .. }
319            | Self::UnsupportedSchemaShape
320            | Self::AllOfRecursiveCycle { .. }
321            | Self::IncompatibleAllOfField { .. }
322            | Self::EmptyParameterName { .. }
323            | Self::EmptyPropertyKey { .. } => {
324                "Use `--ignore-unhandled` to turn this into a warning while keeping generation going."
325            }
326            Self::EmptyRequestBodyContent => {
327                "Arvalez defaulted this request body to `application/octet-stream` with an untyped payload."
328            }
329            Self::ParameterMissingSchema { .. } => {
330                "Arvalez currently expects non-body parameters to declare either `schema` (OpenAPI 3) or `type` (Swagger 2)."
331            }
332            Self::UnsupportedParameterLocation { .. } => {
333                "Arvalez currently supports path, query, header, and cookie parameters here."
334            }
335            Self::MultipleRequestBodyDeclarations { note } => note.as_str(),
336            Self::BodyParameterMissingSchema { .. } => {
337                "Swagger 2 `in: body` parameters must declare a `schema`."
338            }
339            Self::FormDataParameterMissingSchema { .. } => {
340                "Swagger 2 `in: formData` parameters must declare either a `type` or a `schema`."
341            }
342            Self::RecursiveParameterCycle { .. } => {
343                "Arvalez only supports acyclic parameter references."
344            }
345            Self::RecursiveRequestBodyCycle { .. } => {
346                "Arvalez only supports acyclic local `requestBody` references."
347            }
348            Self::UnsupportedReference { .. } => "",
349        }
350    }
351}
352
353#[derive(Debug, Clone)]
354pub struct OpenApiLoadResult {
355    pub ir: CoreIr,
356    pub warnings: Vec<OpenApiDiagnostic>,
357}