Skip to main content

arvalez_openapi/
lib.rs

1use std::borrow::Cow;
2use std::{
3    collections::{BTreeMap, BTreeSet, HashMap},
4    fs,
5    path::Path,
6    sync::OnceLock,
7    time::Instant,
8};
9
10use anyhow::{Context, Result, anyhow, bail};
11use arvalez_ir::{
12    Attributes, CoreIr, Field, HttpMethod, Model, Operation, Parameter, ParameterLocation,
13    RequestBody, Response, SourceRef, TypeRef, validate_ir,
14};
15use indexmap::IndexMap;
16use serde::Deserialize;
17use serde_json::{Value, json};
18
19#[derive(Debug, Clone, Copy, Default)]
20pub struct LoadOpenApiOptions {
21    pub ignore_unhandled: bool,
22    pub emit_timings: bool,
23}
24
25/// Structured diagnostic emitted by the OpenAPI importer. Implements
26/// [`std::error::Error`] so it can be stored inside [`anyhow::Error`],
27/// enabling callers to downcast and inspect the structured data instead of
28/// parsing the human-readable error string.
29#[derive(Debug, Clone)]
30pub struct OpenApiDiagnostic {
31    /// Machine-readable classification of the issue.
32    pub kind: DiagnosticKind,
33    /// JSON pointer into the document where the issue was detected.
34    pub pointer: Option<String>,
35    /// A snippet of the document at the `pointer` location.
36    pub source_preview: Option<String>,
37    /// Human-readable context when there is no pointer (e.g. `"parameter \`foo\`"`).
38    pub context: Option<String>,
39    /// Approximate 1-based source line for the node at `pointer`, when resolvable.
40    pub line: Option<usize>,
41}
42
43impl OpenApiDiagnostic {
44    pub fn from_pointer(
45        kind: DiagnosticKind,
46        pointer: impl Into<String>,
47        source_preview: Option<String>,
48        line: Option<usize>,
49    ) -> Self {
50        OpenApiDiagnostic {
51            kind,
52            pointer: Some(pointer.into()),
53            source_preview,
54            context: None,
55            line,
56        }
57    }
58
59    pub fn from_named_context(kind: DiagnosticKind, context: impl Into<String>) -> Self {
60        OpenApiDiagnostic {
61            kind,
62            pointer: None,
63            source_preview: None,
64            context: Some(context.into()),
65            line: None,
66        }
67    }
68
69    pub fn simple(kind: DiagnosticKind) -> Self {
70        OpenApiDiagnostic {
71            kind,
72            pointer: None,
73            source_preview: None,
74            context: None,
75            line: None,
76        }
77    }
78
79    /// Returns the human-readable note text for this diagnostic, if any.
80    pub fn note(&self) -> Option<&str> {
81        let note = self.kind.note_text();
82        if note.is_empty() { None } else { Some(note) }
83    }
84
85    /// Returns the corpus `(kind, feature)` classification for this diagnostic.
86    ///
87    /// This is the canonical mapping from [`DiagnosticKind`] to the string
88    /// identifiers used in corpus reports.  Keeping it here means adding a new
89    /// variant produces a compile error at the definition site.
90    pub fn classify(&self) -> (&'static str, String) {
91        match &self.kind {
92            DiagnosticKind::UnknownSchemaKeyword { keyword } => {
93                ("unsupported_schema_keyword", keyword.clone())
94            }
95            DiagnosticKind::UnsupportedSchemaKeyword { keyword } => (
96                Self::unsupported_kind_for_pointer(self.pointer.as_deref(), keyword),
97                keyword.clone(),
98            ),
99            DiagnosticKind::UnsupportedSchemaType { schema_type } => {
100                ("unsupported_schema_type", schema_type.clone())
101            }
102            DiagnosticKind::UnsupportedSchemaShape => (
103                "unsupported_schema_shape",
104                self.pointer
105                    .as_deref()
106                    .map(diagnostic_pointer_tail)
107                    .unwrap_or_else(|| "schema_shape".into()),
108            ),
109            DiagnosticKind::UnsupportedReference { reference } => {
110                ("unsupported_reference", categorize_reference(reference))
111            }
112            DiagnosticKind::AllOfRecursiveCycle { .. } => {
113                ("unsupported_all_of_merge", "recursive_cycle".into())
114            }
115            DiagnosticKind::RecursiveParameterCycle { .. } => (
116                "invalid_openapi_document",
117                "recursive_parameter_cycle".into(),
118            ),
119            DiagnosticKind::RecursiveRequestBodyCycle { .. } => (
120                "invalid_openapi_document",
121                "recursive_request_body_cycle".into(),
122            ),
123            DiagnosticKind::IncompatibleAllOfField { field } => {
124                ("unsupported_all_of_merge", field.clone())
125            }
126            DiagnosticKind::EmptyRequestBodyContent => {
127                ("unsupported_request_body_shape", "empty_content".into())
128            }
129            DiagnosticKind::EmptyParameterName { .. } => {
130                ("invalid_openapi_document", "empty_parameter_name".into())
131            }
132            DiagnosticKind::EmptyPropertyKey { .. } => {
133                ("invalid_openapi_document", "empty_property_key".into())
134            }
135            DiagnosticKind::ParameterMissingSchema { name } => (
136                "invalid_openapi_document",
137                normalize_diagnostic_feature(name),
138            ),
139            DiagnosticKind::UnsupportedParameterLocation { name } => (
140                "invalid_openapi_document",
141                normalize_diagnostic_feature(name),
142            ),
143            DiagnosticKind::MultipleRequestBodyDeclarations { .. } => (
144                "invalid_openapi_document",
145                "multiple_request_body_declarations".into(),
146            ),
147            DiagnosticKind::BodyParameterMissingSchema { name } => (
148                "invalid_openapi_document",
149                normalize_diagnostic_feature(name),
150            ),
151            DiagnosticKind::FormDataParameterMissingSchema { name } => (
152                "invalid_openapi_document",
153                normalize_diagnostic_feature(name),
154            ),
155        }
156    }
157
158    pub fn unsupported_kind_for_pointer(pointer: Option<&str>, feature: &str) -> &'static str {
159        if matches!(
160            feature,
161            "allOf" | "anyOf" | "oneOf" | "not" | "discriminator" | "const"
162        ) {
163            return "unsupported_schema_keyword";
164        }
165        match pointer {
166            Some(p)
167                if p.contains("/components/schemas/")
168                    || p.contains("/properties/")
169                    || p.ends_with("/schema")
170                    || p.contains("/items/") =>
171            {
172                "unsupported_schema_keyword"
173            }
174            Some(p) if p.contains("/parameters/") => "unsupported_parameter_feature",
175            Some(p) if p.contains("/responses/") => "unsupported_response_feature",
176            Some(p) if p.contains("/requestBody/") => "unsupported_request_body_feature",
177            _ => "unsupported_feature",
178        }
179    }
180}
181
182pub fn categorize_reference(reference: &str) -> String {
183    // External references (http/https/relative paths without #) are their own category.
184    if !reference.starts_with('#') {
185        return "external".into();
186    }
187    // Strip `#/` and split into path segments, ignoring percent-encoded path globs
188    // and numeric indices so only structural keywords remain.
189    let inner = reference.strip_prefix("#/").unwrap_or("");
190    let structural: Vec<&str> = inner
191        .split('/')
192        .filter(|s| !s.is_empty() && !s.chars().all(|c| c.is_ascii_digit()) && !s.contains('~') && !s.contains('%'))
193        .take(2)
194        .collect();
195    if structural.is_empty() {
196        return "unknown".into();
197    }
198    structural.join("_")
199}
200
201pub fn diagnostic_pointer_tail(pointer: &str) -> String {
202    pointer
203        .trim_end_matches('/')
204        .rsplit('/')
205        .next()
206        .map(normalize_diagnostic_feature)
207        .unwrap_or_else(|| "schema_shape".into())
208}
209
210pub fn normalize_diagnostic_feature(value: &str) -> String {
211    value
212        .replace("~1", "/")
213        .replace("~0", "~")
214        .replace('.', "_")
215        .replace('/', "_")
216        .replace('`', "")
217}
218
219impl std::fmt::Display for OpenApiDiagnostic {
220    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221        let message = self.kind.message_text();
222        let note = self.kind.note_text();
223
224        if let Some(pointer) = &self.pointer {
225            write!(f, "OpenAPI document issue\nCaused by:\n  {message}")?;
226            write!(f, "\n  location: {pointer}")?;
227            if let Some(preview) = &self.source_preview {
228                write!(f, "\n  preview:")?;
229                for line in preview.lines() {
230                    write!(f, "\n    {line}")?;
231                }
232            }
233            if !note.is_empty() {
234                write!(f, "\n  note: {note}")?;
235            }
236        } else if let Some(context) = &self.context {
237            write!(f, "{context}: {message}")?;
238            if !note.is_empty() {
239                write!(f, "\nnote: {note}")?;
240            }
241        } else {
242            write!(f, "{message}")?;
243            if !note.is_empty() {
244                write!(f, "\nnote: {note}")?;
245            }
246        }
247        Ok(())
248    }
249}
250
251impl std::error::Error for OpenApiDiagnostic {}
252
253/// Machine-readable classification of an [`OpenApiDiagnostic`].
254#[derive(Debug, Clone)]
255pub enum DiagnosticKind {
256    // ── Schema keyword issues ────────────────────────────────────────────────
257    /// An unrecognised keyword was found in a schema object.
258    UnknownSchemaKeyword { keyword: String },
259    /// A recognised but unsupported schema keyword was found.
260    UnsupportedSchemaKeyword { keyword: String },
261    /// A schema declared a type string that Arvalez cannot map.
262    UnsupportedSchemaType { schema_type: String },
263    /// A schema's overall shape could not be mapped to a type.
264    UnsupportedSchemaShape,
265    // ── Reference issues ─────────────────────────────────────────────────────
266    /// A `$ref` value points to a location Arvalez cannot resolve.
267    UnsupportedReference { reference: String },
268    /// A `$ref` chain inside `allOf` (or object view collection) is cyclic.
269    AllOfRecursiveCycle { reference: String },
270    /// A parameter `$ref` is cyclic.
271    RecursiveParameterCycle { reference: String },
272    /// A `requestBody` `$ref` is cyclic.
273    RecursiveRequestBodyCycle { reference: String },
274    // ── allOf merge issues ───────────────────────────────────────────────────
275    /// Two `allOf` members declare incompatible values for the same keyword.
276    IncompatibleAllOfField { field: String },
277    // ── Structural document issues ───────────────────────────────────────────
278    /// A `requestBody` object has an empty `content` map.
279    EmptyRequestBodyContent,
280    /// A parameter's `name` field is the empty string.
281    EmptyParameterName { counter: usize },
282    /// An object schema property key is the empty string.
283    EmptyPropertyKey { counter: usize },
284    /// A non-body parameter has neither a `schema` nor a `type`.
285    ParameterMissingSchema { name: String },
286    /// A parameter uses a location (`in`) value Arvalez cannot handle.
287    UnsupportedParameterLocation { name: String },
288    /// An operation has multiple conflicting request-body sources.
289    MultipleRequestBodyDeclarations { note: String },
290    /// A Swagger 2 `in: body` parameter has no `schema`.
291    BodyParameterMissingSchema { name: String },
292    /// A Swagger 2 `in: formData` parameter has no schema/type.
293    FormDataParameterMissingSchema { name: String },
294}
295
296impl DiagnosticKind {
297    fn message_text(&self) -> String {
298        match self {
299            Self::UnknownSchemaKeyword { keyword } => format!("unknown schema keyword `{keyword}`"),
300            Self::UnsupportedSchemaKeyword { keyword } => {
301                format!("`{keyword}` is not supported yet")
302            }
303            Self::UnsupportedSchemaType { schema_type } => {
304                format!("unsupported schema type `{schema_type}`")
305            }
306            Self::UnsupportedSchemaShape => "schema shape is not supported yet".into(),
307            Self::UnsupportedReference { reference } => {
308                format!("unsupported reference `{reference}`")
309            }
310            Self::AllOfRecursiveCycle { reference } => {
311                format!("`allOf` contains a recursive reference cycle involving `{reference}`")
312            }
313            Self::RecursiveParameterCycle { reference } => {
314                format!("parameter reference contains a recursive cycle involving `{reference}`")
315            }
316            Self::RecursiveRequestBodyCycle { reference } => {
317                format!("request body reference contains a recursive cycle involving `{reference}`")
318            }
319            Self::IncompatibleAllOfField { field } => {
320                format!("`allOf` contains incompatible `{field}` declarations")
321            }
322            Self::EmptyRequestBodyContent => "request body has no content entries".into(),
323            Self::EmptyParameterName { counter } => {
324                format!("parameter #{counter} has an empty name")
325            }
326            Self::EmptyPropertyKey { counter } => format!("property #{counter} has an empty name"),
327            Self::ParameterMissingSchema { .. } => "parameter has no schema or type".into(),
328            Self::UnsupportedParameterLocation { .. } => "unsupported parameter location".into(),
329            Self::MultipleRequestBodyDeclarations { .. } => {
330                "multiple request body declarations are not supported".into()
331            }
332            Self::BodyParameterMissingSchema { .. } => "body parameter has no schema".into(),
333            Self::FormDataParameterMissingSchema { .. } => {
334                "formData parameter has no schema or type".into()
335            }
336        }
337    }
338
339    fn note_text(&self) -> &str {
340        match self {
341            Self::UnknownSchemaKeyword { .. }
342            | Self::UnsupportedSchemaKeyword { .. }
343            | Self::UnsupportedSchemaType { .. }
344            | Self::UnsupportedSchemaShape
345            | Self::AllOfRecursiveCycle { .. }
346            | Self::IncompatibleAllOfField { .. }
347            | Self::EmptyParameterName { .. }
348            | Self::EmptyPropertyKey { .. } => {
349                "Use `--ignore-unhandled` to turn this into a warning while keeping generation going."
350            }
351            Self::EmptyRequestBodyContent => {
352                "Arvalez defaulted this request body to `application/octet-stream` with an untyped payload."
353            }
354            Self::ParameterMissingSchema { .. } => {
355                "Arvalez currently expects non-body parameters to declare either `schema` (OpenAPI 3) or `type` (Swagger 2)."
356            }
357            Self::UnsupportedParameterLocation { .. } => {
358                "Arvalez currently supports path, query, header, and cookie parameters here."
359            }
360            Self::MultipleRequestBodyDeclarations { note } => note.as_str(),
361            Self::BodyParameterMissingSchema { .. } => {
362                "Swagger 2 `in: body` parameters must declare a `schema`."
363            }
364            Self::FormDataParameterMissingSchema { .. } => {
365                "Swagger 2 `in: formData` parameters must declare either a `type` or a `schema`."
366            }
367            Self::RecursiveParameterCycle { .. } => {
368                "Arvalez only supports acyclic parameter references."
369            }
370            Self::RecursiveRequestBodyCycle { .. } => {
371                "Arvalez only supports acyclic local `requestBody` references."
372            }
373            Self::UnsupportedReference { .. } => "",
374        }
375    }
376}
377
378#[derive(Debug, Clone)]
379pub struct OpenApiLoadResult {
380    pub ir: CoreIr,
381    pub warnings: Vec<OpenApiDiagnostic>,
382}
383
384pub fn load_openapi_to_ir(path: impl AsRef<Path>) -> Result<CoreIr> {
385    Ok(load_openapi_to_ir_with_options(path, LoadOpenApiOptions::default())?.ir)
386}
387
388pub fn load_openapi_to_ir_with_options(
389    path: impl AsRef<Path>,
390    options: LoadOpenApiOptions,
391) -> Result<OpenApiLoadResult> {
392    let path = path.as_ref();
393    let raw = measure_openapi_phase(options.emit_timings, "openapi_read", || {
394        fs::read_to_string(path)
395            .with_context(|| format!("failed to read OpenAPI document `{}`", path.display()))
396    })?;
397
398    let loaded = measure_openapi_phase(options.emit_timings, "openapi_parse", || {
399        match path.extension().and_then(|ext| ext.to_str()) {
400            Some("yaml") | Some("yml") => parse_yaml_openapi_document(path, &raw),
401            _ => parse_json_openapi_document(path, &raw),
402        }
403    })?;
404
405    OpenApiImporter::new(loaded.document, loaded.source, options).build_ir()
406}
407
408fn measure_openapi_phase<T, F>(enabled: bool, label: &str, task: F) -> Result<T>
409where
410    F: FnOnce() -> Result<T>,
411{
412    if enabled {
413        eprintln!("timing: starting {label}");
414    }
415    let started = Instant::now();
416    let value = task();
417    if enabled {
418        eprintln!(
419            "timing: {:<20} {}",
420            label,
421            format_duration(started.elapsed())
422        );
423    }
424    value
425}
426
427fn format_duration(duration: std::time::Duration) -> String {
428    let micros = duration.as_micros();
429    if micros < 1_000 {
430        format!("{micros}us")
431    } else if micros < 1_000_000 {
432        format!("{}ms", duration.as_millis())
433    } else {
434        format!("{:.2}s", duration.as_secs_f64())
435    }
436}
437
438fn deserialize_paths_map<'de, D>(
439    deserializer: D,
440) -> std::result::Result<BTreeMap<String, PathItem>, D::Error>
441where
442    D: serde::Deserializer<'de>,
443{
444    // Use a proper Visitor so the `serde_path_to_error`-wrapped deserializer
445    // stays in scope when each PathItem is deserialized.  Deserializing via an
446    // intermediate `BTreeMap<String, Value>` then `serde_json::from_value` would
447    // spin up a fresh deserialization context, losing all path tracking and
448    // causing errors to be reported as just `paths` instead of the full path.
449    struct PathsMapVisitor;
450
451    impl<'de> serde::de::Visitor<'de> for PathsMapVisitor {
452        type Value = BTreeMap<String, PathItem>;
453
454        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
455            formatter.write_str("a map of path items")
456        }
457
458        fn visit_map<A>(self, mut map: A) -> std::result::Result<Self::Value, A::Error>
459        where
460            A: serde::de::MapAccess<'de>,
461        {
462            let mut result = BTreeMap::new();
463            while let Some(key) = map.next_key::<String>()? {
464                if key.starts_with("x-") {
465                    // Drain the value without deserializing it.
466                    map.next_value::<serde::de::IgnoredAny>()?;
467                } else {
468                    // Deserialize directly as PathItem so that serde_path_to_error
469                    // can track the path key → PathItem fields.
470                    let value = map.next_value::<PathItem>()?;
471                    result.insert(key, value);
472                }
473            }
474            Ok(result)
475        }
476    }
477
478    deserializer.deserialize_map(PathsMapVisitor)
479}
480
481#[derive(Debug, Clone, Copy, PartialEq, Eq)]
482enum OpenApiVersion {
483    Swagger2,
484    OpenApi3,
485}
486
487fn detect_openapi_version(raw: &str) -> OpenApiVersion {
488    // Scan the first 4 KB — enough to encounter the version field in any real document.
489    let sample = &raw[..raw.len().min(4096)];
490    if sample.contains("\"swagger\"")
491        || sample.starts_with("swagger:")
492        || sample.contains("\nswagger:")
493    {
494        OpenApiVersion::Swagger2
495    } else {
496        OpenApiVersion::OpenApi3
497    }
498}
499
500fn deserialize_json<T>(path: &Path, raw: &str) -> Result<T>
501where
502    T: for<'de> serde::Deserialize<'de>,
503{
504    let mut deserializer = serde_json::Deserializer::from_str(raw);
505    serde_path_to_error::deserialize(&mut deserializer).map_err(|error| {
506        let schema_path = error.path().to_string();
507        let inner = error.into_inner();
508        let line = inner.line();
509        let column = inner.column();
510        let message = inner.to_string();
511        anyhow!(format_openapi_deserialize_error(
512            "JSON",
513            path,
514            raw,
515            if schema_path.is_empty() {
516                None
517            } else {
518                Some(schema_path.as_str())
519            },
520            line,
521            column,
522            &message,
523        ))
524    })
525}
526
527fn deserialize_yaml<T>(path: &Path, raw: &str, sanitized: &str) -> Result<T>
528where
529    T: for<'de> serde::Deserialize<'de>,
530{
531    let deserializer = serde_yaml::Deserializer::from_str(sanitized);
532    serde_path_to_error::deserialize(deserializer).map_err(|error| {
533        let schema_path = error.path().to_string();
534        let inner = error.into_inner();
535        let (line, column) = inner
536            .location()
537            .map(|location| (location.line(), location.column()))
538            .unwrap_or((0, 0));
539        anyhow!(format_openapi_deserialize_error(
540            "YAML",
541            path,
542            raw,
543            if schema_path.is_empty() {
544                None
545            } else {
546                Some(schema_path.as_str())
547            },
548            line,
549            column,
550            &inner.to_string(),
551        ))
552    })
553}
554
555fn parse_json_openapi_document(path: &Path, raw: &str) -> Result<LoadedOpenApiDocument> {
556    let document = if detect_openapi_version(raw) == OpenApiVersion::Swagger2 {
557        OpenApiDocument::from(deserialize_json::<Swagger2Document>(path, raw)?)
558    } else {
559        OpenApiDocument::from(deserialize_json::<OpenApi3Document>(path, raw)?)
560    };
561    Ok(LoadedOpenApiDocument {
562        document,
563        source: OpenApiSource::new(SourceFormat::Json, raw.to_owned()),
564    })
565}
566
567fn parse_yaml_openapi_document(path: &Path, raw: &str) -> Result<LoadedOpenApiDocument> {
568    let sanitized = sanitize_yaml_for_parser(raw);
569    let document = if detect_openapi_version(raw) == OpenApiVersion::Swagger2 {
570        OpenApiDocument::from(deserialize_yaml::<Swagger2Document>(
571            path,
572            raw,
573            sanitized.as_ref(),
574        )?)
575    } else {
576        OpenApiDocument::from(deserialize_yaml::<OpenApi3Document>(
577            path,
578            raw,
579            sanitized.as_ref(),
580        )?)
581    };
582    Ok(LoadedOpenApiDocument {
583        document,
584        source: OpenApiSource::new(SourceFormat::Yaml, raw.to_owned()),
585    })
586}
587
588fn format_openapi_deserialize_error(
589    format_name: &str,
590    path: &Path,
591    raw: &str,
592    schema_path: Option<&str>,
593    line: usize,
594    column: usize,
595    message: &str,
596) -> String {
597    let mut rendered = format!(
598        "failed to parse {format_name} OpenAPI document `{}`",
599        path.display()
600    );
601    rendered.push_str("\nCaused by:");
602
603    if let Some(schema_path) = schema_path {
604        rendered.push_str(&format!(
605            "\n  schema mismatch at `{schema_path}`: {message}"
606        ));
607    } else {
608        rendered.push_str(&format!("\n  {message}"));
609    }
610
611    if line > 0 && column > 0 {
612        rendered.push_str(&format!("\n  location: line {line}, column {column}"));
613        if let Some(source_line) = raw.lines().nth(line.saturating_sub(1)) {
614            rendered.push_str(&format!("\n  source: {source_line}"));
615            rendered.push_str(&format!(
616                "\n          {}^",
617                " ".repeat(column.saturating_sub(1))
618            ));
619        }
620    }
621
622    rendered.push_str(
623        "\n  note: this usually means the document is valid JSON/YAML, but an OpenAPI field had an unexpected shape.",
624    );
625    rendered
626}
627
628fn sanitize_yaml_for_parser(raw: &str) -> Cow<'_, str> {
629    // U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR are treated as
630    // newlines by serde_yaml (YAML 1.1 §4.2). When they appear inside a block
631    // scalar they create implicit line breaks whose "continuation" indentation
632    // is wrong, prematurely ending the block scalar and then causing a parse
633    // error on the next real YAML line. Replace them with regular spaces so
634    // the block scalar content is preserved verbatim.
635    //
636    // C1 control codes (U+0080–U+009F) are forbidden by YAML 1.2. They
637    // sometimes appear as artifacts of double-UTF-8 encoding (e.g. U+2019 '
638    // double-encoded produces the bytes C2 80 / C2 99 which decode to U+0080
639    // and U+0099). Strip them so the parser doesn't reject the document.
640    let needs_unicode_fix = raw.contains('\u{2028}') || raw.contains('\u{2029}');
641    let needs_c1_fix = raw.chars().any(|c| ('\u{0080}'..='\u{009F}').contains(&c));
642    if !raw.contains('\t') && !needs_unicode_fix && !needs_c1_fix {
643        return Cow::Borrowed(raw);
644    }
645
646    let mut changed = false;
647    let mut normalized = String::with_capacity(raw.len());
648
649    for segment in raw.split_inclusive('\n') {
650        let line = segment.strip_suffix('\n').unwrap_or(segment);
651        let has_newline = segment.len() != line.len();
652        if line.contains('\t') && line.chars().all(|ch| matches!(ch, ' ' | '\t')) {
653            changed = true;
654        } else {
655            // Apply both fixes in one pass when either is needed.
656            let has_sep = line.contains('\u{2028}') || line.contains('\u{2029}');
657            let has_c1 = line.chars().any(|c| ('\u{0080}'..='\u{009F}').contains(&c));
658            if has_sep || has_c1 {
659                let fixed: String = line
660                    .chars()
661                    .map(|c| match c {
662                        // Unicode line/paragraph separators → space (preserve blank-line intent)
663                        '\u{2028}' | '\u{2029}' => ' ',
664                        // C1 control codes → strip by replacing with nothing (handled below)
665                        c if ('\u{0080}'..='\u{009F}').contains(&c) => '\0',
666                        c => c,
667                    })
668                    .filter(|&c| c != '\0')
669                    .collect();
670                normalized.push_str(&fixed);
671                changed = true;
672            } else {
673                normalized.push_str(line);
674            }
675        }
676
677        if has_newline {
678            normalized.push('\n');
679        }
680    }
681
682    if changed {
683        Cow::Owned(normalized)
684    } else {
685        Cow::Borrowed(raw)
686    }
687}
688
689struct OpenApiImporter {
690    document: OpenApiDocument,
691    source: OpenApiSource,
692    models: BTreeMap<String, Model>,
693    generated_model_names: BTreeSet<String>,
694    generated_operation_names: BTreeSet<String>,
695    local_ref_model_names: BTreeMap<String, String>,
696    active_model_builds: BTreeSet<String>,
697    active_local_ref_imports: BTreeSet<String>,
698    normalized_all_of_refs: BTreeMap<String, Schema>,
699    active_all_of_refs: Vec<String>,
700    active_object_view_refs: Vec<String>,
701    warnings: Vec<OpenApiDiagnostic>,
702    options: LoadOpenApiOptions,
703}
704
705impl OpenApiImporter {
706    fn new(document: OpenApiDocument, source: OpenApiSource, options: LoadOpenApiOptions) -> Self {
707        Self {
708            document,
709            source,
710            models: BTreeMap::new(),
711            generated_model_names: BTreeSet::new(),
712            generated_operation_names: BTreeSet::new(),
713            local_ref_model_names: BTreeMap::new(),
714            active_model_builds: BTreeSet::new(),
715            active_local_ref_imports: BTreeSet::new(),
716            normalized_all_of_refs: BTreeMap::new(),
717            active_all_of_refs: Vec::new(),
718            active_object_view_refs: Vec::new(),
719            warnings: Vec::new(),
720            options,
721        }
722    }
723
724    fn build_ir(mut self) -> Result<OpenApiLoadResult> {
725        measure_openapi_phase(
726            self.options.emit_timings,
727            "openapi_component_models",
728            || self.import_component_models(),
729        )?;
730
731        let mut operations = Vec::new();
732        measure_openapi_phase(self.options.emit_timings, "openapi_operations", || {
733            let paths = self.document.paths.clone();
734            for (path, item) in &paths {
735                operations.extend(self.import_path_item(path, item)?);
736            }
737            Ok(())
738        })?;
739
740        let ir = CoreIr {
741            models: self.models.into_values().collect(),
742            operations,
743            ..Default::default()
744        };
745
746        measure_openapi_phase(self.options.emit_timings, "openapi_validate_ir", || {
747            validate_ir(&ir).map_err(|errors| {
748                let details = errors
749                    .0
750                    .iter()
751                    .map(|issue| format!("{}: {}", issue.path, issue.message))
752                    .collect::<Vec<_>>()
753                    .join("\n");
754                anyhow!("generated IR is invalid:\n{details}")
755            })
756        })?;
757        Ok(OpenApiLoadResult {
758            ir,
759            warnings: self.warnings,
760        })
761    }
762
763    fn import_component_models(&mut self) -> Result<()> {
764        let mut schemas = Vec::new();
765        for (name, schema) in self.document.components.schemas.clone() {
766            let pointer = format!("#/components/schemas/{name}");
767            schemas.push((name, schema, pointer));
768        }
769        for (name, schema) in self.document.definitions.clone() {
770            let pointer = format!("#/definitions/{name}");
771            schemas.push((name, schema, pointer));
772        }
773        let total = schemas.len();
774        for (index, (name, schema, pointer)) in schemas.into_iter().enumerate() {
775            if self.options.emit_timings {
776                eprintln!(
777                    "timing: starting component_model [{}/{}] {}",
778                    index + 1,
779                    total,
780                    name
781                );
782            }
783            let started = Instant::now();
784            self.ensure_named_schema_model(&name, &schema, &pointer)?;
785            if self.options.emit_timings {
786                eprintln!(
787                    "timing: component_model [{}/{}] {:<40} {}",
788                    index + 1,
789                    total,
790                    name,
791                    format_duration(started.elapsed())
792                );
793            }
794        }
795        Ok(())
796    }
797
798    fn import_path_item(&mut self, path: &str, item: &PathItem) -> Result<Vec<Operation>> {
799        let mut operations = Vec::new();
800        let shared_parameters = item.parameters.clone().unwrap_or_default();
801        let candidates = [
802            (HttpMethod::Get, item.get.as_ref()),
803            (HttpMethod::Post, item.post.as_ref()),
804            (HttpMethod::Put, item.put.as_ref()),
805            (HttpMethod::Patch, item.patch.as_ref()),
806            (HttpMethod::Delete, item.delete.as_ref()),
807        ];
808
809        for (method, spec) in candidates {
810            let Some(spec) = spec else {
811                continue;
812            };
813
814            let operation_name = self.reserve_operation_name(
815                spec.operation_id
816                    .as_deref()
817                    .map(str::trim)
818                    .filter(|value| !value.is_empty())
819                    .map(ToOwned::to_owned)
820                    .unwrap_or_else(|| fallback_operation_name(method, path)),
821            );
822            let mut operation = Operation {
823                id: format!("operation.{operation_name}"),
824                name: operation_name.clone(),
825                method,
826                path: path.to_owned(),
827                params: Vec::new(),
828                request_body: None,
829                responses: Vec::new(),
830                attributes: operation_attributes(spec),
831                source: Some(SourceRef {
832                    pointer: format!("#/paths/{}/{}", json_pointer_key(path), method_key(method)),
833                    line: None,
834                }),
835            };
836            let mut unnamed_parameter_counter = 0usize;
837            let mut form_data_parameters = Vec::new();
838            let shared_len = shared_parameters.len();
839
840            for (param_idx, param) in shared_parameters.iter().chain(spec.parameters.iter()).enumerate() {
841                let mut resolved = self.resolve_parameter(param)?;
842                if resolved.name.trim().is_empty() {
843                    unnamed_parameter_counter += 1;
844                    // Use the specific parameter pointer so the source preview
845                    // and line number point at the offending parameter item
846                    // rather than the whole operation.
847                    let param_pointer = if param_idx < shared_len {
848                        format!("#/paths/{}/parameters/{}", json_pointer_key(path), param_idx)
849                    } else {
850                        format!(
851                            "#/paths/{}/{}/parameters/{}",
852                            json_pointer_key(path),
853                            method_key(method),
854                            param_idx - shared_len,
855                        )
856                    };
857                    self.handle_unhandled(
858                        &param_pointer,
859                        DiagnosticKind::EmptyParameterName {
860                            counter: unnamed_parameter_counter,
861                        },
862                    )?;
863                    resolved.name = format!(
864                        "unnamed_{}_parameter_{}",
865                        raw_parameter_location_label(resolved.location),
866                        unnamed_parameter_counter
867                    );
868                }
869                if resolved.location == RawParameterLocation::Body {
870                    let request_body =
871                        self.import_swagger_body_parameter(&resolved, spec, &operation_name)?;
872                    if operation.request_body.is_some() {
873                        bail!(self.make_diagnostic(
874                            &format!("operation `{operation_name}`"),
875                            DiagnosticKind::MultipleRequestBodyDeclarations {
876                                note: "Arvalez can normalize either an OpenAPI `requestBody` or a single Swagger 2 `in: body` parameter for an operation.".into(),
877                            },
878                        ));
879                    }
880                    operation.request_body = Some(request_body);
881                    continue;
882                }
883                if resolved.location == RawParameterLocation::FormData {
884                    form_data_parameters.push(resolved);
885                    continue;
886                }
887
888                operation.params.push(self.import_parameter(&resolved)?);
889            }
890
891            if !form_data_parameters.is_empty() {
892                if operation.request_body.is_some() {
893                    bail!(self.make_diagnostic(
894                        &format!("operation `{operation_name}`"),
895                        DiagnosticKind::MultipleRequestBodyDeclarations {
896                            note: "Arvalez can normalize either an OpenAPI `requestBody`, a single Swagger 2 `in: body` parameter, or Swagger 2 `formData` parameters for an operation.".into(),
897                        },
898                    ));
899                }
900                operation.request_body = Some(self.import_swagger_form_data_request_body(
901                    &form_data_parameters,
902                    spec,
903                    &operation_name,
904                )?);
905            }
906
907            if let Some(request_body) = &spec.request_body {
908                if operation.request_body.is_some() {
909                    bail!(self.make_diagnostic(
910                        &format!("operation `{operation_name}`"),
911                        DiagnosticKind::MultipleRequestBodyDeclarations {
912                            note: "Arvalez can normalize either an OpenAPI `requestBody` or a single Swagger 2 `in: body` parameter for an operation.".into(),
913                        },
914                    ));
915                }
916                operation.request_body =
917                    Some(self.import_request_body(request_body, &operation_name, path, method)?);
918            }
919
920            for (status, response_or_ref) in &spec.responses {
921                let response = self.resolve_response_spec(response_or_ref)?;
922                operation.responses.push(self.import_response(
923                    status,
924                    &response,
925                    &operation_name,
926                    path,
927                    method,
928                )?);
929            }
930
931            operations.push(operation);
932        }
933
934        Ok(operations)
935    }
936
937    fn import_parameter(&mut self, param: &ParameterSpec) -> Result<Parameter> {
938        let schema = param.effective_schema().ok_or_else(|| {
939            anyhow::Error::new(self.make_diagnostic(
940                &format!("parameter `{}`", param.name),
941                DiagnosticKind::ParameterMissingSchema {
942                    name: param.name.clone(),
943                },
944            ))
945        })?;
946        let imported = self.import_schema_type(
947            &schema,
948            &InlineModelContext::Parameter {
949                name: param.name.clone(),
950            },
951        )?;
952
953        Ok(Parameter {
954            name: param.name.clone(),
955            location: param.location.as_ir_location().ok_or_else(|| {
956                anyhow::Error::new(self.make_diagnostic(
957                    &format!("parameter `{}`", param.name),
958                    DiagnosticKind::UnsupportedParameterLocation {
959                        name: param.name.clone(),
960                    },
961                ))
962            })?,
963            type_ref: imported
964                .type_ref
965                .unwrap_or_else(|| TypeRef::primitive("any")),
966            required: param.required,
967            attributes: parameter_attributes(&param, &schema),
968        })
969    }
970
971    fn import_swagger_body_parameter(
972        &mut self,
973        param: &ParameterSpec,
974        spec: &OperationSpec,
975        operation_name: &str,
976    ) -> Result<RequestBody> {
977        let schema = param.effective_schema().ok_or_else(|| {
978            anyhow::Error::new(self.make_diagnostic(
979                &format!("body parameter `{}`", param.name),
980                DiagnosticKind::BodyParameterMissingSchema {
981                    name: param.name.clone(),
982                },
983            ))
984        })?;
985
986        let imported = self.import_schema_type(
987            &schema,
988            &InlineModelContext::RequestBody {
989                operation_name: operation_name.to_owned(),
990                pointer: format!(
991                    "#/operations/{operation_name}/body_parameter/{}",
992                    param.name
993                ),
994            },
995        )?;
996
997        let media_type = spec
998            .consumes
999            .first()
1000            .cloned()
1001            .or_else(|| self.document.consumes.first().cloned())
1002            .unwrap_or_else(|| "application/json".into());
1003
1004        let mut attributes = schema_runtime_attributes(&schema);
1005        if !param.description.trim().is_empty() {
1006            attributes.insert(
1007                "description".into(),
1008                Value::String(param.description.trim().to_owned()),
1009            );
1010        }
1011
1012        Ok(RequestBody {
1013            required: param.required,
1014            media_type,
1015            type_ref: imported.type_ref,
1016            attributes,
1017        })
1018    }
1019
1020    fn import_swagger_form_data_request_body(
1021        &mut self,
1022        params: &[ParameterSpec],
1023        spec: &OperationSpec,
1024        operation_name: &str,
1025    ) -> Result<RequestBody> {
1026        let mut properties = IndexMap::new();
1027        let mut required = Vec::new();
1028        for param in params {
1029            let mut schema = param.effective_schema().ok_or_else(|| {
1030                anyhow::Error::new(self.make_diagnostic(
1031                    &format!("formData parameter `{}`", param.name),
1032                    DiagnosticKind::FormDataParameterMissingSchema {
1033                        name: param.name.clone(),
1034                    },
1035                ))
1036            })?;
1037            if !param.description.trim().is_empty() {
1038                schema.extra_keywords.insert(
1039                    "description".into(),
1040                    Value::String(param.description.trim().to_owned()),
1041                );
1042            }
1043            if param.required {
1044                required.push(param.name.clone());
1045            }
1046            properties.insert(param.name.clone(), SchemaOrBool::Schema(schema));
1047        }
1048
1049        let imported = self.import_schema_type(
1050            &Schema {
1051                schema_type: Some(SchemaTypeDecl::Single("object".into())),
1052                properties: Some(properties),
1053                required: (!required.is_empty()).then_some(required.clone()),
1054                ..Schema::default()
1055            },
1056            &InlineModelContext::RequestBody {
1057                operation_name: operation_name.to_owned(),
1058                pointer: format!("#/operations/{operation_name}/formData"),
1059            },
1060        )?;
1061
1062        let media_type = spec
1063            .consumes
1064            .first()
1065            .cloned()
1066            .or_else(|| self.document.consumes.first().cloned())
1067            .unwrap_or_else(|| "application/x-www-form-urlencoded".into());
1068
1069        let mut attributes = Attributes::default();
1070        if params.iter().any(|param| param.required) {
1071            attributes.insert("form_encoding".into(), Value::String(media_type.clone()));
1072        }
1073
1074        Ok(RequestBody {
1075            required: params.iter().any(|param| param.required),
1076            media_type,
1077            type_ref: imported.type_ref,
1078            attributes,
1079        })
1080    }
1081
1082    fn resolve_parameter(&self, param: &ParameterOrRef) -> Result<ParameterSpec> {
1083        let mut seen = BTreeSet::new();
1084        self.resolve_parameter_inner(param, &mut seen)
1085    }
1086
1087    fn resolve_parameter_inner(
1088        &self,
1089        param: &ParameterOrRef,
1090        seen: &mut BTreeSet<String>,
1091    ) -> Result<ParameterSpec> {
1092        match param {
1093            ParameterOrRef::Inline(param) => Ok(param.clone()),
1094            ParameterOrRef::Ref { reference } => {
1095                if !seen.insert(reference.clone()) {
1096                    bail!(self.make_pointer_diagnostic(
1097                        reference,
1098                        DiagnosticKind::RecursiveParameterCycle {
1099                            reference: reference.to_owned()
1100                        },
1101                    ));
1102                }
1103
1104                if let Some(parameter) = self.resolve_named_parameter_reference(reference) {
1105                    return self
1106                        .resolve_parameter_inner(&ParameterOrRef::Inline(parameter.clone()), seen);
1107                }
1108
1109                if let Some(parameter) = self.resolve_path_parameter_reference(reference)? {
1110                    return self.resolve_parameter_inner(parameter, seen);
1111                }
1112
1113                Err(anyhow!("unsupported reference `{reference}`"))
1114            }
1115        }
1116    }
1117
1118    fn resolve_named_parameter_reference(&self, reference: &str) -> Option<&ParameterSpec> {
1119        let name = ref_name(reference).ok()?;
1120        self.document
1121            .components
1122            .parameters
1123            .get(&name)
1124            .or_else(|| self.document.parameters.get(&name))
1125    }
1126
1127    fn resolve_path_parameter_reference<'a>(
1128        &'a self,
1129        reference: &str,
1130    ) -> Result<Option<&'a ParameterOrRef>> {
1131        let Some(pointer) = reference.strip_prefix("#/") else {
1132            return Ok(None);
1133        };
1134        let segments = pointer
1135            .split('/')
1136            .map(decode_json_pointer_segment)
1137            .collect::<Result<Vec<_>>>()?;
1138        if segments.first().map(String::as_str) != Some("paths") {
1139            return Ok(None);
1140        }
1141
1142        match segments.as_slice() {
1143            [_, path, scope, index] if scope == "parameters" => {
1144                let index = index.parse::<usize>().ok();
1145                let param = self
1146                    .document
1147                    .paths
1148                    .get(path)
1149                    .and_then(|item| item.parameters.as_ref())
1150                    .and_then(|params| index.and_then(|idx| params.get(idx)));
1151                Ok(param)
1152            }
1153            [_, path, method, scope, index] if scope == "parameters" => {
1154                let index = index.parse::<usize>().ok();
1155                let param = self
1156                    .document
1157                    .paths
1158                    .get(path)
1159                    .and_then(|item| match method.as_str() {
1160                        "get" => item.get.as_ref(),
1161                        "post" => item.post.as_ref(),
1162                        "put" => item.put.as_ref(),
1163                        "patch" => item.patch.as_ref(),
1164                        "delete" => item.delete.as_ref(),
1165                        _ => None,
1166                    })
1167                    .and_then(|operation| index.and_then(|idx| operation.parameters.get(idx)));
1168                Ok(param)
1169            }
1170            _ => Ok(None),
1171        }
1172    }
1173
1174    fn import_request_body(
1175        &mut self,
1176        request_body: &RequestBodyOrRef,
1177        operation_name: &str,
1178        path: &str,
1179        method: HttpMethod,
1180    ) -> Result<RequestBody> {
1181        let fallback_pointer = format!(
1182            "#/paths/{}/{}/requestBody",
1183            json_pointer_key(path),
1184            method_key(method)
1185        );
1186        let (request_body, pointer) = self.resolve_request_body(request_body, &fallback_pointer)?;
1187        let content_pointer = format!("{pointer}/content");
1188        let Some((media_type, media_spec)) = request_body.content.iter().next() else {
1189            self.warnings.push(self.make_pointer_diagnostic(
1190                &content_pointer,
1191                DiagnosticKind::EmptyRequestBodyContent,
1192            ));
1193            return Ok(RequestBody {
1194                required: request_body.required,
1195                media_type: "application/octet-stream".into(),
1196                type_ref: None,
1197                attributes: Attributes::default(),
1198            });
1199        };
1200
1201        let imported = media_spec
1202            .schema
1203            .as_ref()
1204            .map(|schema| {
1205                self.import_schema_type(
1206                    schema,
1207                    &InlineModelContext::RequestBody {
1208                        operation_name: operation_name.to_owned(),
1209                        pointer: format!(
1210                            "{content_pointer}/{}/schema",
1211                            json_pointer_key(media_type)
1212                        ),
1213                    },
1214                )
1215            })
1216            .transpose()?;
1217
1218        Ok(RequestBody {
1219            required: request_body.required,
1220            media_type: media_type.clone(),
1221            type_ref: imported.and_then(|value| value.type_ref),
1222            attributes: media_spec
1223                .schema
1224                .as_ref()
1225                .map(schema_runtime_attributes)
1226                .unwrap_or_default(),
1227        })
1228    }
1229
1230    fn resolve_request_body(
1231        &self,
1232        request_body: &RequestBodyOrRef,
1233        pointer: &str,
1234    ) -> Result<(RequestBodySpec, String)> {
1235        let mut seen = BTreeSet::new();
1236        self.resolve_request_body_inner(request_body, pointer, &mut seen)
1237    }
1238
1239    fn resolve_request_body_inner(
1240        &self,
1241        request_body: &RequestBodyOrRef,
1242        pointer: &str,
1243        seen: &mut BTreeSet<String>,
1244    ) -> Result<(RequestBodySpec, String)> {
1245        match request_body {
1246            RequestBodyOrRef::Inline(spec) => Ok((spec.clone(), pointer.to_owned())),
1247            RequestBodyOrRef::Ref { reference } => {
1248                if !seen.insert(reference.clone()) {
1249                    bail!(self.make_pointer_diagnostic(
1250                        reference,
1251                        DiagnosticKind::RecursiveRequestBodyCycle {
1252                            reference: reference.to_owned()
1253                        },
1254                    ));
1255                }
1256                let name = ref_name(reference)?;
1257                let referenced = self
1258                    .document
1259                    .components
1260                    .request_bodies
1261                    .get(&name)
1262                    .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
1263                self.resolve_request_body_inner(referenced, reference, seen)
1264            }
1265        }
1266    }
1267
1268    fn resolve_response_spec(&self, response: &ResponseSpecOrRef) -> Result<ResponseSpec> {
1269        match &response.reference {
1270            None => Ok(ResponseSpec {
1271                description: response.description.clone(),
1272                content: response.content.clone(),
1273            }),
1274            Some(reference) => {
1275                let Some(pointer) = reference.strip_prefix("#/") else {
1276                    return Err(anyhow!("unsupported reference `{reference}`"));
1277                };
1278                let segments: Vec<&str> = pointer.split('/').collect();
1279                match segments.as_slice() {
1280                    // OpenAPI 3: #/components/responses/{name}
1281                    ["components", "responses", name] => self
1282                        .document
1283                        .components
1284                        .responses
1285                        .get(*name)
1286                        .cloned()
1287                        .ok_or_else(|| anyhow!("unsupported reference `{reference}`")),
1288                    // Swagger 2: #/responses/{name}
1289                    ["responses", name] => self
1290                        .document
1291                        .responses
1292                        .get(*name)
1293                        .cloned()
1294                        .ok_or_else(|| anyhow!("unsupported reference `{reference}`")),
1295                    // Inline path reference (e.g. #/paths/.../responses/200) — return
1296                    // empty response, preserving the same silent-fallback behaviour that
1297                    // existed before ResponseSpecOrRef was introduced.
1298                    _ => Ok(ResponseSpec::default()),
1299                }
1300            }
1301        }
1302    }
1303
1304    fn import_response(
1305        &mut self,
1306        status: &str,
1307        response: &ResponseSpec,
1308        operation_name: &str,
1309        path: &str,
1310        method: HttpMethod,
1311    ) -> Result<Response> {
1312        let (media_type, schema) = response
1313            .content
1314            .iter()
1315            .find_map(|(media_type, media)| {
1316                media.schema.as_ref().map(|schema| (media_type, schema))
1317            })
1318            .map(|(media_type, schema)| (Some(media_type.clone()), Some(schema)))
1319            .unwrap_or((None, None));
1320
1321        let imported = schema
1322            .map(|schema| {
1323                self.import_schema_type(
1324                    schema,
1325                    &InlineModelContext::Response {
1326                        operation_name: operation_name.to_owned(),
1327                        status: status.to_owned(),
1328                        pointer: media_type.as_ref().map_or_else(
1329                            || {
1330                                format!(
1331                                    "#/paths/{}/{}/responses/{}",
1332                                    json_pointer_key(path),
1333                                    method_key(method),
1334                                    json_pointer_key(status)
1335                                )
1336                            },
1337                            |media_type| {
1338                                format!(
1339                                    "#/paths/{}/{}/responses/{}/content/{}/schema",
1340                                    json_pointer_key(path),
1341                                    method_key(method),
1342                                    json_pointer_key(status),
1343                                    json_pointer_key(media_type)
1344                                )
1345                            },
1346                        ),
1347                    },
1348                )
1349            })
1350            .transpose()?;
1351
1352        let mut attributes = Attributes::default();
1353        if !response.description.is_empty() {
1354            attributes.insert(
1355                "description".into(),
1356                Value::String(response.description.clone()),
1357            );
1358        }
1359        if let Some(schema) = schema {
1360            attributes.extend(schema_runtime_attributes(schema));
1361        }
1362
1363        Ok(Response {
1364            status: status.to_owned(),
1365            media_type,
1366            type_ref: imported.and_then(|value| value.type_ref),
1367            attributes,
1368        })
1369    }
1370
1371    fn ensure_named_schema_model(
1372        &mut self,
1373        name: &str,
1374        schema: &Schema,
1375        pointer: &str,
1376    ) -> Result<()> {
1377        if self.models.contains_key(name) {
1378            return Ok(());
1379        }
1380
1381        let model = self.build_model_from_schema(name, schema, pointer)?;
1382        self.generated_model_names.insert(name.to_owned());
1383        self.models.insert(name.to_owned(), model);
1384        Ok(())
1385    }
1386
1387    fn build_model_from_schema(
1388        &mut self,
1389        name: &str,
1390        schema: &Schema,
1391        pointer: &str,
1392    ) -> Result<Model> {
1393        if schema.all_of.is_some() && schema_is_object_like(schema) {
1394            return self.build_object_model_from_all_of(name, schema, pointer);
1395        }
1396
1397        let schema = self.normalize_schema(schema, pointer)?;
1398        let schema = schema.as_ref();
1399        self.validate_schema_keywords(schema, pointer)?;
1400
1401        if let Some(enum_values) = &schema.enum_values {
1402            let mut model = Model::new(format!("model.{}", to_snake_case(name)), name.to_owned());
1403            model.source = Some(SourceRef {
1404                pointer: pointer.to_owned(),
1405                line: None,
1406            });
1407            model
1408                .attributes
1409                .insert("enum_values".into(), Value::Array(enum_values.clone()));
1410            if let Some(schema_type) = schema.primary_schema_type() {
1411                model.attributes.insert(
1412                    "enum_base_type".into(),
1413                    Value::String(schema_type.to_owned()),
1414                );
1415            }
1416            if let Some(title) = &schema.title {
1417                model
1418                    .attributes
1419                    .insert("title".into(), Value::String(title.clone()));
1420            }
1421            return Ok(model);
1422        }
1423
1424        if !schema_is_object_like(schema) {
1425            let imported = self.import_schema_type_normalized(
1426                schema,
1427                &InlineModelContext::NamedSchema {
1428                    name: name.to_owned(),
1429                    pointer: pointer.to_owned(),
1430                },
1431                true,
1432                None,
1433            )?;
1434            let mut model = Model::new(format!("model.{}", to_snake_case(name)), name.to_owned());
1435            model.source = Some(SourceRef {
1436                pointer: pointer.to_owned(),
1437                line: None,
1438            });
1439            model.attributes.insert(
1440                "alias_type_ref".into(),
1441                json!(
1442                    imported
1443                        .type_ref
1444                        .unwrap_or_else(|| TypeRef::primitive("any"))
1445                ),
1446            );
1447            model
1448                .attributes
1449                .insert("alias_nullable".into(), Value::Bool(imported.nullable));
1450            model.attributes.extend(schema_runtime_attributes(schema));
1451            if let Some(title) = &schema.title {
1452                model
1453                    .attributes
1454                    .insert("title".into(), Value::String(title.clone()));
1455            }
1456            return Ok(model);
1457        }
1458
1459        let empty_properties = IndexMap::new();
1460        let properties = schema.properties.as_ref().unwrap_or(&empty_properties);
1461        let required: BTreeSet<&str> = schema
1462            .required
1463            .iter()
1464            .flat_map(|items| items.iter().map(String::as_str))
1465            .collect();
1466
1467        let mut model = Model::new(format!("model.{}", to_snake_case(name)), name.to_owned());
1468        model.source = Some(SourceRef {
1469            pointer: pointer.to_owned(),
1470            line: None,
1471        });
1472        if let Some(title) = &schema.title {
1473            model
1474                .attributes
1475                .insert("title".into(), Value::String(title.clone()));
1476        }
1477
1478        let mut unnamed_field_counter = 0usize;
1479        for (field_name, property_schema_or_bool) in properties {
1480            // Boolean schemas (OpenAPI 3.1: `false`/`true`) have no codegen meaning — skip.
1481            let Some(property_schema) = property_schema_or_bool.as_schema() else {
1482                continue;
1483            };
1484            let original_field_name = field_name.clone();
1485            let field_name = self.normalize_field_name(
1486                field_name.clone(),
1487                &format!("{pointer}/properties"),
1488                &mut unnamed_field_counter,
1489            )?;
1490            let imported = self.import_schema_type(
1491                property_schema,
1492                &InlineModelContext::Field {
1493                    model_name: name.to_owned(),
1494                    field_name: original_field_name.clone(),
1495                    pointer: format!(
1496                        "{}/properties/{}",
1497                        pointer,
1498                        json_pointer_key(&original_field_name)
1499                    ),
1500                },
1501            )?;
1502            let mut field = Field::new(
1503                field_name.clone(),
1504                imported
1505                    .type_ref
1506                    .unwrap_or_else(|| TypeRef::primitive("any")),
1507            );
1508            field.optional = !required.contains(original_field_name.as_str());
1509            field.nullable = imported.nullable;
1510            field.attributes = schema_runtime_attributes(property_schema);
1511            model.fields.push(field);
1512        }
1513
1514        Ok(model)
1515    }
1516
1517    fn build_object_model_from_all_of(
1518        &mut self,
1519        name: &str,
1520        schema: &Schema,
1521        pointer: &str,
1522    ) -> Result<Model> {
1523        let view = self.collect_object_schema_view(schema, pointer)?;
1524        let required = view.required;
1525
1526        let mut model = Model::new(format!("model.{}", to_snake_case(name)), name.to_owned());
1527        model.source = Some(SourceRef {
1528            pointer: pointer.to_owned(),
1529            line: None,
1530        });
1531        if let Some(title) = view.title {
1532            model
1533                .attributes
1534                .insert("title".into(), Value::String(title));
1535        }
1536
1537        let mut unnamed_field_counter = 0usize;
1538        for (field_name, property_schema) in view.properties {
1539            let original_field_name = field_name.clone();
1540            let field_name = self.normalize_field_name(
1541                field_name,
1542                &format!("{pointer}/properties"),
1543                &mut unnamed_field_counter,
1544            )?;
1545            let imported = self.import_schema_type(
1546                &property_schema,
1547                &InlineModelContext::Field {
1548                    model_name: name.to_owned(),
1549                    field_name: original_field_name.clone(),
1550                    pointer: format!(
1551                        "{}/properties/{}",
1552                        pointer,
1553                        json_pointer_key(&original_field_name)
1554                    ),
1555                },
1556            )?;
1557            let mut field = Field::new(
1558                field_name.clone(),
1559                imported
1560                    .type_ref
1561                    .unwrap_or_else(|| TypeRef::primitive("any")),
1562            );
1563            field.optional = !required.contains(original_field_name.as_str());
1564            field.nullable = imported.nullable;
1565            field.attributes = schema_runtime_attributes(&property_schema);
1566            model.fields.push(field);
1567        }
1568
1569        Ok(model)
1570    }
1571
1572    fn import_schema_type(
1573        &mut self,
1574        schema: &Schema,
1575        context: &InlineModelContext,
1576    ) -> Result<ImportedType> {
1577        self.import_schema_type_inner(schema, context, false)
1578    }
1579
1580    fn import_schema_type_inner(
1581        &mut self,
1582        schema: &Schema,
1583        context: &InlineModelContext,
1584        skip_keyword_validation: bool,
1585    ) -> Result<ImportedType> {
1586        let local_reference = schema
1587            .reference
1588            .as_deref()
1589            .filter(|reference| is_inline_local_schema_reference(reference))
1590            .map(ToOwned::to_owned);
1591        if let Some(imported) = self.import_decorated_reference_type(schema, context)? {
1592            return Ok(imported);
1593        }
1594        let schema = self.normalize_schema(schema, &context.describe())?;
1595        self.import_schema_type_normalized(
1596            schema.as_ref(),
1597            context,
1598            skip_keyword_validation,
1599            local_reference.as_deref(),
1600        )
1601    }
1602
1603    fn import_decorated_reference_type(
1604        &mut self,
1605        schema: &Schema,
1606        context: &InlineModelContext,
1607    ) -> Result<Option<ImportedType>> {
1608        if matches!(context, InlineModelContext::NamedSchema { .. }) {
1609            return Ok(None);
1610        }
1611
1612        let all_of = match &schema.all_of {
1613            Some(all_of) => all_of,
1614            None => return Ok(None),
1615        };
1616
1617        if schema_has_non_all_of_shape(schema) {
1618            return Ok(None);
1619        }
1620
1621        let mut reference: Option<&str> = None;
1622        for member in all_of {
1623            if let Some(member_ref) = member.reference.as_deref() {
1624                if reference.replace(member_ref).is_some() {
1625                    return Ok(None);
1626                }
1627                continue;
1628            }
1629
1630            if !is_unconstrained_schema(member) {
1631                return Ok(None);
1632            }
1633        }
1634
1635        let Some(reference) = reference else {
1636            return Ok(None);
1637        };
1638
1639        Ok(Some(ImportedType {
1640            type_ref: Some(TypeRef::named(ref_name(reference)?)),
1641            nullable: false,
1642        }))
1643    }
1644
1645    fn import_schema_type_normalized(
1646        &mut self,
1647        schema: &Schema,
1648        context: &InlineModelContext,
1649        skip_keyword_validation: bool,
1650        local_reference: Option<&str>,
1651    ) -> Result<ImportedType> {
1652        if !skip_keyword_validation {
1653            self.validate_schema_keywords(schema, &context.describe())?;
1654        }
1655
1656        if let Some(reference) = &schema.reference {
1657            if is_inline_local_schema_reference(reference) {
1658                if self.active_local_ref_imports.contains(reference) {
1659                    let model_name = self
1660                        .local_ref_model_names
1661                        .get(reference)
1662                        .cloned()
1663                        .unwrap_or_else(|| {
1664                            to_pascal_case(
1665                                &ref_name(reference).unwrap_or_else(|_| "RecursiveModel".into()),
1666                            )
1667                        });
1668                    return Ok(ImportedType::plain(TypeRef::named(model_name)));
1669                }
1670
1671                self.active_local_ref_imports.insert(reference.clone());
1672                // Use the cycle-safe resolve+expand path so that allOf schemas
1673                // (e.g. `{allOf: [{$ref: "..."}]}`) are fully shaped before
1674                // import_schema_type_normalized sees them, without risking
1675                // infinite recursion on self-referential schemas.
1676                let resolved =
1677                    self.resolve_schema_reference_for_all_of(reference, &context.describe())?;
1678                if schema_is_object_like(&resolved) {
1679                    let already_registered = self.local_ref_model_names.contains_key(reference);
1680                    if !already_registered {
1681                        let model_name = self.inline_model_name(&resolved, context);
1682                        self.local_ref_model_names
1683                            .insert(reference.clone(), model_name);
1684                    } else {
1685                        // Model was already imported from a previous (non-recursive) call site.
1686                        // Return a named reference immediately to avoid re-processing the full
1687                        // expanded schema from each call site, which would create exponentially
1688                        // many inline models for mutually-referential schemas (e.g. Azure specs).
1689                        let model_name = self.local_ref_model_names[reference].clone();
1690                        self.active_local_ref_imports.remove(reference);
1691                        return Ok(ImportedType::plain(TypeRef::named(model_name)));
1692                    }
1693                }
1694                let result = self.import_schema_type_normalized(
1695                    &resolved,
1696                    context,
1697                    skip_keyword_validation,
1698                    Some(reference),
1699                );
1700                self.active_local_ref_imports.remove(reference);
1701                return result;
1702            }
1703            return Ok(ImportedType {
1704                type_ref: Some(TypeRef::named(ref_name(reference)?)),
1705                nullable: false,
1706            });
1707        }
1708
1709        if let Some(const_value) = &schema.const_value {
1710            return self.import_const_type(&schema, const_value, context);
1711        }
1712
1713        if schema_is_object_like(schema)
1714            && schema
1715                .any_of
1716                .as_ref()
1717                .is_some_and(|variants| variants.iter().all(is_validation_only_schema_variant))
1718        {
1719            return self.import_object_type(schema, context, local_reference);
1720        }
1721
1722        if let Some(any_of) = &schema.any_of {
1723            return self.import_any_of(any_of, context);
1724        }
1725
1726        if schema_is_object_like(schema)
1727            && schema
1728                .one_of
1729                .as_ref()
1730                .is_some_and(|variants| variants.iter().all(is_validation_only_schema_variant))
1731        {
1732            return self.import_object_type(schema, context, local_reference);
1733        }
1734
1735        if let Some(one_of) = &schema.one_of {
1736            return self.import_any_of(one_of, context);
1737        }
1738
1739        if let Some(imported) = self.import_implicit_schema_type(schema, context)? {
1740            return Ok(imported);
1741        }
1742
1743        if let Some(imported) = self.import_schema_type_from_decl(&schema, context)? {
1744            return Ok(imported);
1745        }
1746
1747        if is_unconstrained_schema(&schema) {
1748            return Ok(ImportedType::plain(TypeRef::primitive("any")));
1749        }
1750
1751        if schema.properties.is_some() || schema.additional_properties.is_some() {
1752            return self.import_object_type(&schema, context, local_reference);
1753        }
1754
1755        self.handle_unhandled(&context.describe(), DiagnosticKind::UnsupportedSchemaShape)?;
1756        Ok(ImportedType::plain(TypeRef::primitive("any")))
1757    }
1758
1759    fn import_schema_type_from_decl(
1760        &mut self,
1761        schema: &Schema,
1762        context: &InlineModelContext,
1763    ) -> Result<Option<ImportedType>> {
1764        let Some(schema_types) = &schema.schema_type else {
1765            return Ok(None);
1766        };
1767
1768        if let Some(embedded) = schema_types.embedded_schema() {
1769            return Ok(Some(self.import_schema_type(embedded, context)?));
1770        }
1771
1772        let variants = schema_types.as_slice();
1773        if variants.len() == 1 {
1774            let schema_type = variants[0].as_str();
1775            return Ok(Some(match schema_type {
1776                "string" => {
1777                    if schema.format.as_deref() == Some("binary") {
1778                        ImportedType::plain(TypeRef::primitive("binary"))
1779                    } else {
1780                        ImportedType::plain(TypeRef::primitive("string"))
1781                    }
1782                }
1783                "integer" => ImportedType::plain(TypeRef::primitive("integer")),
1784                "number" => ImportedType::plain(TypeRef::primitive("number")),
1785                "boolean" => ImportedType::plain(TypeRef::primitive("boolean")),
1786                "array" => {
1787                    match schema.items.as_ref() {
1788                        Some(item_schema) => {
1789                            let imported = self.import_schema_type(item_schema, context)?;
1790                            ImportedType::plain(TypeRef::array(
1791                                imported
1792                                    .type_ref
1793                                    .unwrap_or_else(|| TypeRef::primitive("any")),
1794                            ))
1795                        }
1796                        // JSON Schema: array without `items` means array of any.
1797                        None => ImportedType::plain(TypeRef::array(TypeRef::primitive("any"))),
1798                    }
1799                }
1800                "object" => self.import_object_type(schema, context, None)?,
1801                "file" => ImportedType::plain(TypeRef::primitive("binary")),
1802                "null" => ImportedType {
1803                    type_ref: Some(TypeRef::primitive("any")),
1804                    nullable: true,
1805                },
1806                other => {
1807                    self.handle_unhandled(
1808                        &context.describe(),
1809                        DiagnosticKind::UnsupportedSchemaType {
1810                            schema_type: other.to_owned(),
1811                        },
1812                    )?;
1813                    ImportedType::plain(TypeRef::primitive("any"))
1814                }
1815            }));
1816        }
1817
1818        let mut nullable = false;
1819        let mut type_refs = Vec::new();
1820        for schema_type in variants {
1821            match schema_type.as_str() {
1822                "null" => nullable = true,
1823                other => {
1824                    let mut synthetic = schema.clone();
1825                    synthetic.schema_type = Some(SchemaTypeDecl::Single(other.to_owned()));
1826                    let imported = self
1827                        .import_schema_type_from_decl(&synthetic, context)?
1828                        .expect("single schema type should import");
1829                    if imported.nullable {
1830                        nullable = true;
1831                    }
1832                    if let Some(type_ref) = imported.type_ref {
1833                        type_refs.push(type_ref);
1834                    }
1835                }
1836            }
1837        }
1838
1839        let type_refs = dedupe_variants(type_refs);
1840        let type_ref = match type_refs.len() {
1841            0 => Some(TypeRef::primitive("any")),
1842            1 => type_refs.into_iter().next(),
1843            _ => Some(TypeRef::Union {
1844                variants: type_refs,
1845            }),
1846        };
1847
1848        Ok(Some(ImportedType { type_ref, nullable }))
1849    }
1850
1851    fn import_implicit_schema_type(
1852        &mut self,
1853        schema: &Schema,
1854        context: &InlineModelContext,
1855    ) -> Result<Option<ImportedType>> {
1856        if let Some(enum_values) = &schema.enum_values {
1857            let inferred = infer_enum_type(enum_values, schema.format.as_deref());
1858            return Ok(Some(ImportedType {
1859                type_ref: Some(inferred),
1860                nullable: false,
1861            }));
1862        }
1863
1864        if schema.items.is_some() {
1865            let item_schema = schema.items.as_ref().expect("checked is_some");
1866            let imported = self.import_schema_type(item_schema, context)?;
1867            return Ok(Some(ImportedType::plain(TypeRef::array(
1868                imported
1869                    .type_ref
1870                    .unwrap_or_else(|| TypeRef::primitive("any")),
1871            ))));
1872        }
1873
1874        if let Some(type_ref) = infer_format_only_type(schema.format.as_deref()) {
1875            return Ok(Some(ImportedType::plain(type_ref)));
1876        }
1877
1878        // Format present but unrecognized by type inference (e.g. a human-readable
1879        // sentence used as the format value). Treat the schema as unconstrained
1880        // rather than failing with an unsupported-shape error.
1881        if schema.format.is_some() {
1882            return Ok(Some(ImportedType::plain(TypeRef::primitive("any"))));
1883        }
1884
1885        Ok(None)
1886    }
1887
1888    fn validate_schema_keywords(&mut self, schema: &Schema, context: &str) -> Result<()> {
1889        for keyword in schema.extra_keywords.keys() {
1890            if is_known_ignored_schema_keyword(keyword) || keyword.starts_with("x-") {
1891                continue;
1892            }
1893
1894            if is_known_but_unimplemented_schema_keyword(keyword) {
1895                self.handle_unhandled(
1896                    context,
1897                    DiagnosticKind::UnsupportedSchemaKeyword {
1898                        keyword: keyword.clone(),
1899                    },
1900                )?;
1901                continue;
1902            }
1903
1904            self.handle_unhandled(
1905                context,
1906                DiagnosticKind::UnknownSchemaKeyword {
1907                    keyword: keyword.clone(),
1908                },
1909            )?;
1910        }
1911
1912        Ok(())
1913    }
1914
1915    fn normalize_schema<'a>(
1916        &mut self,
1917        schema: &'a Schema,
1918        context: &str,
1919    ) -> Result<Cow<'a, Schema>> {
1920        if schema.all_of.is_none() {
1921            return Ok(Cow::Borrowed(schema));
1922        }
1923
1924        let normalized = self.expand_all_of_schema(schema, context)?;
1925        Ok(Cow::Owned(normalized))
1926    }
1927
1928    fn expand_all_of_schema(&mut self, schema: &Schema, context: &str) -> Result<Schema> {
1929        let mut merged = Schema {
1930            all_of: None,
1931            ..schema.clone()
1932        };
1933
1934        for member in schema.all_of.clone().unwrap_or_default() {
1935            let resolved_member = self.resolve_schema_for_merge(&member, context)?;
1936            merged = self.merge_schemas(merged, resolved_member, context)?;
1937        }
1938
1939        Ok(merged)
1940    }
1941
1942    fn resolve_schema_for_merge(&mut self, schema: &Schema, context: &str) -> Result<Schema> {
1943        let mut resolved = if let Some(reference) = &schema.reference {
1944            self.resolve_schema_reference_for_all_of(reference, context)?
1945        } else {
1946            schema.clone()
1947        };
1948
1949        if resolved.all_of.is_some() {
1950            resolved = self.expand_all_of_schema(&resolved, context)?;
1951        }
1952
1953        if schema.reference.is_some() {
1954            let mut overlay = schema.clone();
1955            overlay.reference = None;
1956            overlay.all_of = None;
1957            resolved = self.merge_schemas(resolved, overlay, context)?;
1958        }
1959
1960        Ok(resolved)
1961    }
1962
1963    fn resolve_schema_reference_for_all_of(
1964        &mut self,
1965        reference: &str,
1966        context: &str,
1967    ) -> Result<Schema> {
1968        if let Some(cached) = self.normalized_all_of_refs.get(reference) {
1969            return Ok(cached.clone());
1970        }
1971
1972        if self.active_all_of_refs.iter().any(|item| item == reference) {
1973            self.handle_unhandled(
1974                context,
1975                DiagnosticKind::AllOfRecursiveCycle {
1976                    reference: reference.to_owned(),
1977                },
1978            )?;
1979            return Ok(Schema::default());
1980        }
1981
1982        self.active_all_of_refs.push(reference.to_owned());
1983        let result: Result<Schema> = (|| {
1984            let mut resolved = self.resolve_schema_reference(reference)?;
1985            if resolved.all_of.is_some() {
1986                resolved = self.expand_all_of_schema(&resolved, reference)?;
1987            }
1988            Ok(resolved)
1989        })();
1990        self.active_all_of_refs.pop();
1991
1992        let resolved = result?;
1993        self.normalized_all_of_refs
1994            .insert(reference.to_owned(), resolved.clone());
1995        Ok(resolved)
1996    }
1997
1998    fn resolve_schema_reference(&self, reference: &str) -> Result<Schema> {
1999        let Some(pointer) = reference.strip_prefix("#/") else {
2000            bail!("unsupported reference `{reference}`");
2001        };
2002        let segments = pointer
2003            .split('/')
2004            .map(decode_json_pointer_segment)
2005            .collect::<Result<Vec<_>>>()?;
2006        enum ResolvedSchemaRef<'a> {
2007            Borrowed(&'a Schema),
2008            Owned(Schema),
2009        }
2010
2011        let (resolved, remainder): (ResolvedSchemaRef<'_>, &[String]) = match segments.as_slice() {
2012            [root, collection, name, rest @ ..]
2013                if root == "components" && collection == "schemas" =>
2014            {
2015                (
2016                    ResolvedSchemaRef::Borrowed(
2017                        self.document
2018                            .components
2019                            .schemas
2020                            .get(name)
2021                            .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?,
2022                    ),
2023                    rest,
2024                )
2025            }
2026            [root, name, rest @ ..] if root == "definitions" => (
2027                ResolvedSchemaRef::Borrowed(
2028                    self.document
2029                        .definitions
2030                        .get(name)
2031                        .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?,
2032                ),
2033                rest,
2034            ),
2035            [root, collection, name, schema_segment, rest @ ..]
2036                if root == "components"
2037                    && collection == "parameters"
2038                    && schema_segment == "schema" =>
2039            {
2040                (
2041                    ResolvedSchemaRef::Owned(
2042                        self.document
2043                            .components
2044                            .parameters
2045                            .get(name)
2046                            .and_then(ParameterSpec::effective_schema)
2047                            .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?,
2048                    ),
2049                    rest,
2050                )
2051            }
2052            [root, name, schema_segment, rest @ ..]
2053                if root == "parameters" && schema_segment == "schema" =>
2054            {
2055                (
2056                    ResolvedSchemaRef::Owned(
2057                        self.document
2058                            .parameters
2059                            .get(name)
2060                            .and_then(ParameterSpec::effective_schema)
2061                            .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?,
2062                    ),
2063                    rest,
2064                )
2065            }
2066            // #/components/responses/{name} — use the first available schema
2067            [root, collection, name, rest @ ..]
2068                if root == "components" && collection == "responses" =>
2069            {
2070                let response = self
2071                    .document
2072                    .components
2073                    .responses
2074                    .get(name)
2075                    .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
2076                // `rest` may continue into content/{media_type}/schema/...
2077                // Resolve via the helper that understands response continuation.
2078                return resolve_response_schema_reference(response, rest, reference);
2079            }
2080            // #/paths/{path}/{method}/responses/{status}/content/{media}/schema
2081            // #/paths/{path}/{method}/responses/{status}
2082            [root, path, method, responses_key, status, rest @ ..]
2083                if root == "paths" && responses_key == "responses" =>
2084            {
2085                let operation = self
2086                    .document
2087                    .paths
2088                    .get(path)
2089                    .and_then(|item| match method.as_str() {
2090                        "get" => item.get.as_ref(),
2091                        "post" => item.post.as_ref(),
2092                        "put" => item.put.as_ref(),
2093                        "patch" => item.patch.as_ref(),
2094                        "delete" => item.delete.as_ref(),
2095                        _ => None,
2096                    })
2097                    .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
2098                let response_or_ref = operation
2099                    .responses
2100                    .get(status)
2101                    .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
2102                let response = self.resolve_response_spec(response_or_ref)?;
2103                return resolve_response_schema_reference(&response, rest, reference);
2104            }
2105            // #/paths/{path}/{method}/requestBody/content/{media_type}/schema/...
2106            [
2107                root,
2108                path,
2109                method,
2110                rb_key,
2111                content_key,
2112                media_type,
2113                schema_key,
2114                rest @ ..,
2115            ] if root == "paths"
2116                && rb_key == "requestBody"
2117                && content_key == "content"
2118                && schema_key == "schema" =>
2119            {
2120                let operation = self
2121                    .document
2122                    .paths
2123                    .get(path)
2124                    .and_then(|item| match method.as_str() {
2125                        "get" => item.get.as_ref(),
2126                        "post" => item.post.as_ref(),
2127                        "put" => item.put.as_ref(),
2128                        "patch" => item.patch.as_ref(),
2129                        "delete" => item.delete.as_ref(),
2130                        _ => None,
2131                    })
2132                    .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
2133                let request_body = match operation.request_body.as_ref() {
2134                    Some(RequestBodyOrRef::Inline(rb)) => rb,
2135                    _ => bail!("unsupported reference `{reference}`"),
2136                };
2137                let schema = request_body
2138                    .content
2139                    .get(media_type.as_str())
2140                    .and_then(|m| m.schema.as_ref())
2141                    .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
2142                return resolve_nested_schema_reference(schema, rest, reference);
2143            }
2144            // #/paths/{path}/{method}/parameters/{index}/schema/...
2145            [
2146                root,
2147                path,
2148                method,
2149                params_key,
2150                index_str,
2151                schema_key,
2152                rest @ ..,
2153            ] if root == "paths" && params_key == "parameters" && schema_key == "schema" => {
2154                let idx: usize = index_str
2155                    .parse()
2156                    .map_err(|_| anyhow!("unsupported reference `{reference}`"))?;
2157                let path_item = self
2158                    .document
2159                    .paths
2160                    .get(path)
2161                    .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
2162                let operation = match method.as_str() {
2163                    "get" => path_item.get.as_ref(),
2164                    "post" => path_item.post.as_ref(),
2165                    "put" => path_item.put.as_ref(),
2166                    "patch" => path_item.patch.as_ref(),
2167                    "delete" => path_item.delete.as_ref(),
2168                    _ => None,
2169                }
2170                .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
2171                let param_spec = operation
2172                    .parameters
2173                    .get(idx)
2174                    .or_else(|| {
2175                        path_item
2176                            .parameters
2177                            .as_ref()
2178                            .and_then(|params| params.get(idx))
2179                    })
2180                    .and_then(|p| match p {
2181                        ParameterOrRef::Inline(spec) => Some(spec),
2182                        _ => None,
2183                    })
2184                    .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
2185                let schema = param_spec
2186                    .effective_schema()
2187                    .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
2188                return resolve_nested_schema_reference(&schema, rest, reference);
2189            }
2190            _ => bail!("unsupported reference `{reference}`"),
2191        };
2192
2193        let schema = match &resolved {
2194            ResolvedSchemaRef::Borrowed(schema) => *schema,
2195            ResolvedSchemaRef::Owned(schema) => schema,
2196        };
2197        resolve_nested_schema_reference(schema, remainder, reference)
2198    }
2199
2200    fn reserve_operation_name(&mut self, base: String) -> String {
2201        if self.generated_operation_names.insert(base.clone()) {
2202            return base;
2203        }
2204
2205        let mut counter = 2usize;
2206        loop {
2207            let candidate = format!("{base}_{counter}");
2208            if self.generated_operation_names.insert(candidate.clone()) {
2209                return candidate;
2210            }
2211            counter += 1;
2212        }
2213    }
2214
2215    fn normalize_field_name(
2216        &mut self,
2217        field_name: String,
2218        context: &str,
2219        unnamed_field_counter: &mut usize,
2220    ) -> Result<String> {
2221        if !field_name.trim().is_empty() {
2222            return Ok(field_name);
2223        }
2224
2225        *unnamed_field_counter += 1;
2226        // Point at the specific empty-string key within the properties mapping so
2227        // the source preview and line number resolve to the offending `"":` entry
2228        // rather than the `properties:` block as a whole.
2229        let specific_pointer = format!("{}/{}", context, json_pointer_key(&field_name));
2230        self.handle_unhandled(
2231            &specific_pointer,
2232            DiagnosticKind::EmptyPropertyKey {
2233                counter: *unnamed_field_counter,
2234            },
2235        )?;
2236        Ok(format!("unnamed_field_{}", unnamed_field_counter))
2237    }
2238
2239    fn merge_schemas(
2240        &mut self,
2241        mut base: Schema,
2242        overlay: Schema,
2243        context: &str,
2244    ) -> Result<Schema> {
2245        let inferred_base_type = infer_schema_type_for_merge(&base);
2246        let inferred_overlay_type = infer_schema_type_for_merge(&overlay);
2247        let base_is_generic_object_placeholder = is_generic_object_placeholder(&base);
2248        let overlay_is_generic_object_placeholder = is_generic_object_placeholder(&overlay);
2249        let base_schema_type = base.schema_type.take();
2250        let overlay_schema_type = overlay.schema_type.clone();
2251        merge_non_codegen_optional_field(&mut base.definitions, overlay.definitions);
2252        merge_non_codegen_optional_field(&mut base.title, overlay.title);
2253        merge_non_codegen_optional_field(&mut base.format, overlay.format);
2254        base.schema_type = merge_schema_types(
2255            inferred_base_type,
2256            inferred_overlay_type,
2257            base_is_generic_object_placeholder,
2258            overlay_is_generic_object_placeholder,
2259            base_schema_type,
2260            overlay_schema_type,
2261            context,
2262            self,
2263        )?;
2264        merge_optional_field(
2265            &mut base.const_value,
2266            overlay.const_value,
2267            "const",
2268            context,
2269            self,
2270        )?;
2271        merge_non_codegen_optional_field(&mut base._discriminator, overlay._discriminator);
2272        base.enum_values =
2273            merge_enum_values(base.enum_values.take(), overlay.enum_values, context, self)?;
2274        // incompatible anyOf/oneOf in allOf — keep the base side rather than erroring.
2275        merge_non_codegen_optional_field(&mut base.any_of, overlay.any_of);
2276        merge_non_codegen_optional_field(&mut base.one_of, overlay.one_of);
2277
2278        let base_required = base.required.take();
2279        let overlay_required = overlay.required;
2280        base.required = match (base_required, overlay_required) {
2281            (None, None) => None,
2282            (left, right) => Some(merge_required(
2283                left.unwrap_or_default(),
2284                right.unwrap_or_default(),
2285            )),
2286        };
2287
2288        match (base.items.take(), overlay.items) {
2289            (Some(left), Some(right)) => {
2290                base.items = Some(Box::new(self.merge_schemas(*left, *right, context)?));
2291            }
2292            (Some(left), None) => base.items = Some(left),
2293            (None, Some(right)) => base.items = Some(right),
2294            (None, None) => {}
2295        }
2296
2297        match (
2298            base.additional_properties.take(),
2299            overlay.additional_properties,
2300        ) {
2301            (
2302                Some(AdditionalProperties::Schema(left)),
2303                Some(AdditionalProperties::Schema(right)),
2304            ) => {
2305                base.additional_properties = Some(AdditionalProperties::Schema(Box::new(
2306                    self.merge_schemas(*left, *right, context)?,
2307                )));
2308            }
2309            (Some(AdditionalProperties::Bool(left)), Some(AdditionalProperties::Bool(right)))
2310                if left == right =>
2311            {
2312                base.additional_properties = Some(AdditionalProperties::Bool(left));
2313            }
2314            (Some(value), None) => base.additional_properties = Some(value),
2315            (None, Some(value)) => base.additional_properties = Some(value),
2316            (Some(left), Some(_right)) => {
2317                // Keep the left side; incompatible additionalProperties in allOf
2318                // is an under-specified schema — prefer the more descriptive branch.
2319                base.additional_properties = Some(left);
2320            }
2321            (None, None) => {}
2322        }
2323
2324        let base_properties = base.properties.take();
2325        let overlay_properties = overlay.properties;
2326        base.properties = match (base_properties, overlay_properties) {
2327            (None, None) => None,
2328            (left, right) => Some(merge_properties(
2329                self,
2330                left.unwrap_or_default(),
2331                right.unwrap_or_default(),
2332                context,
2333            )?),
2334        };
2335
2336        for (key, value) in overlay.extra_keywords {
2337            match base.extra_keywords.get(&key) {
2338                Some(existing) if existing != &value => {
2339                    if is_known_ignored_schema_keyword(&key) || key.starts_with("x-") {
2340                        continue;
2341                    }
2342                    self.handle_unhandled(
2343                        context,
2344                        DiagnosticKind::IncompatibleAllOfField { field: key.clone() },
2345                    )?;
2346                }
2347                Some(_) => {}
2348                None => {
2349                    base.extra_keywords.insert(key, value);
2350                }
2351            }
2352        }
2353
2354        Ok(base)
2355    }
2356
2357    fn collect_object_schema_view(
2358        &mut self,
2359        schema: &Schema,
2360        context: &str,
2361    ) -> Result<ObjectSchemaView> {
2362        let mut view = ObjectSchemaView::default();
2363        self.collect_object_schema_view_into(schema, context, &mut view)?;
2364        Ok(view)
2365    }
2366
2367    fn collect_object_schema_view_into(
2368        &mut self,
2369        schema: &Schema,
2370        context: &str,
2371        view: &mut ObjectSchemaView,
2372    ) -> Result<()> {
2373        self.validate_schema_keywords(schema, context)?;
2374
2375        if let Some(reference) = &schema.reference {
2376            if self
2377                .active_object_view_refs
2378                .iter()
2379                .any(|item| item == reference)
2380            {
2381                self.handle_unhandled(
2382                    context,
2383                    DiagnosticKind::AllOfRecursiveCycle {
2384                        reference: reference.clone(),
2385                    },
2386                )?;
2387                return Ok(());
2388            }
2389
2390            self.active_object_view_refs.push(reference.clone());
2391            let resolved = self.resolve_schema_reference(reference)?;
2392            self.collect_object_schema_view_into(&resolved, reference, view)?;
2393            self.active_object_view_refs.pop();
2394        }
2395
2396        if let Some(members) = &schema.all_of {
2397            for member in members {
2398                self.collect_object_schema_view_into(member, context, view)?;
2399            }
2400        }
2401
2402        merge_non_codegen_optional_field(&mut view.title, schema.title.clone());
2403
2404        if let Some(required) = &schema.required {
2405            view.required.extend(required.iter().cloned());
2406        }
2407
2408        if let Some(properties) = &schema.properties {
2409            for (field_name, property_schema_or_bool) in properties {
2410                // Skip boolean schemas — they have no fields to contribute.
2411                let Some(property_schema) = property_schema_or_bool.as_schema() else {
2412                    continue;
2413                };
2414                if let Some(existing) = view.properties.shift_remove(field_name) {
2415                    view.properties.insert(
2416                        field_name.clone(),
2417                        self.merge_schemas(existing, property_schema.clone(), context)?,
2418                    );
2419                } else {
2420                    view.properties
2421                        .insert(field_name.clone(), property_schema.clone());
2422                }
2423            }
2424        }
2425
2426        Ok(())
2427    }
2428
2429    fn import_const_type(
2430        &mut self,
2431        schema: &Schema,
2432        const_value: &Value,
2433        context: &InlineModelContext,
2434    ) -> Result<ImportedType> {
2435        if let Some(schema_type) = schema.primary_schema_type() {
2436            let imported = match schema_type {
2437                "string" => {
2438                    if schema.format.as_deref() == Some("binary") {
2439                        ImportedType::plain(TypeRef::primitive("binary"))
2440                    } else {
2441                        ImportedType::plain(TypeRef::primitive("string"))
2442                    }
2443                }
2444                "integer" => ImportedType::plain(TypeRef::primitive("integer")),
2445                "number" => ImportedType::plain(TypeRef::primitive("number")),
2446                "boolean" => ImportedType::plain(TypeRef::primitive("boolean")),
2447                "null" => ImportedType {
2448                    type_ref: Some(TypeRef::primitive("any")),
2449                    nullable: true,
2450                },
2451                "array" => {
2452                    match schema.items.as_ref() {
2453                        Some(item_schema) => {
2454                            let imported = self.import_schema_type(item_schema, context)?;
2455                            ImportedType::plain(TypeRef::array(
2456                                imported
2457                                    .type_ref
2458                                    .unwrap_or_else(|| TypeRef::primitive("any")),
2459                            ))
2460                        }
2461                        // JSON Schema: array without `items` means array of any.
2462                        None => ImportedType::plain(TypeRef::array(TypeRef::primitive("any"))),
2463                    }
2464                }
2465                "object" => self.import_object_type(schema, context, None)?,
2466                other => {
2467                    self.handle_unhandled(
2468                        &context.describe(),
2469                        DiagnosticKind::UnsupportedSchemaType {
2470                            schema_type: other.to_owned(),
2471                        },
2472                    )?;
2473                    ImportedType::plain(TypeRef::primitive("any"))
2474                }
2475            };
2476            return Ok(imported);
2477        }
2478
2479        let imported = match const_value {
2480            Value::String(_) => ImportedType::plain(TypeRef::primitive("string")),
2481            Value::Bool(_) => ImportedType::plain(TypeRef::primitive("boolean")),
2482            Value::Number(number) => {
2483                if number.is_i64() || number.is_u64() {
2484                    ImportedType::plain(TypeRef::primitive("integer"))
2485                } else {
2486                    ImportedType::plain(TypeRef::primitive("number"))
2487                }
2488            }
2489            Value::Null => ImportedType {
2490                type_ref: Some(TypeRef::primitive("any")),
2491                nullable: true,
2492            },
2493            Value::Array(_) => {
2494                if let Some(items) = &schema.items {
2495                    let imported = self.import_schema_type(items, context)?;
2496                    ImportedType::plain(TypeRef::array(
2497                        imported
2498                            .type_ref
2499                            .unwrap_or_else(|| TypeRef::primitive("any")),
2500                    ))
2501                } else {
2502                    ImportedType::plain(TypeRef::array(TypeRef::primitive("any")))
2503                }
2504            }
2505            Value::Object(_) => self.import_object_type(schema, context, None)?,
2506        };
2507
2508        Ok(imported)
2509    }
2510
2511    fn import_any_of(
2512        &mut self,
2513        schemas: &[Schema],
2514        context: &InlineModelContext,
2515    ) -> Result<ImportedType> {
2516        let mut variants = Vec::new();
2517        let mut nullable = false;
2518
2519        for schema in schemas {
2520            if schema.is_exact_null_type() {
2521                nullable = true;
2522                continue;
2523            }
2524
2525            let imported = self.import_schema_type(schema, context)?;
2526            if imported.nullable {
2527                nullable = true;
2528            }
2529            if let Some(type_ref) = imported.type_ref {
2530                variants.push(type_ref);
2531            }
2532        }
2533
2534        variants = dedupe_variants(variants);
2535        let type_ref = match variants.len() {
2536            0 => Some(TypeRef::primitive("any")),
2537            1 => variants.into_iter().next(),
2538            _ => Some(TypeRef::Union { variants }),
2539        };
2540
2541        Ok(ImportedType { type_ref, nullable })
2542    }
2543
2544    fn import_object_type(
2545        &mut self,
2546        schema: &Schema,
2547        context: &InlineModelContext,
2548        local_reference: Option<&str>,
2549    ) -> Result<ImportedType> {
2550        if let Some(additional_properties) = &schema.additional_properties {
2551            match additional_properties {
2552                AdditionalProperties::Schema(additional_properties) => {
2553                    let imported = self.import_schema_type(additional_properties, context)?;
2554                    return Ok(ImportedType::plain(TypeRef::map(
2555                        imported
2556                            .type_ref
2557                            .unwrap_or_else(|| TypeRef::primitive("any")),
2558                    )));
2559                }
2560                AdditionalProperties::Bool(true) => {
2561                    return Ok(ImportedType::plain(TypeRef::map(TypeRef::primitive("any"))));
2562                }
2563                AdditionalProperties::Bool(false) => {}
2564            }
2565        }
2566
2567        if schema.properties.is_some() {
2568            let model_name = if let Some(reference) = local_reference {
2569                self.local_ref_model_names
2570                    .get(reference)
2571                    .cloned()
2572                    .unwrap_or_else(|| {
2573                        let model_name = self.inline_model_name(schema, context);
2574                        self.local_ref_model_names
2575                            .insert(reference.to_owned(), model_name.clone());
2576                        model_name
2577                    })
2578            } else {
2579                self.inline_model_name(schema, context)
2580            };
2581
2582            if self.models.contains_key(&model_name)
2583                || self.active_model_builds.contains(&model_name)
2584            {
2585                return Ok(ImportedType::plain(TypeRef::named(model_name)));
2586            }
2587
2588            self.active_model_builds.insert(model_name.clone());
2589            if !self.models.contains_key(&model_name) {
2590                let pointer = context.synthetic_pointer(&model_name);
2591                let build_result = self.build_model_from_schema(&model_name, schema, &pointer);
2592                self.active_model_builds.remove(&model_name);
2593                let model = build_result?;
2594                self.generated_model_names.insert(model_name.clone());
2595                self.models.insert(model_name.clone(), model);
2596            } else {
2597                self.active_model_builds.remove(&model_name);
2598            }
2599            return Ok(ImportedType::plain(TypeRef::named(model_name)));
2600        }
2601
2602        Ok(ImportedType::plain(TypeRef::primitive("object")))
2603    }
2604
2605    fn inline_model_name(&mut self, schema: &Schema, context: &InlineModelContext) -> String {
2606        let base = schema.title.clone().unwrap_or_else(|| context.name_hint());
2607        let candidate = to_pascal_case(&base);
2608        if self.generated_model_names.insert(candidate.clone()) {
2609            return candidate;
2610        }
2611
2612        let mut index = 2usize;
2613        loop {
2614            let candidate = format!("{candidate}{index}");
2615            if self.generated_model_names.insert(candidate.clone()) {
2616                return candidate;
2617            }
2618            index += 1;
2619        }
2620    }
2621
2622    fn handle_unhandled(&mut self, context: &str, kind: DiagnosticKind) -> Result<()> {
2623        let diagnostic = self.make_diagnostic(context, kind);
2624        if self.options.ignore_unhandled {
2625            self.warnings.push(diagnostic);
2626            Ok(())
2627        } else {
2628            Err(anyhow::Error::new(diagnostic))
2629        }
2630    }
2631
2632    /// Build an [`OpenApiDiagnostic`] from a context string (either a JSON
2633    /// pointer starting with `#/` or a human-readable label like
2634    /// `"parameter \`foo\`"`).
2635    fn make_diagnostic(&self, context: &str, kind: DiagnosticKind) -> OpenApiDiagnostic {
2636        if context.starts_with("#/") {
2637            let (preview, line) = self.source.pointer_info(context);
2638            OpenApiDiagnostic::from_pointer(kind, context, preview, line)
2639        } else {
2640            OpenApiDiagnostic::from_named_context(kind, context)
2641        }
2642    }
2643
2644    /// Build a pointer diagnostic using the importer's source for preview
2645    /// rendering.
2646    fn make_pointer_diagnostic(&self, pointer: &str, kind: DiagnosticKind) -> OpenApiDiagnostic {
2647        let (preview, line) = self.source.pointer_info(pointer);
2648        OpenApiDiagnostic::from_pointer(kind, pointer, preview, line)
2649    }
2650}
2651
2652#[derive(Debug)]
2653struct LoadedOpenApiDocument {
2654    document: OpenApiDocument,
2655    source: OpenApiSource,
2656}
2657
2658#[derive(Debug)]
2659struct OpenApiSource {
2660    format: SourceFormat,
2661    raw: String,
2662    value: OnceLock<Option<Value>>,
2663    /// Exact pointer → 1-based line map, built lazily from the YAML event stream.
2664    /// `None` means the crate is JSON (uses heuristic instead) or YAML parsing failed.
2665    line_map: OnceLock<Option<HashMap<String, usize>>>,
2666}
2667
2668#[derive(Debug, Clone, Copy)]
2669enum SourceFormat {
2670    Json,
2671    Yaml,
2672}
2673
2674impl OpenApiSource {
2675    fn new(format: SourceFormat, raw: String) -> Self {
2676        Self {
2677            format,
2678            raw,
2679            value: OnceLock::new(),
2680            line_map: OnceLock::new(),
2681        }
2682    }
2683
2684    fn render_pointer_preview(&self, pointer: &str) -> Option<String> {
2685        let node = self
2686            .value
2687            .get_or_init(|| self.parse_value())
2688            .as_ref()?
2689            .pointer(pointer.strip_prefix('#').unwrap_or(pointer))?;
2690        let rendered = match self.format {
2691            SourceFormat::Json => serde_json::to_string_pretty(node).ok()?,
2692            SourceFormat::Yaml => serde_yaml::to_string(node).ok()?,
2693        };
2694        Some(truncate_preview(&rendered, 10))
2695    }
2696
2697    /// Return `(preview_string, 1_based_line)` for the node at `pointer`.
2698    ///
2699    /// For YAML sources both values are derived together: we look up the exact
2700    /// key line from the event-stream map and then slice the raw text from that
2701    /// line, so the stored line and the start of the preview are always the
2702    /// same point in the file.  For JSON sources we fall back to the
2703    /// serde-rendered preview and a text-search heuristic line number.
2704    fn pointer_info(&self, pointer: &str) -> (Option<String>, Option<usize>) {
2705        match self.format {
2706            SourceFormat::Yaml => {
2707                let key = pointer.strip_prefix('#').unwrap_or(pointer);
2708                let line = self
2709                    .line_map
2710                    .get_or_init(|| Some(build_yaml_line_map(&self.raw)))
2711                    .as_ref()
2712                    .and_then(|m| m.get(key).copied());
2713                let preview = line.map(|l| self.raw_preview_from_line(l));
2714                (preview, line)
2715            }
2716            SourceFormat::Json => {
2717                let preview = self.render_pointer_preview(pointer);
2718                let line = self.resolve_pointer_line_heuristic(pointer);
2719                (preview, line)
2720            }
2721        }
2722    }
2723
2724    /// Slice `max_lines` raw source lines starting at 1-based `start_line`,
2725    /// dedented to remove the leading whitespace shared by all lines.
2726    fn raw_preview_from_line(&self, start_line: usize) -> String {
2727        const MAX_LINES: usize = 10;
2728        let lines: Vec<&str> = self
2729            .raw
2730            .lines()
2731            .skip(start_line.saturating_sub(1))
2732            .take(MAX_LINES)
2733            .collect();
2734        // Compute common leading-whitespace indent so the preview isn't
2735        // rendered with the full nesting depth.
2736        let indent = lines
2737            .iter()
2738            .filter(|l| !l.trim().is_empty())
2739            .map(|l| l.len() - l.trim_start().len())
2740            .min()
2741            .unwrap_or(0);
2742        let dedented: Vec<&str> = lines.iter().map(|l| &l[indent.min(l.len())..]).collect();
2743        dedented.join("\n")
2744    }
2745    /// Return the exact 1-based line number for the node identified by `pointer`.
2746    ///
2747    /// For YAML sources this is resolved via an exact pointer→line map produced
2748    /// by walking the YAML event stream (see [`build_yaml_line_map`]).  For JSON
2749    /// sources a best-effort text-search heuristic is used as a fallback.
2750    fn resolve_pointer_line(&self, pointer: &str) -> Option<usize> {
2751        // For YAML, pointer_info() is the unified entry point; this helper is
2752        // retained for callers that only need the line (e.g. make_diagnostic
2753        // routes through pointer_info directly).
2754        let key = pointer.strip_prefix('#').unwrap_or(pointer);
2755        match self.format {
2756            SourceFormat::Yaml => self
2757                .line_map
2758                .get_or_init(|| Some(build_yaml_line_map(&self.raw)))
2759                .as_ref()
2760                .and_then(|m| m.get(key).copied()),
2761            SourceFormat::Json => self.resolve_pointer_line_heuristic(pointer),
2762        }
2763    }
2764
2765    /// Text-search heuristic used for JSON sources (or as a last-resort fallback).
2766    fn resolve_pointer_line_heuristic(&self, pointer: &str) -> Option<usize> {
2767        let inner = pointer.strip_prefix('#').unwrap_or(pointer);
2768        let segments: Vec<String> = inner
2769            .split('/')
2770            .filter(|s| !s.is_empty())
2771            .map(|s| s.replace("~1", "/").replace("~0", "~"))
2772            .collect();
2773
2774        let lines: Vec<&str> = self.raw.lines().collect();
2775        let mut search_from = 0usize;
2776        let mut last_found: Option<usize> = None;
2777
2778        for segment in &segments {
2779            let yaml_pat = format!("{}:", segment);
2780            let json_pat = format!("\"{}\":", segment);
2781            for (idx, line) in lines.iter().enumerate().skip(search_from) {
2782                let trimmed = line.trim();
2783                if trimmed.starts_with(&yaml_pat) || trimmed.starts_with(&json_pat) {
2784                    last_found = Some(idx + 1);
2785                    search_from = idx + 1;
2786                    break;
2787                }
2788            }
2789        }
2790        last_found
2791    }
2792
2793    fn parse_value(&self) -> Option<Value> {
2794        match self.format {
2795            SourceFormat::Json => serde_json::from_str(&self.raw).ok(),
2796            SourceFormat::Yaml => {
2797                let yaml_value: serde_yaml::Value = serde_yaml::from_str(&self.raw).ok()?;
2798                serde_json::to_value(yaml_value).ok()
2799            }
2800        }
2801    }
2802}
2803
2804fn truncate_preview(rendered: &str, max_lines: usize) -> String {
2805    let lines = rendered.lines().collect::<Vec<_>>();
2806    if lines.len() <= max_lines {
2807        return rendered.to_owned();
2808    }
2809
2810    let mut output = lines
2811        .into_iter()
2812        .take(max_lines)
2813        .map(ToOwned::to_owned)
2814        .collect::<Vec<_>>();
2815    output.push("...".into());
2816    output.join("\n")
2817}
2818
2819/// Build an exact JSON-pointer → 1-based-line map by walking the YAML event
2820/// stream.  Every key scalar's line is recorded under the pointer formed by
2821/// appending that key (RFC 6901-encoded) to the parent pointer.  This gives
2822/// precise, heuristic-free location data for any depth, including empty-string
2823/// keys and numeric array indices.
2824fn build_yaml_line_map(raw: &str) -> HashMap<String, usize> {
2825    use yaml_rust2::parser::{Event, MarkedEventReceiver, Parser};
2826    use yaml_rust2::scanner::Marker;
2827
2828    enum Frame {
2829        Mapping {
2830            ptr: String,
2831            /// `true`  = next Scalar event is a key
2832            /// `false` = next event is the value for `pending_key`
2833            expecting_key: bool,
2834            pending_key: String,
2835            /// 1-based line of `pending_key`; used as the stored line for
2836            /// scalar values (key and value are on the same line).  Complex
2837            /// values (MappingStart/SequenceStart) use the event's own mark
2838            /// instead, which points at the first line of rendered content.
2839            pending_line: usize,
2840        },
2841        Sequence {
2842            ptr: String,
2843            index: usize,
2844        },
2845    }
2846
2847    struct Collector {
2848        stack: Vec<Frame>,
2849        map: HashMap<String, usize>,
2850    }
2851
2852    /// RFC 6901 segment encoding (~ → ~0, / → ~1).
2853    fn enc(key: &str) -> String {
2854        key.replace('~', "~0").replace('/', "~1")
2855    }
2856
2857    impl MarkedEventReceiver for Collector {
2858        fn on_event(&mut self, ev: Event, mark: Marker) {
2859            // yaml-rust2 Marker::line() is already 1-based.
2860            let line = mark.line();
2861
2862            match ev {
2863                // ── Mapping / Sequence start ───────────────────────────────
2864                Event::MappingStart(..) | Event::SequenceStart(..) => {
2865                    let is_mapping = matches!(ev, Event::MappingStart(..));
2866
2867                    // Phase 1: derive the child pointer from the parent frame.
2868                    // We compute owned Strings so the borrow on self.stack ends
2869                    // before we mutate self.map / self.stack below.
2870                    let (child_ptr, record_line) = match self.stack.last() {
2871                        None => (String::new(), None), // root node
2872                        Some(Frame::Mapping {
2873                            ptr,
2874                            expecting_key: false,
2875                            pending_key,
2876                            pending_line,
2877                        }) => {
2878                            // Record the KEY's line (`pending_line`) so that
2879                            // `raw_preview_from_line` starts exactly at the key
2880                            // (e.g. `"":`), which is what users and the frontend
2881                            // expect to see highlighted.
2882                            (format!("{}/{}", ptr, enc(pending_key)), Some(*pending_line))
2883                        }
2884                        Some(Frame::Sequence { ptr, index }) => {
2885                            (format!("{}/{}", ptr, index), Some(line))
2886                        }
2887                        // expecting_key == true here would mean a nested
2888                        // structure used as a mapping key — invalid YAML.
2889                        _ => return,
2890                    };
2891
2892                    // Phase 2: record, update parent, push child (no active
2893                    // borrow on self.stack from this point).
2894                    if let Some(l) = record_line {
2895                        self.map.insert(child_ptr.clone(), l);
2896                    }
2897                    match self.stack.last_mut() {
2898                        Some(Frame::Mapping { expecting_key, .. }) => *expecting_key = true,
2899                        Some(Frame::Sequence { index, .. }) => *index += 1,
2900                        None => {}
2901                    }
2902                    if is_mapping {
2903                        self.stack.push(Frame::Mapping {
2904                            ptr: child_ptr,
2905                            expecting_key: true,
2906                            pending_key: String::new(),
2907                            pending_line: 0,
2908                        });
2909                    } else {
2910                        self.stack.push(Frame::Sequence {
2911                            ptr: child_ptr,
2912                            index: 0,
2913                        });
2914                    }
2915                }
2916
2917                // ── Mapping / Sequence end ────────────────────────────────
2918                Event::MappingEnd | Event::SequenceEnd => {
2919                    self.stack.pop();
2920                }
2921
2922                // ── Scalar ────────────────────────────────────────────────
2923                Event::Scalar(value, ..) => {
2924                    // Phase 1: figure out what the scalar represents.
2925                    let is_key = matches!(
2926                        self.stack.last(),
2927                        Some(Frame::Mapping { expecting_key: true, .. })
2928                    );
2929                    let value_info: Option<(String, usize)> = if !is_key {
2930                        match self.stack.last() {
2931                            Some(Frame::Mapping {
2932                                ptr,
2933                                expecting_key: false,
2934                                pending_key,
2935                                pending_line,
2936                            }) => Some((format!("{}/{}", ptr, enc(pending_key)), *pending_line)),
2937                            Some(Frame::Sequence { ptr, index }) => {
2938                                Some((format!("{}/{}", ptr, index), line))
2939                            }
2940                            _ => None,
2941                        }
2942                    } else {
2943                        None
2944                    };
2945
2946                    // Phase 2: apply (borrows above have been released).
2947                    if is_key {
2948                        if let Some(Frame::Mapping {
2949                            expecting_key,
2950                            pending_key,
2951                            pending_line,
2952                            ..
2953                        }) = self.stack.last_mut()
2954                        {
2955                            *pending_key = value;
2956                            *pending_line = line;
2957                            *expecting_key = false;
2958                        }
2959                    } else if let Some((child_ptr, record_line)) = value_info {
2960                        self.map.insert(child_ptr, record_line);
2961                        match self.stack.last_mut() {
2962                            Some(Frame::Mapping { expecting_key, .. }) => *expecting_key = true,
2963                            Some(Frame::Sequence { index, .. }) => *index += 1,
2964                            _ => {}
2965                        }
2966                    }
2967                }
2968
2969                _ => {}
2970            }
2971        }
2972    }
2973
2974    let mut collector = Collector {
2975        stack: Vec::new(),
2976        map: HashMap::new(),
2977    };
2978    let mut parser = Parser::new(raw.chars());
2979    if parser.load(&mut collector, false).is_err() {
2980        return HashMap::new();
2981    }
2982    collector.map
2983}
2984
2985#[derive(Debug, Clone)]
2986struct ImportedType {
2987    type_ref: Option<TypeRef>,
2988    nullable: bool,
2989}
2990
2991impl ImportedType {
2992    fn plain(type_ref: TypeRef) -> Self {
2993        Self {
2994            type_ref: Some(type_ref),
2995            nullable: false,
2996        }
2997    }
2998}
2999
3000#[derive(Default)]
3001struct ObjectSchemaView {
3002    title: Option<String>,
3003    properties: IndexMap<String, Schema>,
3004    required: BTreeSet<String>,
3005}
3006
3007#[derive(Debug)]
3008enum InlineModelContext {
3009    NamedSchema {
3010        name: String,
3011        pointer: String,
3012    },
3013    Field {
3014        model_name: String,
3015        field_name: String,
3016        pointer: String,
3017    },
3018    RequestBody {
3019        operation_name: String,
3020        pointer: String,
3021    },
3022    Response {
3023        operation_name: String,
3024        status: String,
3025        pointer: String,
3026    },
3027    Parameter {
3028        name: String,
3029    },
3030}
3031
3032impl InlineModelContext {
3033    fn name_hint(&self) -> String {
3034        match self {
3035            Self::NamedSchema { name, .. } => name.clone(),
3036            Self::Field {
3037                model_name,
3038                field_name,
3039                ..
3040            } => format!("{model_name} {field_name}"),
3041            Self::RequestBody { operation_name, .. } => format!("{operation_name} request"),
3042            Self::Response {
3043                operation_name,
3044                status,
3045                ..
3046            } => format!("{operation_name} {status} response"),
3047            Self::Parameter { name } => format!("{name} param"),
3048        }
3049    }
3050
3051    fn describe(&self) -> String {
3052        match self {
3053            InlineModelContext::NamedSchema { pointer, .. } => pointer.clone(),
3054            InlineModelContext::Field { pointer, .. } => pointer.clone(),
3055            InlineModelContext::RequestBody { pointer, .. } => pointer.clone(),
3056            InlineModelContext::Response { pointer, .. } => pointer.clone(),
3057            InlineModelContext::Parameter { name } => format!("parameter `{name}`"),
3058        }
3059    }
3060
3061    fn synthetic_pointer(&self, model_name: &str) -> String {
3062        match self {
3063            Self::NamedSchema { pointer, .. } => pointer.clone(),
3064            Self::Field { pointer, .. } => pointer.clone(),
3065            Self::RequestBody { pointer, .. } => pointer.clone(),
3066            Self::Response { pointer, .. } => pointer.clone(),
3067            Self::Parameter { name } => format!("#/synthetic/parameters/{name}/{model_name}"),
3068        }
3069    }
3070}
3071
3072/// Version-specific input struct for Swagger 2.0 documents.
3073/// Top-level `definitions`, `parameters`, `responses`, and `consumes` live here;
3074/// OpenAPI 3's `components` is absent.
3075#[derive(Debug, Deserialize, Clone)]
3076struct Swagger2Document {
3077    #[serde(default)]
3078    #[serde(deserialize_with = "deserialize_paths_map")]
3079    paths: BTreeMap<String, PathItem>,
3080    #[serde(default)]
3081    consumes: Vec<String>,
3082    #[serde(default)]
3083    parameters: BTreeMap<String, ParameterSpec>,
3084    #[serde(rename = "definitions")]
3085    #[serde(default)]
3086    definitions: BTreeMap<String, Schema>,
3087    #[serde(default)]
3088    responses: BTreeMap<String, ResponseSpec>,
3089}
3090
3091/// Version-specific input struct for OpenAPI 3.x documents.
3092/// Top-level `definitions`, `consumes`, etc. are absent; everything lives under `components`.
3093#[derive(Debug, Deserialize, Clone)]
3094struct OpenApi3Document {
3095    #[serde(default)]
3096    #[serde(deserialize_with = "deserialize_paths_map")]
3097    paths: BTreeMap<String, PathItem>,
3098    #[serde(default)]
3099    components: Components,
3100}
3101
3102/// Normalised internal document form fed to `OpenApiImporter`.
3103/// Retains `Deserialize` so unit-test helpers can construct it directly from inline JSON fixtures.
3104#[derive(Debug, Deserialize, Clone)]
3105struct OpenApiDocument {
3106    #[serde(default)]
3107    #[serde(deserialize_with = "deserialize_paths_map")]
3108    paths: BTreeMap<String, PathItem>,
3109    #[serde(default)]
3110    consumes: Vec<String>,
3111    #[serde(default)]
3112    parameters: BTreeMap<String, ParameterSpec>,
3113    #[serde(rename = "definitions")]
3114    #[serde(default)]
3115    definitions: BTreeMap<String, Schema>,
3116    #[serde(default)]
3117    responses: BTreeMap<String, ResponseSpec>,
3118    #[serde(default)]
3119    components: Components,
3120}
3121
3122impl From<Swagger2Document> for OpenApiDocument {
3123    fn from(doc: Swagger2Document) -> Self {
3124        Self {
3125            paths: doc.paths,
3126            consumes: doc.consumes,
3127            parameters: doc.parameters,
3128            definitions: doc.definitions,
3129            responses: doc.responses,
3130            components: Components::default(),
3131        }
3132    }
3133}
3134
3135impl From<OpenApi3Document> for OpenApiDocument {
3136    fn from(doc: OpenApi3Document) -> Self {
3137        Self {
3138            paths: doc.paths,
3139            consumes: Vec::new(),
3140            parameters: BTreeMap::new(),
3141            definitions: BTreeMap::new(),
3142            responses: BTreeMap::new(),
3143            components: doc.components,
3144        }
3145    }
3146}
3147
3148#[derive(Debug, Deserialize, Default, Clone)]
3149struct Components {
3150    #[serde(default)]
3151    schemas: BTreeMap<String, Schema>,
3152    #[serde(default)]
3153    parameters: BTreeMap<String, ParameterSpec>,
3154    #[serde(rename = "requestBodies")]
3155    #[serde(default)]
3156    request_bodies: BTreeMap<String, RequestBodyOrRef>,
3157    #[serde(default)]
3158    responses: BTreeMap<String, ResponseSpec>,
3159}
3160
3161#[derive(Debug, Deserialize, Default, Clone)]
3162struct PathItem {
3163    #[serde(default)]
3164    parameters: Option<Vec<ParameterOrRef>>,
3165    #[serde(default)]
3166    get: Option<OperationSpec>,
3167    #[serde(default)]
3168    post: Option<OperationSpec>,
3169    #[serde(default)]
3170    put: Option<OperationSpec>,
3171    #[serde(default)]
3172    patch: Option<OperationSpec>,
3173    #[serde(default)]
3174    delete: Option<OperationSpec>,
3175}
3176
3177#[derive(Debug, Deserialize, Default, Clone)]
3178struct OperationSpec {
3179    #[serde(rename = "operationId")]
3180    #[serde(default)]
3181    operation_id: Option<String>,
3182    #[serde(default)]
3183    summary: Option<String>,
3184    #[serde(default)]
3185    tags: Vec<String>,
3186    #[serde(default)]
3187    parameters: Vec<ParameterOrRef>,
3188    #[serde(default)]
3189    consumes: Vec<String>,
3190    #[serde(rename = "requestBody")]
3191    #[serde(default)]
3192    request_body: Option<RequestBodyOrRef>,
3193    #[serde(default)]
3194    responses: BTreeMap<String, ResponseSpecOrRef>,
3195}
3196
3197#[derive(Debug, Deserialize, Clone)]
3198struct ParameterSpec {
3199    name: String,
3200    #[serde(default)]
3201    description: String,
3202    #[serde(rename = "in")]
3203    location: RawParameterLocation,
3204    #[serde(default)]
3205    required: bool,
3206    #[serde(default)]
3207    schema: Option<Schema>,
3208    #[serde(rename = "type")]
3209    #[serde(default)]
3210    parameter_type: Option<SchemaTypeDecl>,
3211    #[serde(default)]
3212    format: Option<String>,
3213    #[serde(default)]
3214    items: Option<Box<Schema>>,
3215    #[serde(rename = "collectionFormat")]
3216    #[serde(default)]
3217    collection_format: Option<String>,
3218    /// OpenAPI 3 alternative to `schema`: a single-entry media-type map.
3219    #[serde(default)]
3220    content: BTreeMap<String, MediaTypeSpec>,
3221}
3222
3223impl ParameterSpec {
3224    fn effective_schema(&self) -> Option<Schema> {
3225        self.schema
3226            .clone()
3227            .or_else(|| {
3228                self.parameter_type.clone().map(|schema_type| Schema {
3229                    schema_type: Some(schema_type),
3230                    format: self.format.clone(),
3231                    items: self.items.clone(),
3232                    ..Schema::default()
3233                })
3234            })
3235            .or_else(|| {
3236                // OpenAPI 3 allows `content` instead of `schema` on parameters.
3237                // Use the schema from the first (and per-spec, only) entry.
3238                self.content
3239                    .values()
3240                    .next()
3241                    .and_then(|media| media.schema.clone())
3242            })
3243    }
3244}
3245
3246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3247enum RawParameterLocation {
3248    Path,
3249    Query,
3250    Header,
3251    Cookie,
3252    Body,
3253    FormData,
3254}
3255
3256impl RawParameterLocation {
3257    fn as_ir_location(self) -> Option<ParameterLocation> {
3258        match self {
3259            Self::Path => Some(ParameterLocation::Path),
3260            Self::Query => Some(ParameterLocation::Query),
3261            Self::Header => Some(ParameterLocation::Header),
3262            Self::Cookie => Some(ParameterLocation::Cookie),
3263            Self::Body | Self::FormData => None,
3264        }
3265    }
3266}
3267
3268impl<'de> Deserialize<'de> for RawParameterLocation {
3269    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
3270    where
3271        D: serde::Deserializer<'de>,
3272    {
3273        let value = String::deserialize(deserializer)?;
3274        match value.as_str() {
3275            "path" => Ok(Self::Path),
3276            "query" => Ok(Self::Query),
3277            "header" => Ok(Self::Header),
3278            "cookie" => Ok(Self::Cookie),
3279            "body" => Ok(Self::Body),
3280            "formData" | "formdata" => Ok(Self::FormData),
3281            _ => Err(serde::de::Error::unknown_variant(
3282                &value,
3283                &["path", "query", "header", "cookie", "body", "formData"],
3284            )),
3285        }
3286    }
3287}
3288
3289fn raw_parameter_location_label(location: RawParameterLocation) -> &'static str {
3290    match location {
3291        RawParameterLocation::Path => "path",
3292        RawParameterLocation::Query => "query",
3293        RawParameterLocation::Header => "header",
3294        RawParameterLocation::Cookie => "cookie",
3295        RawParameterLocation::Body => "body",
3296        RawParameterLocation::FormData => "form_data",
3297    }
3298}
3299
3300#[derive(Debug, Deserialize, Clone)]
3301#[serde(untagged)]
3302enum ParameterOrRef {
3303    Ref {
3304        #[serde(rename = "$ref")]
3305        reference: String,
3306    },
3307    Inline(ParameterSpec),
3308}
3309
3310#[derive(Debug, Deserialize, Default, Clone)]
3311struct RequestBodySpec {
3312    #[serde(default)]
3313    required: bool,
3314    #[serde(default)]
3315    content: BTreeMap<String, MediaTypeSpec>,
3316}
3317
3318#[derive(Debug, Deserialize, Clone)]
3319#[serde(untagged)]
3320enum RequestBodyOrRef {
3321    Ref {
3322        #[serde(rename = "$ref")]
3323        reference: String,
3324    },
3325    Inline(RequestBodySpec),
3326}
3327
3328/// A response entry that may be either an inline spec or a `$ref` pointer.
3329/// Using a flat struct (rather than an untagged enum) ensures that
3330/// `serde_path_to_error` can track the full JSON/YAML path through the
3331/// struct's fields, giving accurate error locations on parse failure.
3332#[derive(Debug, Deserialize, Default, Clone)]
3333struct ResponseSpecOrRef {
3334    #[serde(rename = "$ref")]
3335    #[serde(default)]
3336    reference: Option<String>,
3337    #[serde(default)]
3338    description: String,
3339    #[serde(default)]
3340    content: BTreeMap<String, MediaTypeSpec>,
3341}
3342
3343#[derive(Debug, Deserialize, Default, Clone)]
3344struct ResponseSpec {
3345    #[serde(default)]
3346    description: String,
3347    #[serde(default)]
3348    content: BTreeMap<String, MediaTypeSpec>,
3349}
3350
3351#[derive(Debug, Deserialize, Default, Clone)]
3352struct MediaTypeSpec {
3353    #[serde(default)]
3354    schema: Option<Schema>,
3355}
3356
3357#[derive(Debug, Deserialize, Default, Clone, PartialEq)]
3358struct Schema {
3359    #[serde(rename = "$ref")]
3360    #[serde(default)]
3361    reference: Option<String>,
3362    #[serde(default)]
3363    definitions: Option<BTreeMap<String, Schema>>,
3364    #[serde(rename = "type")]
3365    #[serde(default)]
3366    schema_type: Option<SchemaTypeDecl>,
3367    #[serde(default)]
3368    title: Option<String>,
3369    #[serde(default)]
3370    format: Option<String>,
3371    #[serde(rename = "const")]
3372    #[serde(default)]
3373    const_value: Option<Value>,
3374    #[serde(rename = "discriminator")]
3375    #[serde(default)]
3376    _discriminator: Option<Value>,
3377    #[serde(rename = "allOf")]
3378    #[serde(default)]
3379    all_of: Option<Vec<Schema>>,
3380    #[serde(rename = "enum")]
3381    #[serde(default)]
3382    enum_values: Option<Vec<Value>>,
3383    #[serde(default)]
3384    properties: Option<IndexMap<String, SchemaOrBool>>,
3385    #[serde(default)]
3386    required: Option<Vec<String>>,
3387    #[serde(default)]
3388    items: Option<Box<Schema>>,
3389    #[serde(rename = "additionalProperties")]
3390    #[serde(default)]
3391    additional_properties: Option<AdditionalProperties>,
3392    #[serde(rename = "anyOf")]
3393    #[serde(default)]
3394    any_of: Option<Vec<Schema>>,
3395    #[serde(rename = "oneOf")]
3396    #[serde(default)]
3397    one_of: Option<Vec<Schema>>,
3398    // Capture numeric constraint keywords explicitly to avoid serde_yaml integer
3399    // coercion failures that occur when these pass through the flattened map.
3400    #[serde(default)]
3401    minimum: Option<Value>,
3402    #[serde(default)]
3403    maximum: Option<Value>,
3404    #[serde(rename = "exclusiveMinimum")]
3405    #[serde(default)]
3406    exclusive_minimum: Option<Value>,
3407    #[serde(rename = "exclusiveMaximum")]
3408    #[serde(default)]
3409    exclusive_maximum: Option<Value>,
3410    #[serde(default)]
3411    #[serde(rename = "multipleOf")]
3412    multiple_of: Option<Value>,
3413    #[serde(default)]
3414    #[serde(rename = "minLength")]
3415    min_length: Option<Value>,
3416    #[serde(default)]
3417    #[serde(rename = "maxLength")]
3418    max_length: Option<Value>,
3419    #[serde(default)]
3420    #[serde(rename = "minItems")]
3421    min_items: Option<Value>,
3422    #[serde(default)]
3423    #[serde(rename = "maxItems")]
3424    max_items: Option<Value>,
3425    #[serde(default)]
3426    #[serde(rename = "minProperties")]
3427    min_properties: Option<Value>,
3428    #[serde(default)]
3429    #[serde(rename = "maxProperties")]
3430    max_properties: Option<Value>,
3431    #[serde(flatten)]
3432    #[serde(default)]
3433    extra_keywords: BTreeMap<String, Value>,
3434}
3435
3436#[derive(Debug, Deserialize, Clone, PartialEq)]
3437#[serde(untagged)]
3438enum AdditionalProperties {
3439    Bool(bool),
3440    Schema(Box<Schema>),
3441}
3442
3443/// A property schema that may be a full schema object or a boolean schema
3444/// (valid in OpenAPI 3.1 / JSON Schema: `false` = never valid, `true` = always valid).
3445/// Boolean schemas are treated as absent properties for code-generation purposes.
3446#[derive(Debug, Clone, PartialEq)]
3447enum SchemaOrBool {
3448    Schema(Schema),
3449    Bool(bool),
3450}
3451
3452impl Default for SchemaOrBool {
3453    fn default() -> Self {
3454        SchemaOrBool::Schema(Schema::default())
3455    }
3456}
3457
3458impl SchemaOrBool {
3459    /// Returns the inner schema, or `None` for boolean schemas.
3460    fn as_schema(&self) -> Option<&Schema> {
3461        match self {
3462            SchemaOrBool::Schema(s) => Some(s),
3463            SchemaOrBool::Bool(_) => None,
3464        }
3465    }
3466    fn into_schema(self) -> Option<Schema> {
3467        match self {
3468            SchemaOrBool::Schema(s) => Some(s),
3469            SchemaOrBool::Bool(_) => None,
3470        }
3471    }
3472}
3473
3474impl<'de> serde::Deserialize<'de> for SchemaOrBool {
3475    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
3476    where
3477        D: serde::Deserializer<'de>,
3478    {
3479        struct SchemaOrBoolVisitor;
3480        impl<'de> serde::de::Visitor<'de> for SchemaOrBoolVisitor {
3481            type Value = SchemaOrBool;
3482            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3483                write!(f, "a JSON Schema object or boolean")
3484            }
3485            // Boolean schemas: `false` = never valid, `true` = always valid.
3486            fn visit_bool<E: serde::de::Error>(self, v: bool) -> std::result::Result<SchemaOrBool, E> {
3487                Ok(SchemaOrBool::Bool(v))
3488            }
3489            // Map: deserialize as a full Schema.  Using MapAccessDeserializer keeps
3490            // the serde_path_to_error-wrapped MapAccess in play so field-level
3491            // errors within the schema are tracked correctly.
3492            fn visit_map<A: serde::de::MapAccess<'de>>(self, map: A) -> std::result::Result<SchemaOrBool, A::Error> {
3493                let schema = Schema::deserialize(serde::de::value::MapAccessDeserializer::new(map))?;
3494                Ok(SchemaOrBool::Schema(schema))
3495            }
3496        }
3497        deserializer.deserialize_any(SchemaOrBoolVisitor)
3498    }
3499}
3500
3501#[derive(Debug, Deserialize, Clone, PartialEq)]
3502#[serde(untagged)]
3503enum SchemaTypeDecl {
3504    Single(String),
3505    Multiple(Vec<String>),
3506    Embedded(Box<Schema>),
3507}
3508
3509impl SchemaTypeDecl {
3510    fn as_slice(&self) -> &[String] {
3511        match self {
3512            Self::Single(value) => std::slice::from_ref(value),
3513            Self::Multiple(values) => values.as_slice(),
3514            Self::Embedded(_) => &[],
3515        }
3516    }
3517
3518    fn embedded_schema(&self) -> Option<&Schema> {
3519        match self {
3520            Self::Embedded(schema) => Some(schema.as_ref()),
3521            _ => None,
3522        }
3523    }
3524}
3525
3526impl Schema {
3527    fn schema_type_variants(&self) -> Option<&[String]> {
3528        self.schema_type.as_ref().map(SchemaTypeDecl::as_slice)
3529    }
3530
3531    fn primary_schema_type(&self) -> Option<&str> {
3532        self.schema_type_variants()?
3533            .iter()
3534            .find(|value| value.as_str() != "null")
3535            .map(String::as_str)
3536    }
3537
3538    fn is_exact_null_type(&self) -> bool {
3539        matches!(self.schema_type_variants(), Some([value]) if value == "null")
3540    }
3541}
3542
3543fn ref_name(reference: &str) -> Result<String> {
3544    reference
3545        .rsplit('/')
3546        .next()
3547        .filter(|value| !value.is_empty())
3548        .map(ToOwned::to_owned)
3549        .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))
3550}
3551
3552fn is_named_schema_reference(reference: &str) -> bool {
3553    let Some(pointer) = reference.strip_prefix("#/") else {
3554        return false;
3555    };
3556    let segments = pointer.split('/').collect::<Vec<_>>();
3557    matches!(
3558        segments.as_slice(),
3559        ["components", "schemas", _] | ["definitions", _]
3560    )
3561}
3562
3563fn is_inline_local_schema_reference(reference: &str) -> bool {
3564    reference.starts_with("#/") && !is_named_schema_reference(reference)
3565}
3566
3567fn decode_json_pointer_segment(segment: &str) -> Result<String> {
3568    let unescaped = segment.replace("~1", "/").replace("~0", "~");
3569    percent_decode(&unescaped)
3570}
3571
3572fn percent_decode(value: &str) -> Result<String> {
3573    let bytes = value.as_bytes();
3574    let mut decoded = Vec::with_capacity(bytes.len());
3575    let mut index = 0usize;
3576    while index < bytes.len() {
3577        if bytes[index] == b'%' {
3578            if index + 2 >= bytes.len() {
3579                bail!("unsupported reference segment `{value}`");
3580            }
3581            let high = (bytes[index + 1] as char)
3582                .to_digit(16)
3583                .ok_or_else(|| anyhow!("unsupported reference segment `{value}`"))?;
3584            let low = (bytes[index + 2] as char)
3585                .to_digit(16)
3586                .ok_or_else(|| anyhow!("unsupported reference segment `{value}`"))?;
3587            decoded.push(((high << 4) | low) as u8);
3588            index += 3;
3589        } else {
3590            decoded.push(bytes[index]);
3591            index += 1;
3592        }
3593    }
3594
3595    String::from_utf8(decoded).map_err(|_| anyhow!("unsupported reference segment `{value}`"))
3596}
3597
3598/// Resolve a `$ref` that points into a `ResponseSpec`, optionally continuing
3599/// into `content/{media_type}/schema/...`.
3600fn resolve_response_schema_reference(
3601    response: &ResponseSpec,
3602    segments: &[String],
3603    reference: &str,
3604) -> Result<Schema> {
3605    match segments {
3606        // Referencing the response object itself — use its primary schema.
3607        // If the response has no content (e.g. a description-only response used
3608        // mistakenly as a schema $ref), return an empty schema so callers treat
3609        // this as `any` rather than failing.
3610        [] => {
3611            let schema = response
3612                .content
3613                .values()
3614                .find_map(|media| media.schema.as_ref())
3615                .cloned()
3616                .unwrap_or_default();
3617            Ok(schema)
3618        }
3619        // content/{media_type}/schema/...
3620        [content_key, media_type, schema_key, rest @ ..]
3621            if content_key == "content" && schema_key == "schema" =>
3622        {
3623            let schema = response
3624                .content
3625                .get(media_type)
3626                .and_then(|media| media.schema.as_ref())
3627                .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
3628            resolve_nested_schema_reference(schema, rest, reference)
3629        }
3630        _ => Err(anyhow!("unsupported reference `{reference}`")),
3631    }
3632}
3633
3634fn resolve_nested_schema_reference(
3635    schema: &Schema,
3636    segments: &[String],
3637    reference: &str,
3638) -> Result<Schema> {
3639    if segments.is_empty() {
3640        return Ok(schema.clone());
3641    }
3642
3643    match segments {
3644        [segment, name, remainder @ ..] if segment == "definitions" => {
3645            let nested = schema
3646                .definitions
3647                .as_ref()
3648                .and_then(|definitions| definitions.get(name))
3649                .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
3650            resolve_nested_schema_reference(nested, remainder, reference)
3651        }
3652        [segment, remainder @ ..] if segment == "allOf" => {
3653            let index = remainder
3654                .first()
3655                .and_then(|value| value.parse::<usize>().ok())
3656                .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
3657            let member = schema
3658                .all_of
3659                .as_ref()
3660                .and_then(|members| members.get(index))
3661                .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
3662            resolve_nested_schema_reference(member, &remainder[1..], reference)
3663        }
3664        [segment, remainder @ ..] if segment == "anyOf" => {
3665            let index = remainder
3666                .first()
3667                .and_then(|value| value.parse::<usize>().ok())
3668                .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
3669            let member = schema
3670                .any_of
3671                .as_ref()
3672                .and_then(|members| members.get(index))
3673                .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
3674            resolve_nested_schema_reference(member, &remainder[1..], reference)
3675        }
3676        [segment, remainder @ ..] if segment == "oneOf" => {
3677            let index = remainder
3678                .first()
3679                .and_then(|value| value.parse::<usize>().ok())
3680                .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
3681            let member = schema
3682                .one_of
3683                .as_ref()
3684                .and_then(|members| members.get(index))
3685                .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
3686            resolve_nested_schema_reference(member, &remainder[1..], reference)
3687        }
3688        [segment, name, remainder @ ..] if segment == "properties" => {
3689            // Try top-level properties first.
3690            if let Some(property) = schema
3691                .properties
3692                .as_ref()
3693                .and_then(|p| p.get(name))
3694                .and_then(SchemaOrBool::as_schema)
3695            {
3696                return resolve_nested_schema_reference(property, remainder, reference);
3697            }
3698            // If the schema uses allOf with no top-level properties (e.g. a schema
3699            // whose properties are spread across its allOf members), search members.
3700            if let Some(all_of) = &schema.all_of {
3701                for member in all_of {
3702                    if let Some(property) = member
3703                        .properties
3704                        .as_ref()
3705                        .and_then(|p| p.get(name))
3706                        .and_then(SchemaOrBool::as_schema)
3707                    {
3708                        return resolve_nested_schema_reference(property, remainder, reference);
3709                    }
3710                }
3711            }
3712            Err(anyhow!("unsupported reference `{reference}`"))
3713        }
3714        [segment, remainder @ ..] if segment == "items" => {
3715            let item = schema
3716                .items
3717                .as_deref()
3718                .ok_or_else(|| anyhow!("unsupported reference `{reference}`"))?;
3719            resolve_nested_schema_reference(item, remainder, reference)
3720        }
3721        [segment, remainder @ ..] if segment == "additionalProperties" => {
3722            let nested = match schema.additional_properties.as_ref() {
3723                Some(AdditionalProperties::Schema(schema)) => schema.as_ref(),
3724                _ => return Err(anyhow!("unsupported reference `{reference}`")),
3725            };
3726            resolve_nested_schema_reference(nested, remainder, reference)
3727        }
3728        _ => Err(anyhow!("unsupported reference `{reference}`")),
3729    }
3730}
3731
3732fn schema_is_object_like(schema: &Schema) -> bool {
3733    schema
3734        .schema_type_variants()
3735        .is_some_and(|variants| variants.iter().any(|value| value == "object"))
3736        || schema.properties.is_some()
3737        || schema.additional_properties.is_some()
3738}
3739
3740fn is_validation_only_schema_variant(schema: &Schema) -> bool {
3741    schema.reference.is_none()
3742        && schema.definitions.is_none()
3743        && schema
3744            .schema_type
3745            .as_ref()
3746            .is_none_or(|decl| matches!(decl.as_slice(), [value] if value == "object"))
3747        && schema.format.is_none()
3748        && schema.const_value.is_none()
3749        && schema._discriminator.is_none()
3750        && schema.all_of.is_none()
3751        && schema.enum_values.is_none()
3752        && schema.properties.is_none()
3753        && schema.items.is_none()
3754        && schema.additional_properties.is_none()
3755        && schema.any_of.is_none()
3756        && schema.one_of.is_none()
3757        && schema
3758            .extra_keywords
3759            .keys()
3760            .all(|keyword| is_known_ignored_schema_keyword(keyword) || keyword.starts_with("x-"))
3761}
3762
3763fn is_generic_object_placeholder(schema: &Schema) -> bool {
3764    let has_object_type = schema
3765        .schema_type
3766        .as_ref()
3767        .is_some_and(|decl| matches!(decl.as_slice(), [value] if value == "object"));
3768
3769    (has_object_type || schema.properties.is_some())
3770        && schema
3771            .properties
3772            .as_ref()
3773            .is_some_and(|properties| properties.is_empty())
3774        && schema.additional_properties.is_none()
3775        && schema.definitions.is_none()
3776        && schema.items.is_none()
3777        && schema.enum_values.is_none()
3778        && schema.const_value.is_none()
3779        && schema.any_of.is_none()
3780        && schema.one_of.is_none()
3781        && schema.all_of.is_none()
3782        && schema._discriminator.is_none()
3783}
3784
3785fn schema_runtime_attributes(schema: &Schema) -> Attributes {
3786    let mut attributes = Attributes::default();
3787    if let Some(description) = schema
3788        .extra_keywords
3789        .get("description")
3790        .and_then(Value::as_str)
3791    {
3792        attributes.insert("description".into(), Value::String(description.to_owned()));
3793    }
3794    if let Some(content_encoding) = schema
3795        .extra_keywords
3796        .get("contentEncoding")
3797        .and_then(Value::as_str)
3798    {
3799        attributes.insert(
3800            "content_encoding".into(),
3801            Value::String(content_encoding.to_owned()),
3802        );
3803    }
3804    if let Some(content_media_type) = schema
3805        .extra_keywords
3806        .get("contentMediaType")
3807        .and_then(Value::as_str)
3808    {
3809        attributes.insert(
3810            "content_media_type".into(),
3811            Value::String(content_media_type.to_owned()),
3812        );
3813    }
3814    attributes
3815}
3816
3817fn parameter_attributes(param: &ParameterSpec, schema: &Schema) -> Attributes {
3818    let mut attributes = schema_runtime_attributes(schema);
3819    if !param.description.trim().is_empty() {
3820        attributes.insert(
3821            "description".into(),
3822            Value::String(param.description.trim().to_owned()),
3823        );
3824    }
3825    if let Some(collection_format) = &param.collection_format {
3826        attributes.insert(
3827            "collection_format".into(),
3828            Value::String(collection_format.clone()),
3829        );
3830    }
3831    attributes
3832}
3833
3834fn is_unconstrained_schema(schema: &Schema) -> bool {
3835    schema.reference.is_none()
3836        && schema.definitions.is_none()
3837        && schema.schema_type.is_none()
3838        && schema.format.is_none()
3839        && schema.const_value.is_none()
3840        && schema._discriminator.is_none()
3841        && schema.all_of.is_none()
3842        && schema.enum_values.is_none()
3843        && schema.properties.is_none()
3844        && schema.required.is_none()
3845        && schema.items.is_none()
3846        && schema.additional_properties.is_none()
3847        && schema.any_of.is_none()
3848        && schema.one_of.is_none()
3849        && schema
3850            .extra_keywords
3851            .keys()
3852            .all(|keyword| is_known_ignored_schema_keyword(keyword) || keyword.starts_with("x-"))
3853}
3854
3855fn schema_has_non_all_of_shape(schema: &Schema) -> bool {
3856    schema.reference.is_some()
3857        || schema.definitions.is_some()
3858        || schema.schema_type.is_some()
3859        || schema.format.is_some()
3860        || schema.const_value.is_some()
3861        || schema.enum_values.is_some()
3862        || schema.properties.is_some()
3863        || schema.required.is_some()
3864        || schema.items.is_some()
3865        || schema.additional_properties.is_some()
3866        || schema.any_of.is_some()
3867        || schema.one_of.is_some()
3868        || schema._discriminator.is_some()
3869}
3870
3871fn is_known_ignored_schema_keyword(keyword: &str) -> bool {
3872    matches!(
3873        keyword,
3874        "default"
3875            | "not"
3876            | "description"
3877            | "example"
3878            | "examples"
3879            | "collectionFormat"
3880            | "contentEncoding"
3881            | "contentMediaType"
3882            | "externalDocs"
3883            | "xml"
3884            | "deprecated"
3885            | "readOnly"
3886            | "writeOnly"
3887            | "minimum"
3888            | "maximum"
3889            | "exclusiveMinimum"
3890            | "exclusiveMaximum"
3891            | "multipleOf"
3892            | "minLength"
3893            | "maxLength"
3894            | "pattern"
3895            | "minItems"
3896            | "maxItems"
3897            | "uniqueItems"
3898            | "minProperties"
3899            | "maxProperties"
3900            | "nullable"
3901            | "$schema"
3902            | "$id"
3903            | "$comment"
3904    )
3905}
3906
3907fn is_known_but_unimplemented_schema_keyword(keyword: &str) -> bool {
3908    matches!(
3909        keyword,
3910        "if" | "then"
3911            | "else"
3912            | "contains"
3913            | "prefixItems"
3914            | "patternProperties"
3915            | "propertyNames"
3916            | "dependentSchemas"
3917            | "unevaluatedProperties"
3918            | "unevaluatedItems"
3919            | "$defs"
3920    )
3921}
3922
3923fn fallback_operation_name(method: HttpMethod, path: &str) -> String {
3924    to_snake_case(&format!("{} {}", method_key(method), path))
3925}
3926
3927fn method_key(method: HttpMethod) -> &'static str {
3928    match method {
3929        HttpMethod::Get => "get",
3930        HttpMethod::Post => "post",
3931        HttpMethod::Put => "put",
3932        HttpMethod::Patch => "patch",
3933        HttpMethod::Delete => "delete",
3934    }
3935}
3936
3937fn operation_attributes(spec: &OperationSpec) -> Attributes {
3938    let mut attributes = Attributes::default();
3939    if let Some(summary) = &spec.summary {
3940        attributes.insert("summary".into(), Value::String(summary.clone()));
3941    }
3942    if !spec.tags.is_empty() {
3943        attributes.insert("tags".into(), json!(spec.tags));
3944    }
3945    attributes
3946}
3947
3948fn json_pointer_key(input: &str) -> String {
3949    input.replace('~', "~0").replace('/', "~1")
3950}
3951
3952fn to_pascal_case(input: &str) -> String {
3953    let mut output = String::new();
3954    for part in split_words(input) {
3955        let mut chars = part.chars();
3956        if let Some(first) = chars.next() {
3957            output.extend(first.to_uppercase());
3958            output.push_str(chars.as_str());
3959        }
3960    }
3961    if output.is_empty() {
3962        "InlineModel".into()
3963    } else {
3964        output
3965    }
3966}
3967
3968fn to_snake_case(input: &str) -> String {
3969    let parts = split_words(input);
3970    if parts.is_empty() {
3971        return "value".into();
3972    }
3973    parts.join("_").to_lowercase()
3974}
3975
3976fn split_words(input: &str) -> Vec<String> {
3977    let mut words = Vec::new();
3978    let mut current = String::new();
3979
3980    for ch in input.chars() {
3981        if ch.is_ascii_alphanumeric() {
3982            if ch.is_uppercase() && !current.is_empty() {
3983                words.push(current.clone());
3984                current.clear();
3985            }
3986            current.push(ch.to_ascii_lowercase());
3987        } else if !current.is_empty() {
3988            words.push(current.clone());
3989            current.clear();
3990        }
3991    }
3992
3993    if !current.is_empty() {
3994        words.push(current);
3995    }
3996
3997    words
3998}
3999
4000fn dedupe_variants(variants: Vec<TypeRef>) -> Vec<TypeRef> {
4001    let mut seen = BTreeSet::new();
4002    let mut deduped = Vec::new();
4003    for variant in variants {
4004        let key = serde_json::to_string(&variant).expect("type refs should always serialize");
4005        if seen.insert(key) {
4006            deduped.push(variant);
4007        }
4008    }
4009    deduped
4010}
4011
4012fn merge_required(mut left: Vec<String>, right: Vec<String>) -> Vec<String> {
4013    let mut seen = left.iter().cloned().collect::<BTreeSet<_>>();
4014    for value in right {
4015        if seen.insert(value.clone()) {
4016            left.push(value);
4017        }
4018    }
4019    left
4020}
4021
4022fn merge_optional_field<T>(
4023    target: &mut Option<T>,
4024    incoming: Option<T>,
4025    field_name: &str,
4026    context: &str,
4027    importer: &mut OpenApiImporter,
4028) -> Result<()>
4029where
4030    T: PartialEq,
4031{
4032    match (target.as_ref(), incoming) {
4033        (_, None) => {}
4034        (None, Some(value)) => *target = Some(value),
4035        (Some(existing), Some(value)) if *existing == value => {}
4036        (Some(_), Some(_)) => {
4037            importer.handle_unhandled(
4038                context,
4039                DiagnosticKind::IncompatibleAllOfField {
4040                    field: field_name.to_owned(),
4041                },
4042            )?;
4043        }
4044    }
4045    Ok(())
4046}
4047
4048fn merge_non_codegen_optional_field<T>(target: &mut Option<T>, incoming: Option<T>) {
4049    if target.is_none() {
4050        *target = incoming;
4051    }
4052}
4053
4054fn merge_schema_types(
4055    inferred_left: Option<SchemaTypeDecl>,
4056    inferred_right: Option<SchemaTypeDecl>,
4057    left_is_generic_object_placeholder: bool,
4058    right_is_generic_object_placeholder: bool,
4059    left: Option<SchemaTypeDecl>,
4060    right: Option<SchemaTypeDecl>,
4061    _context: &str,
4062    _importer: &mut OpenApiImporter,
4063) -> Result<Option<SchemaTypeDecl>> {
4064    match (left, right) {
4065        (None, None) => Ok(inferred_left.or(inferred_right)),
4066        (Some(value), None) => Ok(Some(value)),
4067        (None, Some(value)) => Ok(Some(value)),
4068        (Some(left), Some(right)) if left == right => Ok(Some(left)),
4069        (Some(left), Some(right)) => {
4070            let left_inferred = inferred_left.unwrap_or(left.clone());
4071            let right_inferred = inferred_right.unwrap_or(right.clone());
4072            if left_is_generic_object_placeholder {
4073                return Ok(Some(right_inferred));
4074            }
4075            if right_is_generic_object_placeholder {
4076                return Ok(Some(left_inferred));
4077            }
4078            if let Some(merged) =
4079                merge_numeric_compatible_schema_types(&left_inferred, &right_inferred)
4080            {
4081                return Ok(Some(merged));
4082            }
4083            if let Some(merged) =
4084                merge_nullable_compatible_schema_types(&left_inferred, &right_inferred)
4085            {
4086                return Ok(Some(merged));
4087            }
4088            if left_inferred == right_inferred {
4089                Ok(Some(left_inferred))
4090            } else {
4091                // Incompatible types in allOf: keep the left (base) type and continue.
4092                Ok(Some(left_inferred))
4093            }
4094        }
4095    }
4096}
4097
4098fn merge_numeric_compatible_schema_types(
4099    left: &SchemaTypeDecl,
4100    right: &SchemaTypeDecl,
4101) -> Option<SchemaTypeDecl> {
4102    let left_variants = left.as_slice();
4103    let right_variants = right.as_slice();
4104    let left_has_numeric = left_variants
4105        .iter()
4106        .any(|value| value == "integer" || value == "number");
4107    let right_has_numeric = right_variants
4108        .iter()
4109        .any(|value| value == "integer" || value == "number");
4110    if !left_has_numeric || !right_has_numeric {
4111        return None;
4112    }
4113
4114    let left_other = left_variants
4115        .iter()
4116        .filter(|value| value.as_str() != "integer" && value.as_str() != "number")
4117        .collect::<BTreeSet<_>>();
4118    let right_other = right_variants
4119        .iter()
4120        .filter(|value| value.as_str() != "integer" && value.as_str() != "number")
4121        .collect::<BTreeSet<_>>();
4122    if left_other != right_other {
4123        return None;
4124    }
4125
4126    let mut merged = left_other
4127        .into_iter()
4128        .map(|value| value.to_owned())
4129        .collect::<Vec<_>>();
4130    merged.push("number".into());
4131
4132    Some(if merged.len() == 1 {
4133        SchemaTypeDecl::Single(merged.remove(0))
4134    } else {
4135        SchemaTypeDecl::Multiple(merged)
4136    })
4137}
4138
4139fn merge_nullable_compatible_schema_types(
4140    left: &SchemaTypeDecl,
4141    right: &SchemaTypeDecl,
4142) -> Option<SchemaTypeDecl> {
4143    let left_variants = left.as_slice();
4144    let right_variants = right.as_slice();
4145    if left_variants.is_empty() || right_variants.is_empty() {
4146        return None;
4147    }
4148
4149    let left_has_null = left_variants.iter().any(|value| value == "null");
4150    let right_has_null = right_variants.iter().any(|value| value == "null");
4151    if !left_has_null && !right_has_null {
4152        return None;
4153    }
4154
4155    let left_without_null = left_variants
4156        .iter()
4157        .filter(|value| value.as_str() != "null")
4158        .cloned()
4159        .collect::<BTreeSet<_>>();
4160    let right_without_null = right_variants
4161        .iter()
4162        .filter(|value| value.as_str() != "null")
4163        .cloned()
4164        .collect::<BTreeSet<_>>();
4165
4166    let merged_without_null = if left_without_null.is_empty() && !right_without_null.is_empty() {
4167        right_without_null
4168    } else if right_without_null.is_empty() && !left_without_null.is_empty() {
4169        left_without_null
4170    } else if left_without_null == right_without_null {
4171        left_without_null
4172    } else {
4173        return None;
4174    };
4175
4176    let mut merged = merged_without_null.into_iter().collect::<Vec<_>>();
4177    merged.push("null".into());
4178
4179    Some(if merged.len() == 1 {
4180        SchemaTypeDecl::Single(merged.remove(0))
4181    } else {
4182        SchemaTypeDecl::Multiple(merged)
4183    })
4184}
4185
4186fn merge_enum_values(
4187    left: Option<Vec<Value>>,
4188    right: Option<Vec<Value>>,
4189    _context: &str,
4190    _importer: &mut OpenApiImporter,
4191) -> Result<Option<Vec<Value>>> {
4192    match (left, right) {
4193        (None, None) => Ok(None),
4194        (Some(values), None) | (None, Some(values)) => Ok(Some(values)),
4195        (Some(left_values), Some(right_values)) => {
4196            let right_keys = right_values
4197                .iter()
4198                .map(serde_json::to_string)
4199                .collect::<std::result::Result<BTreeSet<_>, _>>()
4200                .expect("enum values should always serialize");
4201            let merged = left_values
4202                .iter()
4203                .filter(|value| {
4204                    let key =
4205                        serde_json::to_string(value).expect("enum values should always serialize");
4206                    right_keys.contains(&key)
4207                })
4208                .cloned()
4209                .collect::<Vec<_>>();
4210
4211            // If the intersection is empty the enum sets are disjoint.
4212            // Accept all values from the left side as a graceful fallback.
4213            let result = if merged.is_empty() {
4214                left_values
4215            } else {
4216                merged
4217            };
4218
4219            Ok(Some(result))
4220        }
4221    }
4222}
4223
4224fn infer_schema_type_for_merge(schema: &Schema) -> Option<SchemaTypeDecl> {
4225    schema.schema_type.clone().or_else(|| {
4226        if schema.properties.is_some() || schema.additional_properties.is_some() {
4227            Some(SchemaTypeDecl::Single("object".into()))
4228        } else if schema.items.is_some() {
4229            Some(SchemaTypeDecl::Single("array".into()))
4230        } else if let Some(enum_values) = &schema.enum_values {
4231            match infer_enum_type(enum_values, schema.format.as_deref()) {
4232                TypeRef::Primitive { name } => Some(SchemaTypeDecl::Single(name)),
4233                _ => None,
4234            }
4235        } else {
4236            infer_format_only_type(schema.format.as_deref()).and_then(|type_ref| match type_ref {
4237                TypeRef::Primitive { name } => Some(SchemaTypeDecl::Single(name)),
4238                _ => None,
4239            })
4240        }
4241    })
4242}
4243
4244fn infer_enum_type(enum_values: &[Value], format: Option<&str>) -> TypeRef {
4245    let inferred_name = if enum_values.iter().all(Value::is_string) {
4246        if format == Some("binary") {
4247            "binary"
4248        } else {
4249            "string"
4250        }
4251    } else if enum_values.iter().all(|value| value.as_i64().is_some()) {
4252        "integer"
4253    } else if enum_values.iter().all(Value::is_number) {
4254        "number"
4255    } else if enum_values.iter().all(Value::is_boolean) {
4256        "boolean"
4257    } else {
4258        "any"
4259    };
4260
4261    TypeRef::primitive(inferred_name)
4262}
4263
4264fn infer_format_only_type(format: Option<&str>) -> Option<TypeRef> {
4265    let inferred = match format? {
4266        "binary" => "binary",
4267        // Allow the primitive type names themselves used as format values.
4268        "boolean" | "bool" => "boolean",
4269        "integer" | "int" | "int32" | "int64" => "integer",
4270        "number" | "float" | "double" | "decimal" => "number",
4271        // "string" (and related) as format → infer string type.
4272        "string" | "byte" | "date" | "date-time" | "duration" | "email" | "hostname"
4273        | "host-name" | "ipv4" | "ipv6" | "password" | "uri" | "uuid" => "string",
4274        _ => return None,
4275    };
4276    Some(TypeRef::primitive(inferred))
4277}
4278
4279fn merge_properties(
4280    importer: &mut OpenApiImporter,
4281    mut left: IndexMap<String, SchemaOrBool>,
4282    right: IndexMap<String, SchemaOrBool>,
4283    context: &str,
4284) -> Result<IndexMap<String, SchemaOrBool>> {
4285    for (key, value) in right {
4286        if let Some(existing) = left.shift_remove(&key) {
4287            let merged = match (existing, value) {
4288                (SchemaOrBool::Schema(l), SchemaOrBool::Schema(r)) => {
4289                    SchemaOrBool::Schema(importer.merge_schemas(l, r, context)?)
4290                }
4291                // Boolean schema vs real schema: prefer the real schema.
4292                (SchemaOrBool::Schema(s), SchemaOrBool::Bool(_))
4293                | (SchemaOrBool::Bool(_), SchemaOrBool::Schema(s)) => SchemaOrBool::Schema(s),
4294                // Both boolean: keep default.
4295                (SchemaOrBool::Bool(_), SchemaOrBool::Bool(_)) => SchemaOrBool::default(),
4296            };
4297            left.insert(key, merged);
4298        } else {
4299            left.insert(key, value);
4300        }
4301    }
4302    Ok(left)
4303}
4304
4305#[cfg(test)]
4306mod tests {
4307    use super::*;
4308
4309    fn json_test_source(spec: &str) -> OpenApiSource {
4310        OpenApiSource::new(SourceFormat::Json, spec.to_owned())
4311    }
4312
4313    #[test]
4314    fn imports_minimal_openapi_document() {
4315        let spec = r##"
4316{
4317  "openapi": "3.1.0",
4318  "paths": {
4319    "/widgets/{widget_id}": {
4320      "get": {
4321        "operationId": "get_widget",
4322        "parameters": [
4323          {
4324            "name": "widget_id",
4325            "in": "path",
4326            "required": true,
4327            "schema": { "type": "string" }
4328          }
4329        ],
4330        "responses": {
4331          "200": {
4332            "description": "ok",
4333            "content": {
4334              "application/json": {
4335                "schema": { "$ref": "#/components/schemas/Widget" }
4336              }
4337            }
4338          }
4339        }
4340      }
4341    }
4342  },
4343  "components": {
4344    "schemas": {
4345      "Widget": {
4346        "type": "object",
4347        "required": ["id"],
4348        "properties": {
4349          "status": {
4350            "$ref": "#/components/schemas/WidgetStatus"
4351          },
4352          "id": { "type": "string" },
4353          "count": { "anyOf": [{ "type": "integer" }, { "type": "null" }] },
4354          "labels": {
4355            "type": "object",
4356            "additionalProperties": { "type": "string" }
4357          },
4358          "metadata": {
4359            "type": "object",
4360            "additionalProperties": true
4361          }
4362        }
4363      },
4364      "WidgetStatus": {
4365        "type": "string",
4366        "enum": ["READY", "PAUSED"]
4367      }
4368    }
4369  }
4370}
4371"##;
4372
4373        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
4374        let result = OpenApiImporter::new(
4375            document,
4376            json_test_source(spec),
4377            LoadOpenApiOptions::default(),
4378        )
4379        .build_ir()
4380        .expect("should import successfully");
4381        let ir = result.ir;
4382
4383        assert_eq!(ir.models.len(), 2);
4384        assert_eq!(ir.operations.len(), 1);
4385        assert_eq!(ir.operations[0].name, "get_widget");
4386        assert!(ir.models.iter().any(|model| model.name == "Widget"));
4387        assert!(ir.models.iter().any(|model| model.name == "WidgetStatus"));
4388        let widget = ir
4389            .models
4390            .iter()
4391            .find(|model| model.name == "Widget")
4392            .expect("widget model");
4393        assert!(
4394            widget
4395                .fields
4396                .iter()
4397                .find(|field| field.name == "count")
4398                .expect("count field")
4399                .nullable
4400        );
4401        assert!(matches!(
4402            widget
4403                .fields
4404                .iter()
4405                .find(|field| field.name == "metadata")
4406                .expect("metadata field")
4407                .type_ref,
4408            TypeRef::Map { .. }
4409        ));
4410        assert_eq!(
4411            widget
4412                .fields
4413                .iter()
4414                .find(|field| field.name == "status")
4415                .expect("status field")
4416                .type_ref,
4417            TypeRef::named("WidgetStatus")
4418        );
4419    }
4420
4421    #[test]
4422    fn supports_parameter_refs() {
4423        let spec = r##"
4424{
4425  "openapi": "3.1.0",
4426  "paths": {
4427    "/key/{PK}": {
4428      "delete": {
4429        "operationId": "delete_key",
4430        "parameters": [
4431          { "$ref": "#/components/parameters/PK" }
4432        ],
4433        "responses": {
4434          "204": { "description": "deleted" }
4435        }
4436      }
4437    }
4438  },
4439  "components": {
4440    "parameters": {
4441      "PK": {
4442        "name": "PK",
4443        "in": "path",
4444        "required": true,
4445        "schema": { "type": "string" }
4446      }
4447    }
4448  }
4449}
4450"##;
4451
4452        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
4453        let result = OpenApiImporter::new(
4454            document,
4455            json_test_source(spec),
4456            LoadOpenApiOptions::default(),
4457        )
4458        .build_ir()
4459        .expect("parameter refs should be supported");
4460
4461        assert_eq!(result.ir.operations.len(), 1);
4462        let operation = &result.ir.operations[0];
4463        assert_eq!(operation.params.len(), 1);
4464        let param = &operation.params[0];
4465        assert_eq!(param.name, "PK");
4466        assert_eq!(param.location, ParameterLocation::Path);
4467        assert!(param.required);
4468        assert_eq!(param.type_ref, TypeRef::primitive("string"));
4469    }
4470
4471    #[test]
4472    fn supports_swagger_root_parameter_refs_with_type() {
4473        let spec = r##"
4474{
4475  "swagger": "2.0",
4476  "paths": {
4477    "/widgets/{id}": {
4478      "get": {
4479        "operationId": "get_widget",
4480        "parameters": [
4481          { "$ref": "#/parameters/ApiVersionParameter" },
4482          { "$ref": "#/parameters/IdParameter" }
4483        ],
4484        "responses": {
4485          "200": { "description": "ok" }
4486        }
4487      }
4488    }
4489  },
4490  "parameters": {
4491    "ApiVersionParameter": {
4492      "name": "api-version",
4493      "in": "query",
4494      "required": true,
4495      "type": "string"
4496    },
4497    "IdParameter": {
4498      "name": "id",
4499      "in": "path",
4500      "required": true,
4501      "type": "integer",
4502      "format": "int64"
4503    }
4504  }
4505}
4506"##;
4507
4508        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
4509        let result = OpenApiImporter::new(
4510            document,
4511            json_test_source(spec),
4512            LoadOpenApiOptions::default(),
4513        )
4514        .build_ir()
4515        .expect("swagger root parameter refs should be supported");
4516
4517        let operation = result
4518            .ir
4519            .operations
4520            .iter()
4521            .find(|operation| operation.name == "get_widget")
4522            .expect("operation should exist");
4523        assert_eq!(operation.params.len(), 2);
4524        assert_eq!(operation.params[0].name, "api-version");
4525        assert_eq!(operation.params[0].location, ParameterLocation::Query);
4526        assert_eq!(operation.params[0].type_ref, TypeRef::primitive("string"));
4527        assert_eq!(operation.params[1].name, "id");
4528        assert_eq!(operation.params[1].location, ParameterLocation::Path);
4529        assert_eq!(operation.params[1].type_ref, TypeRef::primitive("integer"));
4530    }
4531
4532    #[test]
4533    fn supports_references_into_parameter_schemas() {
4534        let spec = r##"
4535{
4536  "openapi": "3.1.0",
4537  "paths": {},
4538  "components": {
4539    "schemas": {
4540      "Widget": {
4541        "type": "object",
4542        "properties": {
4543          "companyId": {
4544            "$ref": "#/components/parameters/companyId/schema"
4545          }
4546        }
4547      }
4548    },
4549    "parameters": {
4550      "companyId": {
4551        "name": "companyId",
4552        "in": "path",
4553        "required": true,
4554        "schema": {
4555          "type": "string"
4556        }
4557      }
4558    }
4559  }
4560}
4561"##;
4562
4563        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid spec");
4564        let result = OpenApiImporter::new(
4565            document,
4566            json_test_source(spec),
4567            LoadOpenApiOptions::default(),
4568        )
4569        .build_ir()
4570        .expect("schema refs into reusable parameters should resolve");
4571
4572        let widget = result
4573            .ir
4574            .models
4575            .iter()
4576            .find(|model| model.name == "Widget")
4577            .expect("Widget model should exist");
4578        assert_eq!(widget.fields[0].name, "companyId");
4579        assert_eq!(widget.fields[0].type_ref, TypeRef::primitive("string"));
4580    }
4581
4582    #[test]
4583    fn preserves_external_file_references_as_named_types() {
4584        let spec = r##"
4585{
4586  "openapi": "3.1.0",
4587  "paths": {},
4588  "components": {
4589    "schemas": {
4590      "Route": {
4591        "type": "object",
4592        "properties": {
4593          "subnet": {
4594            "$ref": "./virtualNetwork.json#/definitions/Subnet"
4595          }
4596        }
4597      }
4598    }
4599  }
4600}
4601"##;
4602
4603        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid spec");
4604        let result = OpenApiImporter::new(
4605            document,
4606            json_test_source(spec),
4607            LoadOpenApiOptions::default(),
4608        )
4609        .build_ir()
4610        .expect("external file refs should remain importable as named types");
4611
4612        let route = result
4613            .ir
4614            .models
4615            .iter()
4616            .find(|model| model.name == "Route")
4617            .expect("Route model should exist");
4618        assert_eq!(route.fields[0].name, "subnet");
4619        assert_eq!(route.fields[0].type_ref, TypeRef::named("Subnet"));
4620    }
4621
4622    #[test]
4623    fn normalizes_swagger_body_parameters_into_request_bodies() {
4624        let spec = r##"
4625{
4626  "swagger": "2.0",
4627  "consumes": ["application/json"],
4628  "paths": {
4629    "/widgets/{id}": {
4630      "patch": {
4631        "operationId": "patch_widget",
4632        "parameters": [
4633          {
4634            "name": "id",
4635            "in": "path",
4636            "required": true,
4637            "type": "string"
4638          },
4639          {
4640            "name": "widget",
4641            "in": "body",
4642            "required": true,
4643            "description": "Widget update payload.",
4644            "schema": {
4645              "type": "object",
4646              "properties": {
4647                "name": { "type": "string" }
4648              }
4649            }
4650          }
4651        ],
4652        "responses": {
4653          "200": { "description": "ok" }
4654        }
4655      }
4656    }
4657  }
4658}
4659"##;
4660
4661        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
4662        let result = OpenApiImporter::new(
4663            document,
4664            json_test_source(spec),
4665            LoadOpenApiOptions::default(),
4666        )
4667        .build_ir()
4668        .expect("swagger body parameters should become request bodies");
4669
4670        let operation = result
4671            .ir
4672            .operations
4673            .iter()
4674            .find(|operation| operation.name == "patch_widget")
4675            .expect("operation should exist");
4676        assert_eq!(operation.params.len(), 1);
4677        assert_eq!(operation.params[0].name, "id");
4678
4679        let request_body = operation.request_body.as_ref().expect("request body");
4680        assert!(request_body.required);
4681        assert_eq!(request_body.media_type, "application/json");
4682        assert_eq!(
4683            request_body.attributes.get("description"),
4684            Some(&Value::String("Widget update payload.".into()))
4685        );
4686        assert!(matches!(request_body.type_ref, Some(TypeRef::Named { .. })));
4687    }
4688
4689    #[test]
4690    fn supports_request_body_refs() {
4691        let spec = r##"
4692{
4693  "openapi": "3.1.0",
4694  "paths": {
4695    "/events": {
4696      "post": {
4697        "operationId": "create_event",
4698        "requestBody": {
4699          "$ref": "#/components/requestBodies/EventRequest"
4700        },
4701        "responses": {
4702          "200": { "description": "ok" }
4703        }
4704      }
4705    }
4706  },
4707  "components": {
4708    "requestBodies": {
4709      "EventRequest": {
4710        "$ref": "#/components/requestBodies/BaseEventRequest"
4711      },
4712      "BaseEventRequest": {
4713        "required": true,
4714        "content": {
4715          "application/json": {
4716            "schema": { "type": "string" }
4717          }
4718        }
4719      }
4720    }
4721  }
4722}
4723"##;
4724
4725        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
4726        let result = OpenApiImporter::new(
4727            document,
4728            json_test_source(spec),
4729            LoadOpenApiOptions::default(),
4730        )
4731        .build_ir()
4732        .expect("request body refs should be supported");
4733
4734        let operation = result
4735            .ir
4736            .operations
4737            .iter()
4738            .find(|operation| operation.name == "create_event")
4739            .expect("operation should exist");
4740        let request_body = operation.request_body.as_ref().expect("request body");
4741        assert!(request_body.required);
4742        assert_eq!(request_body.media_type, "application/json");
4743        assert_eq!(request_body.type_ref, Some(TypeRef::primitive("string")));
4744        assert!(result.warnings.is_empty());
4745    }
4746
4747    #[test]
4748    fn defaults_empty_request_body_content_to_untyped_octet_stream() {
4749        let spec = r##"
4750{
4751  "openapi": "3.1.0",
4752  "paths": {
4753    "/events": {
4754      "post": {
4755        "operationId": "create_event",
4756        "requestBody": {
4757          "required": true,
4758          "content": {}
4759        },
4760        "responses": {
4761          "200": { "description": "ok" }
4762        }
4763      }
4764    }
4765  }
4766}
4767"##;
4768
4769        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
4770        let result = OpenApiImporter::new(
4771            document,
4772            json_test_source(spec),
4773            LoadOpenApiOptions::default(),
4774        )
4775        .build_ir()
4776        .expect("empty request body content should be normalized");
4777
4778        let operation = result
4779            .ir
4780            .operations
4781            .iter()
4782            .find(|operation| operation.name == "create_event")
4783            .expect("operation should exist");
4784        let request_body = operation.request_body.as_ref().expect("request body");
4785        assert!(request_body.required);
4786        assert_eq!(request_body.media_type, "application/octet-stream");
4787        assert_eq!(request_body.type_ref, None);
4788        assert_eq!(result.warnings.len(), 1);
4789        assert!(matches!(
4790            result.warnings[0].kind,
4791            DiagnosticKind::EmptyRequestBodyContent
4792        ));
4793        assert_eq!(
4794            result.warnings[0].pointer.as_deref(),
4795            Some("#/paths/~1events/post/requestBody/content")
4796        );
4797    }
4798
4799    #[test]
4800    fn supports_const_scalar_fields() {
4801        let spec = r##"
4802{
4803  "openapi": "3.1.0",
4804  "paths": {},
4805  "components": {
4806    "schemas": {
4807      "PatchOp": {
4808        "type": "object",
4809        "properties": {
4810          "op": {
4811            "type": "string",
4812            "const": "replace"
4813          }
4814        }
4815      }
4816    }
4817  }
4818}
4819"##;
4820
4821        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
4822        let result = OpenApiImporter::new(
4823            document,
4824            json_test_source(spec),
4825            LoadOpenApiOptions::default(),
4826        )
4827        .build_ir()
4828        .expect("const should be supported");
4829        let patch_op = result
4830            .ir
4831            .models
4832            .iter()
4833            .find(|model| model.name == "PatchOp")
4834            .expect("PatchOp model");
4835        assert!(
4836            patch_op
4837                .fields
4838                .iter()
4839                .any(|field| field.name == "op" && field.type_ref == TypeRef::primitive("string"))
4840        );
4841        assert!(result.warnings.is_empty());
4842    }
4843
4844    #[test]
4845    fn supports_type_array_with_nullability() {
4846        let spec = r##"
4847{
4848  "openapi": "3.1.0",
4849  "paths": {},
4850  "components": {
4851    "schemas": {
4852      "Widget": {
4853        "type": "object",
4854        "properties": {
4855          "name": {
4856            "type": ["string", "null"]
4857          }
4858        }
4859      }
4860    }
4861  }
4862}
4863"##;
4864
4865        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
4866        let result = OpenApiImporter::new(
4867            document,
4868            json_test_source(spec),
4869            LoadOpenApiOptions::default(),
4870        )
4871        .build_ir()
4872        .expect("type arrays with null should be supported");
4873        let widget = result
4874            .ir
4875            .models
4876            .iter()
4877            .find(|model| model.name == "Widget")
4878            .expect("Widget model");
4879        let name = widget
4880            .fields
4881            .iter()
4882            .find(|field| field.name == "name")
4883            .expect("name field");
4884        assert_eq!(name.type_ref, TypeRef::primitive("string"));
4885        assert!(name.nullable);
4886    }
4887
4888    #[test]
4889    fn falls_back_when_operation_id_is_empty() {
4890        let spec = r##"
4891{
4892  "openapi": "3.1.0",
4893  "paths": {
4894    "/widgets": {
4895      "get": {
4896        "operationId": "",
4897        "responses": {
4898          "200": { "description": "ok" }
4899        }
4900      }
4901    }
4902  }
4903}
4904"##;
4905
4906        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
4907        let result = OpenApiImporter::new(
4908            document,
4909            json_test_source(spec),
4910            LoadOpenApiOptions::default(),
4911        )
4912        .build_ir()
4913        .expect("empty operation ids should fall back");
4914        let operation = &result.ir.operations[0];
4915        assert_eq!(operation.name, "get_widgets");
4916    }
4917
4918    #[test]
4919    fn supports_implicit_enum_and_items_schema_shapes() {
4920        let spec = r##"
4921{
4922  "openapi": "3.1.0",
4923  "paths": {},
4924  "components": {
4925    "schemas": {
4926      "Widget": {
4927        "type": "object",
4928        "properties": {
4929          "status": {
4930            "enum": ["ready", "pending"]
4931          },
4932          "children": {
4933            "items": {
4934              "type": "string"
4935            }
4936          },
4937          "withTrial": {
4938            "format": "boolean"
4939          }
4940        }
4941      }
4942    }
4943  }
4944}
4945"##;
4946
4947        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
4948        let result = OpenApiImporter::new(
4949            document,
4950            json_test_source(spec),
4951            LoadOpenApiOptions::default(),
4952        )
4953        .build_ir()
4954        .expect("implicit enum/items/format schema shapes should be supported");
4955        let widget = result
4956            .ir
4957            .models
4958            .iter()
4959            .find(|model| model.name == "Widget")
4960            .expect("Widget model");
4961        let status = widget
4962            .fields
4963            .iter()
4964            .find(|field| field.name == "status")
4965            .expect("status field");
4966        assert_eq!(status.type_ref, TypeRef::primitive("string"));
4967
4968        let children = widget
4969            .fields
4970            .iter()
4971            .find(|field| field.name == "children")
4972            .expect("children field");
4973        assert_eq!(
4974            children.type_ref,
4975            TypeRef::array(TypeRef::primitive("string"))
4976        );
4977
4978        let with_trial = widget
4979            .fields
4980            .iter()
4981            .find(|field| field.name == "withTrial")
4982            .expect("withTrial field");
4983        assert_eq!(with_trial.type_ref, TypeRef::primitive("boolean"));
4984    }
4985
4986    #[test]
4987    fn supports_object_schemas_with_validation_only_any_of() {
4988        let spec = r##"
4989{
4990  "openapi": "3.1.0",
4991  "paths": {},
4992  "components": {
4993    "schemas": {
4994      "PatchGist": {
4995        "type": "object",
4996        "properties": {
4997          "description": { "type": "string" },
4998          "files": { "type": "object" }
4999        },
5000        "anyOf": [
5001          { "required": ["description"] },
5002          { "required": ["files"] }
5003        ],
5004        "nullable": true
5005      }
5006    }
5007  }
5008}
5009"##;
5010
5011        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
5012        let result = OpenApiImporter::new(
5013            document,
5014            json_test_source(spec),
5015            LoadOpenApiOptions::default(),
5016        )
5017        .build_ir()
5018        .expect("object schemas with validation-only anyOf should be supported");
5019        let patch_gist = result
5020            .ir
5021            .models
5022            .iter()
5023            .find(|model| model.name == "PatchGist")
5024            .expect("PatchGist model");
5025        let field_names = patch_gist
5026            .fields
5027            .iter()
5028            .map(|field| field.name.as_str())
5029            .collect::<Vec<_>>();
5030        assert_eq!(field_names, vec!["description", "files"]);
5031    }
5032
5033    #[test]
5034    fn preserves_schema_property_order() {
5035        let spec = r##"
5036{
5037  "openapi": "3.1.0",
5038  "paths": {},
5039  "components": {
5040    "schemas": {
5041      "Widget": {
5042        "type": "object",
5043        "properties": {
5044          "zebra": { "type": "string" },
5045          "alpha": { "type": "string" },
5046          "middle": { "type": "string" }
5047        }
5048      }
5049    }
5050  }
5051}
5052"##;
5053
5054        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
5055        let result = OpenApiImporter::new(
5056            document,
5057            json_test_source(spec),
5058            LoadOpenApiOptions::default(),
5059        )
5060        .build_ir()
5061        .expect("property order should be preserved");
5062        let widget = result
5063            .ir
5064            .models
5065            .iter()
5066            .find(|model| model.name == "Widget")
5067            .expect("Widget model");
5068        let field_names = widget
5069            .fields
5070            .iter()
5071            .map(|field| field.name.as_str())
5072            .collect::<Vec<_>>();
5073        assert_eq!(field_names, vec!["zebra", "alpha", "middle"]);
5074    }
5075
5076    #[test]
5077    fn supports_metadata_only_property_schema_as_any() {
5078        let spec = r##"
5079{
5080  "openapi": "3.1.0",
5081  "paths": {},
5082  "components": {
5083    "schemas": {
5084      "ErrorDetail": {
5085        "type": "object",
5086        "properties": {
5087          "value": {
5088            "description": "The value at the given location"
5089          }
5090        }
5091      }
5092    }
5093  }
5094}
5095"##;
5096
5097        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
5098        let result = OpenApiImporter::new(
5099            document,
5100            json_test_source(spec),
5101            LoadOpenApiOptions::default(),
5102        )
5103        .build_ir()
5104        .expect("metadata-only schema should be treated as any");
5105        let error_detail = result
5106            .ir
5107            .models
5108            .iter()
5109            .find(|model| model.name == "ErrorDetail")
5110            .expect("ErrorDetail model");
5111        let value = error_detail
5112            .fields
5113            .iter()
5114            .find(|field| field.name == "value")
5115            .expect("value field");
5116        assert_eq!(value.type_ref, TypeRef::primitive("any"));
5117    }
5118
5119    #[test]
5120    fn supports_discriminator_on_unions() {
5121        let spec = r##"
5122{
5123  "openapi": "3.1.0",
5124  "paths": {},
5125  "components": {
5126    "schemas": {
5127      "AddOperation": {
5128        "type": "object",
5129        "properties": {
5130          "op": {
5131            "type": "string",
5132            "const": "add"
5133          }
5134        }
5135      },
5136      "RemoveOperation": {
5137        "type": "object",
5138        "properties": {
5139          "op": {
5140            "type": "string",
5141            "const": "remove"
5142          }
5143        }
5144      },
5145      "PatchSchema": {
5146        "type": "object",
5147        "properties": {
5148          "patches": {
5149            "type": "array",
5150            "items": {
5151              "oneOf": [
5152                { "$ref": "#/components/schemas/AddOperation" },
5153                { "$ref": "#/components/schemas/RemoveOperation" }
5154              ],
5155              "discriminator": {
5156                "propertyName": "op",
5157                "mapping": {
5158                  "add": "#/components/schemas/AddOperation",
5159                  "remove": "#/components/schemas/RemoveOperation"
5160                }
5161              }
5162            }
5163          }
5164        }
5165      }
5166    }
5167  }
5168}
5169"##;
5170
5171        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
5172        let result = OpenApiImporter::new(
5173            document,
5174            json_test_source(spec),
5175            LoadOpenApiOptions::default(),
5176        )
5177        .build_ir()
5178        .expect("discriminator unions should be supported");
5179        let patch_schema = result
5180            .ir
5181            .models
5182            .iter()
5183            .find(|model| model.name == "PatchSchema")
5184            .expect("PatchSchema model");
5185        let patches = patch_schema
5186            .fields
5187            .iter()
5188            .find(|field| field.name == "patches")
5189            .expect("patches field");
5190        assert!(matches!(
5191            &patches.type_ref,
5192            TypeRef::Array { item }
5193                if matches!(
5194                    item.as_ref(),
5195                    TypeRef::Union { variants }
5196                        if variants == &vec![
5197                            TypeRef::named("AddOperation"),
5198                            TypeRef::named("RemoveOperation")
5199                        ]
5200                )
5201        ));
5202        assert!(result.warnings.is_empty());
5203    }
5204
5205    #[test]
5206    fn supports_all_of_object_composition() {
5207        let spec = r##"
5208{
5209  "openapi": "3.1.0",
5210  "paths": {},
5211  "components": {
5212    "schemas": {
5213      "Cursor": {
5214        "type": "object",
5215        "properties": {
5216          "cursor": { "type": "string" }
5217        },
5218        "required": ["cursor"]
5219      },
5220      "PatchSchema": {
5221        "allOf": [
5222          { "$ref": "#/components/schemas/Cursor" },
5223          {
5224            "type": "object",
5225            "properties": {
5226              "items": {
5227                "type": "array",
5228                "items": { "type": "string" }
5229              }
5230            },
5231            "required": ["items"]
5232          }
5233        ]
5234      },
5235      "BaseId": { "type": "string" },
5236      "WrappedId": {
5237        "allOf": [
5238          { "$ref": "#/components/schemas/BaseId" },
5239          { "description": "Identifier wrapper" }
5240        ]
5241      },
5242      "Status": {
5243        "type": "string",
5244        "enum": ["ready", "pending", "failed"]
5245      },
5246      "RetryableStatus": {
5247        "allOf": [
5248          { "$ref": "#/components/schemas/Status" },
5249          { "enum": ["pending", "failed"] }
5250        ]
5251      },
5252      "TitledCursor": {
5253        "allOf": [
5254          {
5255            "$ref": "#/components/schemas/Cursor",
5256            "title": "Cursor Base"
5257          },
5258          {
5259            "type": "object",
5260            "title": "Cursor Overlay",
5261            "properties": {
5262              "nextCursor": { "type": "string" }
5263            }
5264          }
5265        ]
5266      },
5267      "Wrapper": {
5268        "type": "object",
5269        "properties": {
5270          "cursorRef": {
5271            "allOf": [
5272              { "$ref": "#/components/schemas/Cursor" },
5273              { "description": "Keep the named component reference" }
5274            ]
5275          }
5276        }
5277      }
5278    }
5279  }
5280}
5281"##;
5282
5283        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
5284        let result = OpenApiImporter::new(
5285            document,
5286            json_test_source(spec),
5287            LoadOpenApiOptions::default(),
5288        )
5289        .build_ir()
5290        .expect("allOf should be supported");
5291
5292        let patch_schema = result
5293            .ir
5294            .models
5295            .iter()
5296            .find(|model| model.name == "PatchSchema")
5297            .expect("PatchSchema model");
5298        let field_names = patch_schema
5299            .fields
5300            .iter()
5301            .map(|field| field.name.as_str())
5302            .collect::<Vec<_>>();
5303        assert_eq!(field_names, vec!["cursor", "items"]);
5304
5305        let titled_cursor = result
5306            .ir
5307            .models
5308            .iter()
5309            .find(|model| model.name == "TitledCursor")
5310            .expect("TitledCursor model");
5311        let titled_cursor_fields = titled_cursor
5312            .fields
5313            .iter()
5314            .map(|field| field.name.as_str())
5315            .collect::<Vec<_>>();
5316        assert_eq!(titled_cursor_fields, vec!["cursor", "nextCursor"]);
5317
5318        let retryable_status = result
5319            .ir
5320            .models
5321            .iter()
5322            .find(|model| model.name == "RetryableStatus")
5323            .expect("RetryableStatus model");
5324        assert_eq!(
5325            retryable_status.attributes.get("enum_values"),
5326            Some(&Value::Array(vec![
5327                Value::String("pending".into()),
5328                Value::String("failed".into())
5329            ]))
5330        );
5331        assert!(
5332            patch_schema
5333                .fields
5334                .iter()
5335                .find(|field| field.name == "cursor")
5336                .map(|field| !field.optional)
5337                .unwrap_or(false)
5338        );
5339        assert!(
5340            patch_schema
5341                .fields
5342                .iter()
5343                .find(|field| field.name == "items")
5344                .map(|field| !field.optional)
5345                .unwrap_or(false)
5346        );
5347
5348        let wrapped_id = result
5349            .ir
5350            .models
5351            .iter()
5352            .find(|model| model.name == "WrappedId")
5353            .expect("WrappedId model");
5354        assert_eq!(
5355            wrapped_id.attributes.get("alias_type_ref"),
5356            Some(&json!(TypeRef::primitive("string")))
5357        );
5358        let wrapper = result
5359            .ir
5360            .models
5361            .iter()
5362            .find(|model| model.name == "Wrapper")
5363            .expect("Wrapper model");
5364        assert_eq!(
5365            wrapper
5366                .fields
5367                .iter()
5368                .find(|field| field.name == "cursorRef")
5369                .map(|field| &field.type_ref),
5370            Some(&TypeRef::named("Cursor"))
5371        );
5372        assert!(result.warnings.is_empty());
5373    }
5374
5375    #[test]
5376    fn errors_on_recursive_all_of_reference_cycles() {
5377        let spec = r##"
5378{
5379  "openapi": "3.1.0",
5380  "paths": {},
5381  "components": {
5382    "schemas": {
5383      "Node": {
5384        "allOf": [
5385          { "$ref": "#/components/schemas/Node" }
5386        ]
5387      }
5388    }
5389  }
5390}
5391"##;
5392
5393        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
5394        let error = OpenApiImporter::new(
5395            document,
5396            json_test_source(spec),
5397            LoadOpenApiOptions::default(),
5398        )
5399        .build_ir()
5400        .expect_err("recursive allOf cycles should fail cleanly");
5401
5402        let rendered = format!("{error:#}");
5403        assert!(
5404            rendered.contains("recursive reference cycle"),
5405            "unexpected error: {rendered}"
5406        );
5407        assert!(rendered.contains("#/components/schemas/Node"));
5408    }
5409
5410    #[test]
5411    fn errors_on_unhandled_elements_by_default_and_warns_when_ignored() {
5412        // `not` is now silently ignored (mapped to `any`). Verify a genuinely
5413        // unsupported-but-declared keyword (`if`) still triggers the unhandled path.
5414        let spec = r##"
5415{
5416  "openapi": "3.1.0",
5417  "paths": {},
5418  "components": {
5419    "schemas": {
5420      "PatchSchema": {
5421        "if": {
5422          "properties": { "foo": { "type": "string" } }
5423        }
5424      }
5425    }
5426  }
5427}
5428"##;
5429
5430        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
5431        let strict_error = OpenApiImporter::new(
5432            document.clone(),
5433            json_test_source(spec),
5434            LoadOpenApiOptions::default(),
5435        )
5436        .build_ir()
5437        .expect_err("strict mode should fail");
5438        assert!(
5439            strict_error
5440                .to_string()
5441                .contains("`if` is not supported yet")
5442        );
5443
5444        let warning_result = OpenApiImporter::new(
5445            document,
5446            json_test_source(spec),
5447            LoadOpenApiOptions {
5448                ignore_unhandled: true,
5449                ..Default::default()
5450            },
5451        )
5452        .build_ir()
5453        .expect("ignore mode should succeed");
5454        assert!(
5455            warning_result
5456                .warnings
5457                .iter()
5458                .any(|warning| matches!(&warning.kind, DiagnosticKind::UnsupportedSchemaKeyword { keyword } if keyword == "if"))
5459        );
5460
5461        // Verify `not` is silently ignored (no error, no warning).
5462        let not_spec = r##"
5463{
5464  "openapi": "3.1.0",
5465  "paths": {},
5466  "components": {
5467    "schemas": {
5468      "NotSchema": {
5469        "not": { "type": "object" }
5470      }
5471    }
5472  }
5473}
5474"##;
5475        let not_document: OpenApiDocument =
5476            serde_json::from_str(not_spec).expect("valid test spec");
5477        let not_result = OpenApiImporter::new(
5478            not_document,
5479            json_test_source(not_spec),
5480            LoadOpenApiOptions::default(),
5481        )
5482        .build_ir()
5483        .expect("`not` keyword should be silently ignored");
5484        assert!(
5485            not_result.warnings.is_empty(),
5486            "`not` should produce no warnings"
5487        );
5488    }
5489
5490    #[test]
5491    fn errors_on_unknown_schema_keywords() {
5492        let spec = r##"
5493{
5494  "openapi": "3.1.0",
5495  "paths": {},
5496  "components": {
5497    "schemas": {
5498      "PatchSchema": {
5499        "type": "string",
5500        "frobnicate": true
5501      }
5502    }
5503  }
5504}
5505"##;
5506
5507        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
5508        let error = OpenApiImporter::new(
5509            document,
5510            json_test_source(spec),
5511            LoadOpenApiOptions::default(),
5512        )
5513        .build_ir()
5514        .expect_err("unknown keyword should fail");
5515        assert!(
5516            error
5517                .to_string()
5518                .contains("unknown schema keyword `frobnicate`")
5519        );
5520    }
5521
5522    #[test]
5523    fn ignores_known_non_codegen_schema_keywords() {
5524        let spec = r##"
5525{
5526  "openapi": "3.1.0",
5527  "paths": {},
5528  "components": {
5529    "schemas": {
5530      "PatchSchema": {
5531        "type": "string",
5532        "description": "some text",
5533        "default": "value",
5534        "minLength": 1,
5535        "contentEncoding": "base64",
5536        "externalDocs": {
5537          "description": "More details",
5538          "url": "https://example.com/schema-docs"
5539        },
5540        "xml": {
5541          "name": "patchSchema"
5542        }
5543      }
5544    }
5545  }
5546}
5547"##;
5548
5549        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
5550        let result = OpenApiImporter::new(
5551            document,
5552            json_test_source(spec),
5553            LoadOpenApiOptions::default(),
5554        )
5555        .build_ir()
5556        .expect("known ignored keywords should not fail");
5557        assert!(result.warnings.is_empty());
5558    }
5559
5560    #[test]
5561    fn json_parse_errors_include_schema_path_and_source_context() {
5562        let spec = r##"
5563{
5564  "openapi": "3.1.0",
5565  "paths": {},
5566  "components": {
5567    "schemas": {
5568      "Broken": {
5569        "type": "object",
5570        "title": ["not", "a", "string"]
5571      }
5572    }
5573  }
5574}
5575"##;
5576
5577        let error = parse_json_openapi_document(Path::new("broken.json"), spec)
5578            .expect_err("invalid schema shape should fail during deserialization");
5579        let message = error.to_string();
5580        assert!(message.contains("failed to parse JSON OpenAPI document `broken.json`"));
5581        assert!(message.contains("schema mismatch at `components.schemas.Broken.title`"));
5582        assert!(message.contains("invalid type"));
5583        assert!(message.contains("source:         \"title\": [\"not\", \"a\", \"string\"]"));
5584        assert!(message.contains("note: this usually means"));
5585    }
5586
5587    #[test]
5588    fn yaml_loader_ignores_tab_only_blank_lines_in_block_scalars() {
5589        let spec = r##"
5590openapi: 3.1.0
5591paths: {}
5592components:
5593  schemas:
5594    AdditionalDataAirline:
5595      type: object
5596      properties:
5597        airline.leg.date_of_travel:
5598          description: |-
5599            	
5600            Date and time of travel in ISO 8601 format.
5601          type: string
5602"##;
5603
5604        let loaded = parse_yaml_openapi_document(Path::new("broken.yaml"), spec)
5605            .expect("tab-only blank lines should be normalized before YAML parsing");
5606        let result = OpenApiImporter::new(
5607            loaded.document,
5608            loaded.source,
5609            LoadOpenApiOptions::default(),
5610        )
5611        .build_ir()
5612        .expect("normalized YAML should import");
5613
5614        let model = result
5615            .ir
5616            .models
5617            .iter()
5618            .find(|model| model.name == "AdditionalDataAirline")
5619            .expect("model should exist");
5620        assert!(
5621            model
5622                .fields
5623                .iter()
5624                .any(|field| field.name == "airline.leg.date_of_travel")
5625        );
5626    }
5627
5628    #[test]
5629    fn preserves_content_encoding_metadata_in_ir_attributes() {
5630        let spec = r##"
5631{
5632  "openapi": "3.1.0",
5633  "paths": {
5634    "/widgets": {
5635      "post": {
5636        "operationId": "create_widget",
5637        "parameters": [
5638          {
5639            "name": "token",
5640            "in": "query",
5641            "required": true,
5642            "schema": {
5643              "type": "string",
5644              "contentEncoding": "base64"
5645            }
5646          }
5647        ],
5648        "requestBody": {
5649          "required": true,
5650          "content": {
5651            "application/json": {
5652              "schema": {
5653                "type": "string",
5654                "contentEncoding": "base64",
5655                "contentMediaType": "application/octet-stream"
5656              }
5657            }
5658          }
5659        },
5660        "responses": {
5661          "200": {
5662            "description": "ok",
5663            "content": {
5664              "application/json": {
5665                "schema": {
5666                  "$ref": "#/components/schemas/EncodedValue"
5667                }
5668              }
5669            }
5670          }
5671        }
5672      }
5673    }
5674  },
5675  "components": {
5676    "schemas": {
5677      "EncodedValue": {
5678        "type": "object",
5679        "properties": {
5680          "payload": {
5681            "type": "string",
5682            "contentEncoding": "base64"
5683          }
5684        }
5685      }
5686    }
5687  }
5688}
5689"##;
5690
5691        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid test spec");
5692        let result = OpenApiImporter::new(
5693            document,
5694            json_test_source(spec),
5695            LoadOpenApiOptions::default(),
5696        )
5697        .build_ir()
5698        .expect("content encoding metadata should be preserved");
5699
5700        let operation = result
5701            .ir
5702            .operations
5703            .iter()
5704            .find(|operation| operation.name == "create_widget")
5705            .expect("operation should exist");
5706        assert_eq!(
5707            operation.params[0]
5708                .attributes
5709                .get("content_encoding")
5710                .and_then(Value::as_str),
5711            Some("base64")
5712        );
5713        assert_eq!(
5714            operation
5715                .request_body
5716                .as_ref()
5717                .and_then(|request_body| request_body.attributes.get("content_media_type"))
5718                .and_then(Value::as_str),
5719            Some("application/octet-stream")
5720        );
5721        let response = operation
5722            .responses
5723            .iter()
5724            .find(|response| response.status == "200")
5725            .expect("response should exist");
5726        assert_eq!(
5727            response.type_ref.as_ref(),
5728            Some(&TypeRef::named("EncodedValue"))
5729        );
5730        let model = result
5731            .ir
5732            .models
5733            .iter()
5734            .find(|model| model.name == "EncodedValue")
5735            .expect("model should exist");
5736        assert_eq!(
5737            model.fields[0]
5738                .attributes
5739                .get("content_encoding")
5740                .and_then(Value::as_str),
5741            Some("base64")
5742        );
5743    }
5744
5745    #[test]
5746    fn supports_swagger_form_data_parameters() {
5747        let spec = r##"
5748{
5749  "swagger": "2.0",
5750  "consumes": ["application/x-www-form-urlencoded"],
5751  "paths": {
5752    "/widgets": {
5753      "post": {
5754        "operationId": "create_widget",
5755        "parameters": [
5756          { "$ref": "#/parameters/form_name" }
5757        ],
5758        "responses": {
5759          "200": {
5760            "description": "ok"
5761          }
5762        }
5763      }
5764    }
5765  },
5766  "parameters": {
5767    "form_name": {
5768      "name": "name",
5769      "in": "formData",
5770      "description": "Widget name",
5771      "required": true,
5772      "type": "string"
5773    }
5774  }
5775}
5776"##;
5777
5778        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid swagger spec");
5779        let result = OpenApiImporter::new(
5780            document,
5781            json_test_source(spec),
5782            LoadOpenApiOptions::default(),
5783        )
5784        .build_ir()
5785        .expect("formData parameters should normalize into a request body");
5786        let operation = result
5787            .ir
5788            .operations
5789            .iter()
5790            .find(|operation| operation.name == "create_widget")
5791            .expect("operation should exist");
5792        assert!(operation.params.is_empty());
5793        let request_body = operation
5794            .request_body
5795            .as_ref()
5796            .expect("formData should create a request body");
5797        assert_eq!(request_body.media_type, "application/x-www-form-urlencoded");
5798        assert_eq!(
5799            request_body.type_ref.as_ref(),
5800            Some(&TypeRef::named("CreateWidgetRequest"))
5801        );
5802        let body_model = result
5803            .ir
5804            .models
5805            .iter()
5806            .find(|model| model.name == "CreateWidgetRequest")
5807            .expect("inline form body model should exist");
5808        assert_eq!(body_model.fields[0].name, "name");
5809        assert!(!body_model.fields[0].optional);
5810        assert_eq!(
5811            body_model.fields[0]
5812                .attributes
5813                .get("description")
5814                .and_then(Value::as_str),
5815            Some("Widget name")
5816        );
5817    }
5818
5819    #[test]
5820    fn supports_path_local_parameter_references() {
5821        let spec = r##"
5822{
5823  "swagger": "2.0",
5824  "definitions": {
5825    "Widget": {
5826      "type": "object",
5827      "properties": {
5828        "id": { "type": "string" }
5829      }
5830    }
5831  },
5832  "paths": {
5833    "/widgets/{id}": {
5834      "post": {
5835        "operationId": "get_widget",
5836        "parameters": [
5837          {
5838            "name": "id",
5839            "in": "path",
5840            "required": true,
5841            "type": "string"
5842          }
5843        ],
5844        "responses": {
5845          "200": {
5846            "description": "ok",
5847            "schema": {
5848              "$ref": "#/definitions/Widget"
5849            }
5850          }
5851        }
5852      }
5853    },
5854    "/widget-ids": {
5855      "get": {
5856        "operationId": "list_widget_ids",
5857        "parameters": [
5858          {
5859            "$ref": "#/paths/~1widgets~1%7Bid%7D/post/parameters/0"
5860          }
5861        ],
5862        "responses": {
5863          "200": {
5864            "description": "ok"
5865          }
5866        }
5867      }
5868    }
5869  }
5870}
5871"##;
5872
5873        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid swagger spec");
5874        let result = OpenApiImporter::new(
5875            document,
5876            json_test_source(spec),
5877            LoadOpenApiOptions::default(),
5878        )
5879        .build_ir()
5880        .expect("path-local parameter refs should resolve");
5881        let operation = result
5882            .ir
5883            .operations
5884            .iter()
5885            .find(|operation| operation.name == "list_widget_ids")
5886            .expect("operation should exist");
5887        assert_eq!(operation.params[0].name, "id");
5888        assert_eq!(operation.params[0].location, ParameterLocation::Path);
5889        assert_eq!(
5890            result
5891                .ir
5892                .models
5893                .iter()
5894                .find(|model| model.name == "Widget")
5895                .map(|model| model.name.as_str()),
5896            Some("Widget")
5897        );
5898    }
5899
5900    #[test]
5901    fn de_duplicates_operation_names() {
5902        let spec = r##"
5903{
5904  "openapi": "3.1.0",
5905  "paths": {
5906    "/widgets": {
5907      "get": {
5908        "operationId": "get_widgets",
5909        "responses": {
5910          "200": { "description": "ok" }
5911        }
5912      }
5913    },
5914    "/users": {
5915      "get": {
5916        "operationId": "get_widgets",
5917        "responses": {
5918          "200": { "description": "ok" }
5919        }
5920      }
5921    }
5922  }
5923}
5924"##;
5925
5926        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid spec");
5927        let result = OpenApiImporter::new(
5928            document,
5929            json_test_source(spec),
5930            LoadOpenApiOptions::default(),
5931        )
5932        .build_ir()
5933        .expect("duplicate operation ids should be disambiguated");
5934        let names = result
5935            .ir
5936            .operations
5937            .iter()
5938            .map(|operation| operation.name.as_str())
5939            .collect::<Vec<_>>();
5940        assert_eq!(names, vec!["get_widgets", "get_widgets_2"]);
5941    }
5942
5943    #[test]
5944    fn supports_numeric_all_of_type_widening() {
5945        let spec = r##"
5946{
5947  "openapi": "3.1.0",
5948  "paths": {},
5949  "components": {
5950    "schemas": {
5951      "WidgetEvent": {
5952        "type": "object",
5953        "properties": {
5954          "payload": {
5955            "allOf": [
5956              {
5957                "type": "object",
5958                "properties": {
5959                  "count": { "type": "integer" }
5960                }
5961              },
5962              {
5963                "type": "object",
5964                "properties": {
5965                  "count": { "type": "number" }
5966                }
5967              }
5968            ]
5969          }
5970        }
5971      }
5972    }
5973  }
5974}
5975"##;
5976
5977        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid spec");
5978        let result = OpenApiImporter::new(
5979            document,
5980            json_test_source(spec),
5981            LoadOpenApiOptions::default(),
5982        )
5983        .build_ir()
5984        .expect("numeric allOf overlays should merge");
5985        assert!(result.warnings.is_empty());
5986        let payload_model = result
5987            .ir
5988            .models
5989            .iter()
5990            .find(|model| model.name == "WidgetEventPayload")
5991            .expect("inline payload model should exist");
5992        assert_eq!(
5993            payload_model.fields[0].type_ref,
5994            TypeRef::primitive("number")
5995        );
5996    }
5997
5998    #[test]
5999    fn supports_nested_schema_definitions_references() {
6000        let spec = r##"
6001{
6002  "openapi": "3.1.0",
6003  "paths": {},
6004  "components": {
6005    "schemas": {
6006      "Transfer": {
6007        "type": "object",
6008        "definitions": {
6009          "money": {
6010            "type": "object",
6011            "properties": {
6012              "currency": { "type": "string" }
6013            }
6014          }
6015        },
6016        "properties": {
6017          "amount": {
6018            "$ref": "#/components/schemas/Transfer/definitions/money"
6019          },
6020          "currency": {
6021            "$ref": "#/components/schemas/Transfer/definitions/money/properties/currency"
6022          }
6023        }
6024      }
6025    }
6026  }
6027}
6028"##;
6029
6030        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid spec");
6031        let result = OpenApiImporter::new(
6032            document,
6033            json_test_source(spec),
6034            LoadOpenApiOptions::default(),
6035        )
6036        .build_ir()
6037        .expect("nested schema definitions refs should resolve");
6038        let transfer = result
6039            .ir
6040            .models
6041            .iter()
6042            .find(|model| model.name == "Transfer")
6043            .expect("Transfer model should exist");
6044        assert_eq!(transfer.fields[0].name, "amount");
6045        let amount_type_name = match &transfer.fields[0].type_ref {
6046            TypeRef::Named { name } => name.clone(),
6047            other => panic!("expected named type for nested definition, got {other:?}"),
6048        };
6049        assert!(
6050            amount_type_name.starts_with("TransferAmount"),
6051            "nested definition should be materialized as a TransferAmount* inline model"
6052        );
6053        assert!(
6054            result
6055                .ir
6056                .models
6057                .iter()
6058                .any(|model| model.name == amount_type_name),
6059            "nested local definition model should be imported"
6060        );
6061        assert_eq!(transfer.fields[1].type_ref, TypeRef::primitive("string"));
6062    }
6063
6064    #[test]
6065    fn supports_nullable_all_of_overlays_on_referenced_scalars() {
6066        let spec = r##"
6067{
6068  "openapi": "3.1.0",
6069  "paths": {},
6070  "components": {
6071    "schemas": {
6072      "Transfer": {
6073        "type": "object",
6074        "definitions": {
6075          "money": {
6076            "type": "string"
6077          }
6078        }
6079      },
6080      "Bill": {
6081        "type": "object",
6082        "properties": {
6083          "currency": {
6084            "allOf": [
6085              { "$ref": "#/components/schemas/Transfer/definitions/money" },
6086              { "type": "null" }
6087            ]
6088          }
6089        }
6090      }
6091    }
6092  }
6093}
6094"##;
6095
6096        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid spec");
6097        let result = OpenApiImporter::new(
6098            document,
6099            json_test_source(spec),
6100            LoadOpenApiOptions::default(),
6101        )
6102        .build_ir()
6103        .expect("nullable allOf overlay should merge");
6104        let bill = result
6105            .ir
6106            .models
6107            .iter()
6108            .find(|model| model.name == "Bill")
6109            .expect("Bill model should exist");
6110        assert_eq!(bill.fields[0].name, "currency");
6111        assert_eq!(bill.fields[0].type_ref, TypeRef::primitive("string"));
6112        assert!(bill.fields[0].nullable);
6113    }
6114
6115    #[test]
6116    fn supports_recursive_local_object_references_without_unbounded_inline_models() {
6117        let spec = r##"
6118{
6119  "openapi": "3.1.0",
6120  "paths": {},
6121  "components": {
6122    "schemas": {
6123      "PushOption": {
6124        "definitions": {
6125          "pushOptionProperty": {
6126            "type": "object",
6127            "properties": {
6128              "properties": {
6129                "type": "object",
6130                "additionalProperties": {
6131                  "$ref": "#/components/schemas/PushOption/definitions/pushOptionProperty"
6132                }
6133              }
6134            }
6135          }
6136        },
6137        "type": "object",
6138        "properties": {
6139          "properties": {
6140            "type": "object",
6141            "additionalProperties": {
6142              "$ref": "#/components/schemas/PushOption/definitions/pushOptionProperty"
6143            }
6144          }
6145        }
6146      }
6147    }
6148  }
6149}
6150"##;
6151
6152        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid spec");
6153        let result = OpenApiImporter::new(
6154            document,
6155            json_test_source(spec),
6156            LoadOpenApiOptions::default(),
6157        )
6158        .build_ir()
6159        .expect("recursive local refs should not recurse forever");
6160
6161        let push_option = result
6162            .ir
6163            .models
6164            .iter()
6165            .find(|model| model.name == "PushOption")
6166            .expect("PushOption model should exist");
6167        let properties_field = push_option
6168            .fields
6169            .iter()
6170            .find(|field| field.name == "properties")
6171            .expect("properties field should exist");
6172        assert!(matches!(properties_field.type_ref, TypeRef::Map { .. }));
6173
6174        let inline_models = result
6175            .ir
6176            .models
6177            .iter()
6178            .filter(|model| model.name.contains("Properties"))
6179            .collect::<Vec<_>>();
6180        assert!(
6181            inline_models.len() <= 2,
6182            "recursive local refs should reuse an inline model instead of generating an unbounded chain"
6183        );
6184    }
6185
6186    #[test]
6187    fn supports_collection_format_metadata() {
6188        let spec = r##"
6189{
6190  "swagger": "2.0",
6191  "paths": {
6192    "/widgets": {
6193      "get": {
6194        "operationId": "list_widgets",
6195        "parameters": [
6196          {
6197            "name": "categories",
6198            "in": "query",
6199            "type": "array",
6200            "collectionFormat": "csv",
6201            "items": {
6202              "type": "string",
6203              "collectionFormat": "csv"
6204            }
6205          }
6206        ],
6207        "responses": {
6208          "200": {
6209            "description": "ok"
6210          }
6211        }
6212      }
6213    }
6214  }
6215}
6216"##;
6217
6218        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid spec");
6219        let result = OpenApiImporter::new(
6220            document,
6221            json_test_source(spec),
6222            LoadOpenApiOptions::default(),
6223        )
6224        .build_ir()
6225        .expect("collectionFormat should be accepted");
6226        let operation = result
6227            .ir
6228            .operations
6229            .iter()
6230            .find(|operation| operation.name == "list_widgets")
6231            .expect("operation should exist");
6232        assert_eq!(
6233            operation.params[0]
6234                .attributes
6235                .get("collection_format")
6236                .and_then(Value::as_str),
6237            Some("csv")
6238        );
6239    }
6240
6241    #[test]
6242    fn supports_all_of_with_multiple_discriminators() {
6243        let spec = r##"
6244{
6245  "openapi": "3.1.0",
6246  "paths": {},
6247  "components": {
6248    "schemas": {
6249      "Base": {
6250        "type": "object",
6251        "discriminator": {
6252          "propertyName": "serviceType"
6253        },
6254        "properties": {
6255          "serviceType": { "type": "string" }
6256        }
6257      },
6258      "Derived": {
6259        "allOf": [
6260          { "$ref": "#/components/schemas/Base" },
6261          {
6262            "type": "object",
6263            "discriminator": {
6264              "propertyName": "credentialType"
6265            },
6266            "properties": {
6267              "credentialType": { "type": "string" }
6268            }
6269          }
6270        ]
6271      }
6272    }
6273  }
6274}
6275"##;
6276
6277        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid spec");
6278        let result = OpenApiImporter::new(
6279            document,
6280            json_test_source(spec),
6281            LoadOpenApiOptions::default(),
6282        )
6283        .build_ir()
6284        .expect("allOf discriminator metadata should not fail");
6285        assert!(result.warnings.is_empty());
6286    }
6287
6288    #[test]
6289    fn empty_property_names_fail_cleanly_or_warn_when_ignored() {
6290        let spec = r##"
6291{
6292  "openapi": "3.1.0",
6293  "paths": {},
6294  "components": {
6295    "schemas": {
6296      "Broken": {
6297        "type": "object",
6298        "properties": {
6299          "": { "type": "string" }
6300        }
6301      }
6302    }
6303  }
6304}
6305"##;
6306
6307        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid spec");
6308        let error = OpenApiImporter::new(
6309            document.clone(),
6310            json_test_source(spec),
6311            LoadOpenApiOptions::default(),
6312        )
6313        .build_ir()
6314        .expect_err("empty property names should fail by default");
6315        assert!(error.to_string().contains("property #1 has an empty name"));
6316
6317        let result = OpenApiImporter::new(
6318            document,
6319            json_test_source(spec),
6320            LoadOpenApiOptions {
6321                ignore_unhandled: true,
6322                emit_timings: false,
6323            },
6324        )
6325        .build_ir()
6326        .expect("empty property names should be synthesized when warnings are allowed");
6327        let broken = result
6328            .ir
6329            .models
6330            .iter()
6331            .find(|model| model.name == "Broken")
6332            .expect("Broken model should exist");
6333        assert_eq!(broken.fields[0].name, "unnamed_field_1");
6334    }
6335
6336    #[test]
6337    fn supports_ref_to_components_responses() {
6338        let spec = r##"
6339{
6340  "openapi": "3.1.0",
6341  "paths": {
6342    "/widgets": {
6343      "get": {
6344        "operationId": "list_widgets",
6345        "parameters": [],
6346        "responses": {
6347          "200": {
6348            "description": "ok",
6349            "content": {
6350              "application/json": {
6351                "schema": { "$ref": "#/components/responses/WidgetList/content/application~1json/schema" }
6352              }
6353            }
6354          }
6355        }
6356      }
6357    }
6358  },
6359  "components": {
6360    "responses": {
6361      "WidgetList": {
6362        "description": "A list of widgets",
6363        "content": {
6364          "application/json": {
6365            "schema": {
6366              "type": "array",
6367              "items": { "type": "string" }
6368            }
6369          }
6370        }
6371      }
6372    }
6373  }
6374}
6375"##;
6376        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid spec");
6377        let result = OpenApiImporter::new(
6378            document,
6379            json_test_source(spec),
6380            LoadOpenApiOptions::default(),
6381        )
6382        .build_ir()
6383        .expect("$ref to components/responses should succeed");
6384        let op = result
6385            .ir
6386            .operations
6387            .iter()
6388            .find(|o| o.name == "list_widgets")
6389            .expect("op should exist");
6390        let response = op.responses.first().expect("should have a response");
6391        // The $ref resolves to an array type; type_ref should be Some (not None).
6392        assert!(
6393            response.type_ref.is_some(),
6394            "response type_ref should be resolved, got: {response:?}"
6395        );
6396    }
6397
6398    #[test]
6399    fn supports_ref_to_path_response_schema() {
6400        let spec = r##"
6401{
6402  "openapi": "3.1.0",
6403  "paths": {
6404    "/widgets": {
6405      "get": {
6406        "operationId": "list_widgets",
6407        "parameters": [],
6408        "responses": {
6409          "200": {
6410            "description": "ok",
6411            "content": {
6412              "application/json": {
6413                "schema": { "type": "array", "items": { "type": "string" } }
6414              }
6415            }
6416          }
6417        }
6418      },
6419      "post": {
6420        "operationId": "create_widget",
6421        "parameters": [],
6422        "responses": {
6423          "201": {
6424            "description": "created",
6425            "content": {
6426              "application/json": {
6427                "schema": { "$ref": "#/paths/~1widgets/get/responses/200/content/application~1json/schema" }
6428              }
6429            }
6430          }
6431        }
6432      }
6433    }
6434  }
6435}
6436"##;
6437        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid spec");
6438        let result = OpenApiImporter::new(
6439            document,
6440            json_test_source(spec),
6441            LoadOpenApiOptions::default(),
6442        )
6443        .build_ir()
6444        .expect("$ref to path response schema should succeed");
6445        let op = result
6446            .ir
6447            .operations
6448            .iter()
6449            .find(|o| o.name == "create_widget")
6450            .expect("op should exist");
6451        let response = op.responses.first().expect("should have a response");
6452        // The $ref resolves to an array-of-string type; type_ref should be Some.
6453        assert!(
6454            response.type_ref.is_some(),
6455            "response type_ref should be resolved, got: {response:?}"
6456        );
6457    }
6458
6459    #[test]
6460    fn supports_content_based_parameters() {
6461        let spec = r##"
6462{
6463  "openapi": "3.1.0",
6464  "paths": {
6465    "/widgets": {
6466      "get": {
6467        "operationId": "list_widgets",
6468        "parameters": [
6469          {
6470            "name": "filter",
6471            "in": "query",
6472            "content": {
6473              "application/json": {
6474                "schema": { "type": "object", "properties": { "name": { "type": "string" } } }
6475              }
6476            }
6477          }
6478        ],
6479        "responses": { "200": { "description": "ok" } }
6480      }
6481    }
6482  }
6483}
6484"##;
6485        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid spec");
6486        let result = OpenApiImporter::new(
6487            document,
6488            json_test_source(spec),
6489            LoadOpenApiOptions::default(),
6490        )
6491        .build_ir()
6492        .expect("content-based parameter should succeed");
6493        let op = result
6494            .ir
6495            .operations
6496            .iter()
6497            .find(|o| o.name == "list_widgets")
6498            .expect("op should exist");
6499        assert_eq!(op.params.len(), 1);
6500        assert_eq!(op.params[0].name, "filter");
6501    }
6502
6503    #[test]
6504    fn supports_format_string_as_type_inference() {
6505        let spec = r##"
6506{
6507  "openapi": "3.1.0",
6508  "paths": {},
6509  "components": {
6510    "schemas": {
6511      "Widget": {
6512        "type": "object",
6513        "properties": {
6514          "name": { "format": "string", "description": "The widget name" },
6515          "score": { "format": "float" }
6516        }
6517      }
6518    }
6519  }
6520}
6521"##;
6522        let document: OpenApiDocument = serde_json::from_str(spec).expect("valid spec");
6523        let result = OpenApiImporter::new(
6524            document,
6525            json_test_source(spec),
6526            LoadOpenApiOptions::default(),
6527        )
6528        .build_ir()
6529        .expect("format:string schema shape should succeed");
6530        let model = result
6531            .ir
6532            .models
6533            .iter()
6534            .find(|m| m.name == "Widget")
6535            .expect("model should exist");
6536        let name_field = model
6537            .fields
6538            .iter()
6539            .find(|f| f.name == "name")
6540            .expect("name field should exist");
6541        assert!(matches!(&name_field.type_ref, t if format!("{t:?}").contains("string")));
6542        let score_field = model
6543            .fields
6544            .iter()
6545            .find(|f| f.name == "score")
6546            .expect("score field should exist");
6547        assert!(matches!(&score_field.type_ref, t if format!("{t:?}").contains("number")));
6548    }
6549}