Skip to main content

forge_parser/
lib.rs

1//! OpenAPI 3.0 JSON → `forge_ir::Ir`.
2//!
3//! Stage 3 supports a deliberately narrow subset of OpenAPI 3.0.x. See
4//! `docs/parser-coverage.md` for the precise list. Anything outside the
5//! subset produces an `error`-severity diagnostic with a JSON-pointer
6//! location — never silent best-effort output.
7//!
8//! # Public surface
9//!
10//! ```no_run
11//! let json = std::fs::read_to_string("openapi.json").unwrap();
12//! match forge_parser::parse_str(&json) {
13//!     Ok(out) => {
14//!         for d in &out.diagnostics { eprintln!("{}: {}", d.code, d.message); }
15//!         if let Some(ir) = out.spec { println!("{} operations", ir.operations.len()); }
16//!     }
17//!     Err(e) => eprintln!("fatal: {e}"),
18//! }
19//! ```
20
21#![forbid(unsafe_code)]
22
23mod ctx;
24mod diag;
25pub mod external;
26mod finalize;
27mod normalize;
28mod operations;
29mod pointer;
30mod ref_walk;
31mod refs;
32mod sanitize;
33mod schema;
34mod security;
35mod value;
36
37pub use external::{FileResolver, NoExternalResolver, Resolver, ResolverError};
38
39use forge_ir::{
40    ApiInfo, Callback, Contact, Diagnostic, Example, ExternalDocs, Ir, Link, Server,
41    ServerVariable, SpecLocation, Tag, XmlObject,
42};
43use serde_json::Value as J;
44use thiserror::Error;
45
46use crate::ctx::Ctx;
47use crate::pointer::Ptr;
48use crate::schema::{parse_schema, NameHint};
49
50#[derive(Debug, Error)]
51pub enum ParseError {
52    #[error("invalid JSON: {0}")]
53    InvalidJson(String),
54    #[error("input is empty")]
55    Empty,
56    #[error("root document must be a JSON object")]
57    NotObject,
58    #[error("could not read input file `{path}`: {message}")]
59    Io { path: String, message: String },
60}
61
62#[derive(Debug, Default)]
63pub struct ParseOutput {
64    pub spec: Option<Ir>,
65    pub diagnostics: Vec<Diagnostic>,
66}
67
68/// Parse an OpenAPI 3.0 JSON document.
69pub fn parse_str(source: &str) -> Result<ParseOutput, ParseError> {
70    parse_str_with_file(source, None)
71}
72
73/// Parse with a `file` label that gets attached to every `SpecLocation`.
74pub fn parse_str_with_file(source: &str, file: Option<&str>) -> Result<ParseOutput, ParseError> {
75    parse_with_resolver(
76        source,
77        file,
78        Box::new(external::NoExternalResolver),
79        ctx::synthetic_main_path(),
80    )
81}
82
83/// Parse a spec from a filesystem path. External `$ref`s are resolved
84/// relative to the spec file's parent directory; paths that escape that
85/// root are rejected with `parser/E-EXTERNAL-REF`.
86pub fn parse_path(path: &std::path::Path) -> Result<ParseOutput, ParseError> {
87    let canonical = path.canonicalize().map_err(|e| ParseError::Io {
88        path: path.display().to_string(),
89        message: e.to_string(),
90    })?;
91    let source = std::fs::read_to_string(&canonical).map_err(|e| ParseError::Io {
92        path: canonical.display().to_string(),
93        message: e.to_string(),
94    })?;
95    let resolver = external::FileResolver::new(&canonical).map_err(|e| ParseError::Io {
96        path: canonical.display().to_string(),
97        message: e.to_string(),
98    })?;
99    // The label only goes into `SpecLocation.file`. Use the filename so
100    // generated diagnostics stay portable across machines (callers who
101    // need the absolute path can join it with a known root themselves).
102    let label = canonical
103        .file_name()
104        .and_then(|s| s.to_str())
105        .unwrap_or("")
106        .to_string();
107    parse_with_resolver(&source, Some(&label), Box::new(resolver), canonical)
108}
109
110fn parse_with_resolver(
111    source: &str,
112    file: Option<&str>,
113    resolver: Box<dyn external::Resolver>,
114    main_doc: std::path::PathBuf,
115) -> Result<ParseOutput, ParseError> {
116    if source.trim().is_empty() {
117        return Err(ParseError::Empty);
118    }
119    let root: J =
120        serde_json::from_str(source).map_err(|e| ParseError::InvalidJson(e.to_string()))?;
121    let root_map = match &root {
122        J::Object(m) => m,
123        _ => return Err(ParseError::NotObject),
124    };
125
126    let mut ctx = Ctx::with_resolver(file, resolver, main_doc);
127    // Cache the main spec's root so structural refs (`#/components/parameters/Page`)
128    // can resolve without going back through the resolver.
129    ctx.doc_roots.insert(ctx.current_doc.clone(), root.clone());
130    let mut ptr = Ptr::new();
131
132    // 1. Version check.
133    if !check_version(&mut ctx, root_map, &mut ptr) {
134        // Bail early but still emit ParseOutput with diagnostic.
135        return Ok(ParseOutput {
136            spec: None,
137            diagnostics: ctx.diagnostics,
138        });
139    }
140
141    // 2. Info / servers (best-effort; missing fields produce diagnostics).
142    parse_info(&mut ctx, root_map, &mut ptr);
143    parse_servers(&mut ctx, root_map, &mut ptr);
144    let tags = parse_tags(&mut ctx, root_map, &mut ptr);
145
146    // 3. Security schemes (parses what it can; oauth2 / openIdConnect emit
147    //    deferred-feature diagnostics).
148    security::walk_components(&mut ctx, root_map, &mut ptr);
149
150    // 4. Components: pre-register schemas for forward $ref resolution, then
151    //    walk each in dependency-aware order so allOf $refs resolve to
152    //    already-walked targets.
153    register_component_schemas(&mut ctx, root_map);
154    walk_component_schemas(&mut ctx, root_map, &mut ptr);
155
156    // 5. Top-level `security` (default for operations that don't override).
157    if let Some(top_sec) = root_map.get("security") {
158        ptr.with_token("security", |ptr| {
159            ctx.default_security = security::parse_requirements(&mut ctx, top_sec, ptr);
160        });
161    }
162
163    // 6. Paths.
164    if let Some(paths) = root_map.get("paths") {
165        ptr.with_token("paths", |ptr| {
166            operations::parse_paths(&mut ctx, paths, ptr);
167        });
168    }
169
170    // 6b. 3.1+ webhooks (top-level inbound operations). Generators that
171    //     handle webhooks read `Ir.webhooks`; client generators ignore.
172    if let Some(webhooks) = root_map.get("webhooks") {
173        ptr.with_token("webhooks", |ptr| {
174            operations::parse_webhooks(&mut ctx, webhooks, ptr);
175        });
176    }
177
178    // 7. Root-level `externalDocs`. Reads here rather than in
179    //    `parse_info` because OAS puts it on the root, not nested under
180    //    `info`. Carried on `Ir.external_docs`; the rest of
181    //    `Ir.docs` stays empty (root-level prose lives on `info.docs`).
182    let root_external_docs = parse_external_docs(&mut ctx, root_map.get("externalDocs"), &mut ptr);
183
184    // 7b. Surface unused `components.pathItems` declarations. They
185    //     don't affect the IR (operations land via $ref from paths /
186    //     webhooks / callbacks); a declared-and-never-used entry is
187    //     almost certainly a spec bug.
188    scan_unused_component_path_items(&mut ctx, root_map, &mut ptr);
189    scan_unused_component_media_types(&mut ctx, root_map, &mut ptr);
190
191    // 7c. Root-level annotations. Carried verbatim — generators that
192    //     care can read them; most ignore them.
193    let json_schema_dialect = root_map
194        .get("jsonSchemaDialect")
195        .and_then(J::as_str)
196        .map(String::from);
197    let self_url = root_map.get("$self").and_then(J::as_str).map(String::from);
198
199    // 8. Build the IR and finalize (sort + topo).
200    let mut ir = Ir {
201        info: ctx.info.take().unwrap_or(ApiInfo {
202            title: String::new(),
203            version: String::new(),
204            summary: None,
205            description: None,
206            terms_of_service: None,
207            contact: None,
208            license_name: None,
209            license_url: None,
210            license_identifier: None,
211            extensions: vec![],
212        }),
213        operations: std::mem::take(&mut ctx.operations),
214        types: ctx.types.values().cloned().collect::<Vec<_>>(),
215        security_schemes: std::mem::take(&mut ctx.security_schemes),
216        servers: std::mem::take(&mut ctx.servers),
217        webhooks: std::mem::take(&mut ctx.webhooks),
218        external_docs: root_external_docs,
219        tags,
220        json_schema_dialect,
221        self_url,
222        values: std::mem::take(&mut ctx.values).finish(),
223    };
224    let mut diagnostics = std::mem::take(&mut ctx.diagnostics);
225    diagnostics.extend(finalize::canonicalize(&mut ir));
226
227    Ok(ParseOutput {
228        spec: Some(ir),
229        diagnostics,
230    })
231}
232
233/// Walk the top-level `tags: []` array into `Ir.tags` records. The
234/// 3.2 `parent` / `kind` / `summary` fields are preserved; tags whose
235/// `parent` doesn't reference a declared sibling drop the parent ref
236/// with `parser/W-TAG-PARENT-DANGLING` so generators that render tag
237/// trees don't see broken nesting.
238fn parse_tags(ctx: &mut Ctx, root: &serde_json::Map<String, J>, ptr: &mut Ptr) -> Vec<Tag> {
239    let Some(J::Array(tags)) = root.get("tags") else {
240        return Vec::new();
241    };
242    let mut out: Vec<Tag> = Vec::new();
243    let mut declared_names: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
244    ptr.with_token("tags", |ptr| {
245        // Pass 1: collect declared names so the parent-ref check below
246        // can validate references regardless of declaration order.
247        for tag in tags.iter() {
248            if let Some(name) = tag
249                .as_object()
250                .and_then(|m| m.get("name"))
251                .and_then(J::as_str)
252            {
253                declared_names.insert(name.to_string());
254            }
255        }
256        for (i, tag) in tags.iter().enumerate() {
257            ptr.with_index(i, |ptr| {
258                let Some(map) = tag.as_object() else {
259                    ctx.push_diag(diag::err(
260                        diag::E_INVALID_TYPE,
261                        "tag must be an object",
262                        ptr.loc(ctx.file),
263                    ));
264                    return;
265                };
266                let Some(name) = map.get("name").and_then(J::as_str) else {
267                    ctx.push_diag(diag::err(
268                        diag::E_MISSING_FIELD,
269                        "tag is missing required `name`",
270                        ptr.loc(ctx.file),
271                    ));
272                    return;
273                };
274                // OAS §4.22: Tag carries summary (3.2), description,
275                // externalDocs.
276                let summary = crate::schema::summary(map);
277                let description = crate::schema::description(map);
278                let external_docs = parse_external_docs(ctx, map.get("externalDocs"), ptr);
279                let kind = map.get("kind").and_then(J::as_str).map(String::from);
280                let parent_raw = map.get("parent").and_then(J::as_str).map(String::from);
281                let parent = match parent_raw {
282                    Some(p) if !declared_names.contains(&p) => {
283                        ctx.push_diag(diag::warn(
284                            diag::W_TAG_PARENT_DANGLING,
285                            format!(
286                                "tag `{name}` references parent `{p}`, which is not declared in \
287                                 the top-level `tags` array; dropping the parent reference."
288                            ),
289                            ptr.loc(ctx.file),
290                        ));
291                        None
292                    }
293                    other => other,
294                };
295                let extensions = operations::collect_extensions(ctx, map, ptr);
296                out.push(Tag {
297                    name: name.to_string(),
298                    summary,
299                    description,
300                    external_docs,
301                    parent,
302                    kind,
303                    extensions,
304                });
305            });
306        }
307    });
308    // Determinism: sort by name. `Operation.tags` stays in declared order.
309    out.sort_by(|a, b| a.name.cmp(&b.name));
310    out
311}
312
313/// Versions the parser knows how to walk. Adding a new entry is the
314/// single edit needed to opt a future version into the existing pipeline;
315/// the per-feature differences are gated inside the walkers.
316const ACCEPTED_VERSION_PREFIXES: &[&str] = &["3.0.", "3.1.", "3.2."];
317
318/// Walk OAS `example` (3.0 single-literal), `examples` (3.1+ keyed
319/// map of Example Objects on parameters / media types), and the
320/// JSON-Schema 2020-12 `examples` array (bare literals on Schema
321/// Objects) off a schema / parameter / media-type entry. Returns the
322/// merged list with the 3.0 `example` stored under the synthetic key
323/// `"_default"` and each array-form element under `"_examples[<i>]"`
324/// so generators have one shape to read.
325///
326/// `$ref` into `components.examples.<Name>` resolves through the
327/// existing ref machinery. Example values (scalar or compound) are
328/// interned into [`Ctx::values`]; the only `W-EXAMPLE-DROPPED` warning
329/// remaining covers `value` + `externalValue` co-declaration.
330pub(crate) fn parse_examples(
331    ctx: &mut Ctx,
332    map: &serde_json::Map<String, J>,
333    ptr: &mut Ptr,
334) -> Vec<(String, Example)> {
335    let mut out = Vec::new();
336    // 3.0 single-form `example: <literal>`. Stored under "_default".
337    if let Some(raw) = map.get("example") {
338        ptr.with_token("example", |_ptr| {
339            let value = Some(ctx.values.intern_json(raw));
340            out.push((
341                "_default".to_string(),
342                Example {
343                    summary: None,
344                    description: None,
345                    value,
346                    external_value: None,
347                    data_value: None,
348                    serialized_value: None,
349                },
350            ));
351        });
352    }
353    // 3.1+ keyed `examples: { name: ExampleObject | $ref }`.
354    if let Some(J::Object(named)) = map.get("examples") {
355        ptr.with_token("examples", |ptr| {
356            for (name, entry) in named {
357                ptr.with_token(name, |ptr| {
358                    crate::ref_walk::with_resolved_object(ctx, entry, ptr, |ctx, resolved, ptr| {
359                        let Some(emap) = resolved.as_object() else {
360                            ctx.push_diag(diag::err(
361                                diag::E_INVALID_TYPE,
362                                "example must be an object",
363                                ptr.loc(ctx.file),
364                            ));
365                            return Some(());
366                        };
367                        let summary = emap.get("summary").and_then(J::as_str).map(String::from);
368                        let description = emap
369                            .get("description")
370                            .and_then(J::as_str)
371                            .map(String::from);
372                        let external_value = emap
373                            .get("externalValue")
374                            .and_then(J::as_str)
375                            .map(String::from);
376                        let value = emap.get("value").map(|raw| ctx.values.intern_json(raw));
377                        // OAS 3.2 added `dataValue` (parsed form) and
378                        // `serializedValue` (wire form) as a refinement
379                        // of `value`. Both compound and scalar shapes
380                        // survive via the value pool.
381                        let data_value =
382                            emap.get("dataValue").map(|raw| ctx.values.intern_json(raw));
383                        let serialized_value = emap
384                            .get("serializedValue")
385                            .and_then(J::as_str)
386                            .map(String::from);
387                        if value.is_some() && external_value.is_some() {
388                            ctx.push_diag(diag::err(
389                                diag::E_EXAMPLE_VALUE_CONFLICT,
390                                format!(
391                                    "example `{name}` declares both `value` and `externalValue`; \
392                                     OAS §4.7.20 makes them mutually exclusive. Keeping `value`."
393                                ),
394                                ptr.loc(ctx.file),
395                            ));
396                        }
397                        let kept_external = if value.is_some() {
398                            None
399                        } else {
400                            external_value
401                        };
402                        out.push((
403                            name.clone(),
404                            Example {
405                                summary,
406                                description,
407                                value,
408                                external_value: kept_external,
409                                data_value,
410                                serialized_value,
411                            },
412                        ));
413                        Some(())
414                    });
415                });
416            }
417        });
418    }
419    // JSON-Schema 2020-12 array form `examples: [ <literal>, … ]` on a
420    // Schema Object. Each element is a bare literal (no Example Object
421    // wrapper), lowered under the synthetic index-keyed name
422    // `"_examples[<i>]"`, mirroring the `"_default"` key used for the
423    // singular `example` keyword. Parameter / media-type `examples` is
424    // always the keyed-object form handled above, so a JSON array here
425    // only ever originates from a schema.
426    if let Some(J::Array(items)) = map.get("examples") {
427        ptr.with_token("examples", |ptr| {
428            for (i, raw) in items.iter().enumerate() {
429                ptr.with_index(i, |_ptr| {
430                    let value = Some(ctx.values.intern_json(raw));
431                    out.push((
432                        format!("_examples[{i}]"),
433                        Example {
434                            summary: None,
435                            description: None,
436                            value,
437                            external_value: None,
438                            data_value: None,
439                            serialized_value: None,
440                        },
441                    ));
442                });
443            }
444        });
445    }
446    out
447}
448
449/// Scan `components.pathItems` and emit
450/// `parser/W-COMPONENT-PATH-ITEM-UNUSED` for every entry that wasn't
451/// `$ref`'d from `paths`, `webhooks`, or any callback. Tracking lives
452/// in `Ctx::referenced_component_path_items`, populated by
453/// `with_resolved_object` whenever it resolves a fragment of the form
454/// `/components/pathItems/<name>` against the main spec.
455fn scan_unused_component_path_items(
456    ctx: &mut Ctx,
457    root: &serde_json::Map<String, J>,
458    ptr: &mut Ptr,
459) {
460    let Some(J::Object(components)) = root.get("components") else {
461        return;
462    };
463    let Some(J::Object(path_items)) = components.get("pathItems") else {
464        return;
465    };
466    ptr.with_token("components", |ptr| {
467        ptr.with_token("pathItems", |ptr| {
468            for name in path_items.keys() {
469                if !ctx.referenced_component_path_items.contains(name) {
470                    ptr.with_token(name, |ptr| {
471                        ctx.push_diag(diag::warn(
472                            diag::W_COMPONENT_PATH_ITEM_UNUSED,
473                            format!(
474                                "components.pathItems.`{name}` is declared but never \
475                                 referenced from paths, webhooks, or callbacks. The \
476                                 declaration is silently invisible to generators."
477                            ),
478                            ptr.loc(ctx.file),
479                        ));
480                    });
481                }
482            }
483        });
484    });
485}
486
487/// Scan 3.2 `components.mediaTypes` and emit
488/// `parser/W-COMPONENT-MEDIA-TYPE-UNUSED` for every entry that wasn't
489/// `$ref`'d from any request body / response content. Tracking lives
490/// in `Ctx::referenced_component_media_types`, populated by
491/// `with_resolved_object` whenever it resolves a fragment of the form
492/// `/components/mediaTypes/<name>` against the main spec.
493fn scan_unused_component_media_types(
494    ctx: &mut Ctx,
495    root: &serde_json::Map<String, J>,
496    ptr: &mut Ptr,
497) {
498    let Some(J::Object(components)) = root.get("components") else {
499        return;
500    };
501    let Some(J::Object(media_types)) = components.get("mediaTypes") else {
502        return;
503    };
504    ptr.with_token("components", |ptr| {
505        ptr.with_token("mediaTypes", |ptr| {
506            for name in media_types.keys() {
507                if !ctx.referenced_component_media_types.contains(name) {
508                    ptr.with_token(name, |ptr| {
509                        ctx.push_diag(diag::warn(
510                            diag::W_COMPONENT_MEDIA_TYPE_UNUSED,
511                            format!(
512                                "components.mediaTypes.`{name}` is declared but never \
513                                 referenced. The declaration is silently invisible to \
514                                 generators."
515                            ),
516                            ptr.loc(ctx.file),
517                        ));
518                    });
519                }
520            }
521        });
522    });
523}
524
525/// Walk an `operation.callbacks` map (or a `components.callbacks`
526/// entry resolved via `$ref`). The OAS shape is
527/// `callbacks: { <name>: { <expression>: PathItem } }`; the IR
528/// flattens this into a `Vec<Callback>` where each element pairs a
529/// name with one runtime expression. Each path item is walked through
530/// `parse_path_item` so the inner operations get the same treatment as
531/// top-level paths (operationId dedup, params merging, etc.).
532pub(crate) fn parse_callbacks(
533    ctx: &mut Ctx,
534    value: Option<&J>,
535    ptr: &mut Ptr,
536    seen_op_ids: &mut std::collections::HashSet<String>,
537) -> Vec<Callback> {
538    let Some(J::Object(named)) = value else {
539        return Vec::new();
540    };
541    let mut out = Vec::new();
542    ptr.with_token("callbacks", |ptr| {
543        for (name, entry) in named {
544            ptr.with_token(name, |ptr| {
545                crate::ref_walk::with_resolved_object(ctx, entry, ptr, |ctx, resolved, ptr| {
546                    let Some(emap) = resolved.as_object() else {
547                        ctx.push_diag(diag::err(
548                            diag::E_INVALID_TYPE,
549                            "callback must be an object",
550                            ptr.loc(ctx.file),
551                        ));
552                        return Some(());
553                    };
554                    // Top-level extensions on the callback wrapper.
555                    let extensions = operations::collect_extensions(ctx, emap, ptr);
556                    for (expr, path_item) in emap {
557                        // x-* keys are extensions on the callback
558                        // wrapper, not expression keys.
559                        if expr.starts_with("x-") {
560                            continue;
561                        }
562                        ptr.with_token(expr, |ptr| {
563                            let ops =
564                                operations::parse_path_item(ctx, expr, path_item, ptr, seen_op_ids);
565                            // Operations live in `Ir.operations` (the
566                            // global pool); the callback references
567                            // them by id. Push onto the context so
568                            // they show up in the final IR.
569                            let operation_ids: Vec<String> =
570                                ops.iter().map(|o| o.id.clone()).collect();
571                            ctx.operations.extend(ops);
572                            out.push(Callback {
573                                name: name.clone(),
574                                expression: expr.clone(),
575                                operation_ids,
576                                extensions: extensions.clone(),
577                            });
578                        });
579                    }
580                    Some(())
581                });
582            });
583        }
584    });
585    out
586}
587
588/// Walk a `response.links` map. Returns the parsed list (named,
589/// ordered). `$ref` into `components.links.<Name>` resolves through
590/// the existing ref machinery. Compound runtime-expression values
591/// (object / array literals) drop with the new
592/// `parser/W-LINK-VALUE-DROPPED` warning.
593pub(crate) fn parse_links(ctx: &mut Ctx, value: Option<&J>, ptr: &mut Ptr) -> Vec<(String, Link)> {
594    let Some(J::Object(named)) = value else {
595        return Vec::new();
596    };
597    let mut out = Vec::new();
598    ptr.with_token("links", |ptr| {
599        for (name, entry) in named {
600            ptr.with_token(name, |ptr| {
601                crate::ref_walk::with_resolved_object(ctx, entry, ptr, |ctx, resolved, ptr| {
602                    let Some(lmap) = resolved.as_object() else {
603                        ctx.push_diag(diag::err(
604                            diag::E_INVALID_TYPE,
605                            "link must be an object",
606                            ptr.loc(ctx.file),
607                        ));
608                        return Some(());
609                    };
610                    let operation_ref = lmap
611                        .get("operationRef")
612                        .and_then(J::as_str)
613                        .map(String::from);
614                    let raw_operation_id = lmap
615                        .get("operationId")
616                        .and_then(J::as_str)
617                        .map(String::from);
618                    let operation_id = if operation_ref.is_some() && raw_operation_id.is_some() {
619                        ctx.push_diag(diag::err(
620                            diag::E_LINK_OP_CONFLICT,
621                            format!(
622                                "link `{name}` declares both `operationRef` and `operationId`; \
623                                 OAS §4.7.21 makes them mutually exclusive. Keeping `operationRef`."
624                            ),
625                            ptr.loc(ctx.file),
626                        ));
627                        None
628                    } else {
629                        raw_operation_id
630                    };
631                    let parameters = lmap
632                        .get("parameters")
633                        .and_then(|v| v.as_object())
634                        .map(|m| {
635                            m.iter()
636                                .map(|(k, raw)| (k.clone(), ctx.values.intern_json(raw)))
637                                .collect()
638                        })
639                        .unwrap_or_default();
640                    let request_body = lmap
641                        .get("requestBody")
642                        .map(|raw| ctx.values.intern_json(raw));
643                    // OAS §4.20: Link Object carries only `description`.
644                    let description = crate::schema::description(lmap);
645                    let server = lmap.get("server").and_then(|s| {
646                        s.as_object().and_then(|m| {
647                            let url = m.get("url").and_then(J::as_str)?;
648                            let server_desc =
649                                m.get("description").and_then(J::as_str).map(String::from);
650                            let server_name = m.get("name").and_then(J::as_str).map(String::from);
651                            Some(Server {
652                                url: url.to_string(),
653                                description: server_desc,
654                                name: server_name,
655                                variables: Vec::new(),
656                                extensions: Vec::new(),
657                            })
658                        })
659                    });
660                    let extensions = operations::collect_extensions(ctx, lmap, ptr);
661                    out.push((
662                        name.clone(),
663                        Link {
664                            operation_ref,
665                            operation_id,
666                            parameters,
667                            request_body,
668                            description,
669                            server,
670                            extensions,
671                        },
672                    ));
673                    Some(())
674                });
675            });
676        }
677    });
678    out
679}
680
681/// OAS Schema Object's `xml` block. Returns `None` when the spec
682/// didn't declare an `xml` field. Defaults match OAS: `attribute` and
683/// `wrapped` are `false`. `x-*` extensions on the xml block survive
684/// via the existing `collect_extensions` path.
685pub(crate) fn parse_xml(
686    ctx: &mut Ctx,
687    map: &serde_json::Map<String, J>,
688    ptr: &mut Ptr,
689) -> Option<XmlObject> {
690    let xml = map.get("xml")?;
691    let xml_map = xml.as_object()?;
692    let mut out = None;
693    ptr.with_token("xml", |ptr| {
694        let name = xml_map.get("name").and_then(J::as_str).map(String::from);
695        let namespace = xml_map
696            .get("namespace")
697            .and_then(J::as_str)
698            .map(String::from);
699        let prefix = xml_map.get("prefix").and_then(J::as_str).map(String::from);
700        let attribute = xml_map
701            .get("attribute")
702            .and_then(J::as_bool)
703            .unwrap_or(false);
704        let wrapped = xml_map.get("wrapped").and_then(J::as_bool).unwrap_or(false);
705        let text = xml_map.get("text").and_then(J::as_bool).unwrap_or(false);
706        let ordered = xml_map.get("ordered").and_then(J::as_bool).unwrap_or(false);
707        let extensions = operations::collect_extensions(ctx, xml_map, ptr);
708        out = Some(XmlObject {
709            name,
710            namespace,
711            prefix,
712            attribute,
713            wrapped,
714            text,
715            ordered,
716            extensions,
717        });
718    });
719    out
720}
721
722/// JSON Schema `default` for a schema or property. Interns the value
723/// (scalar or compound) into [`Ctx::values`] and returns its
724/// [`forge_ir::ValueRef`]. Returns `None` only when the field is absent.
725pub(crate) fn parse_default(
726    ctx: &mut Ctx,
727    map: &serde_json::Map<String, J>,
728    _ptr: &mut Ptr,
729    _site: &str,
730) -> Option<forge_ir::ValueRef> {
731    let raw = map.get("default")?;
732    Some(ctx.values.intern_json(raw))
733}
734
735/// Walk an OAS ExternalDocumentation Object. `url` is the only
736/// required field; documents that omit it surface
737/// `parser/W-EXTERNAL-DOCS-NO-URL` and the block is dropped. Used
738/// at root, per-operation, and per-schema sites.
739pub(crate) fn parse_external_docs(
740    ctx: &mut Ctx,
741    value: Option<&J>,
742    ptr: &mut Ptr,
743) -> Option<ExternalDocs> {
744    let map = value?.as_object()?;
745    let mut out = None;
746    ptr.with_token("externalDocs", |ptr| {
747        let Some(url) = map.get("url").and_then(J::as_str) else {
748            ctx.push_diag(diag::warn(
749                diag::W_EXTERNAL_DOCS_NO_URL,
750                "externalDocs is missing required `url`; dropping the block.",
751                ptr.loc(ctx.file),
752            ));
753            return;
754        };
755        let description = map.get("description").and_then(J::as_str).map(String::from);
756        out = Some(ExternalDocs {
757            description,
758            url: url.to_string(),
759        });
760    });
761    out
762}
763
764fn check_version(ctx: &mut Ctx, root: &serde_json::Map<String, J>, ptr: &mut Ptr) -> bool {
765    // OpenAPI 2.0 / Swagger uses a `swagger` field instead of `openapi`.
766    // Surface it specifically so users get a clear message.
767    if root.contains_key("swagger") {
768        ptr.with_token("swagger", |ptr| {
769            ctx.push_diag(diag::err(
770                diag::E_UNSUPPORTED_VERSION,
771                "OpenAPI 2.0 (Swagger) is not supported and is not on the roadmap. \
772                 Convert to OpenAPI 3.0 upstream (e.g. `swagger2openapi`) before invoking forge.",
773                ptr.loc(ctx.file),
774            ));
775        });
776        return false;
777    }
778    let version = root.get("openapi").and_then(J::as_str);
779    match version {
780        Some(v) if ACCEPTED_VERSION_PREFIXES.iter().any(|p| v.starts_with(p)) => {
781            // OAS 3.0 forbade `$ref` siblings; 3.1+ inherits JSON
782            // Schema 2020-12's allowance. The schema walker reads this
783            // bit to pick the right diagnostic.
784            ctx.is_oas_3_0 = v.starts_with("3.0.");
785            true
786        }
787        Some(other) => {
788            let msg = if other.starts_with("2.") || other.starts_with("1.") {
789                format!(
790                    "OpenAPI {other} is not supported and is not on the roadmap. \
791                     Convert to OpenAPI 3.x upstream before invoking forge."
792                )
793            } else {
794                format!("unsupported OpenAPI version `{other}`; expected 3.0.x / 3.1.x / 3.2.x")
795            };
796            ptr.with_token("openapi", |ptr| {
797                ctx.push_diag(diag::err(
798                    diag::E_UNSUPPORTED_VERSION,
799                    msg,
800                    ptr.loc(ctx.file),
801                ));
802            });
803            false
804        }
805        None => {
806            ctx.push_diag(diag::err(
807                diag::E_MISSING_FIELD,
808                "missing required `openapi` field",
809                SpecLocation::new(""),
810            ));
811            false
812        }
813    }
814}
815
816fn parse_info(ctx: &mut Ctx, root: &serde_json::Map<String, J>, ptr: &mut Ptr) {
817    let Some(J::Object(info)) = root.get("info") else {
818        ctx.push_diag(diag::err(
819            diag::E_MISSING_FIELD,
820            "missing required `info` object",
821            ptr.loc(ctx.file),
822        ));
823        return;
824    };
825    ptr.with_token("info", |ptr| {
826        let title = info.get("title").and_then(J::as_str).unwrap_or_else(|| {
827            ctx.push_diag(diag::err(
828                diag::E_MISSING_FIELD,
829                "info is missing `title`",
830                ptr.loc(ctx.file),
831            ));
832            ""
833        });
834        let version = info.get("version").and_then(J::as_str).unwrap_or_else(|| {
835            ctx.push_diag(diag::err(
836                diag::E_MISSING_FIELD,
837                "info is missing `version`",
838                ptr.loc(ctx.file),
839            ));
840            ""
841        });
842        // OAS §4.2: Info Object carries summary (3.1+) and description.
843        let summary = crate::schema::summary(info);
844        let description = crate::schema::description(info);
845        let terms_of_service = info
846            .get("termsOfService")
847            .and_then(J::as_str)
848            .map(String::from);
849        let contact =
850            info.get("contact")
851                .and_then(|v| v.as_object())
852                .and_then(|m| -> Option<Contact> {
853                    let name = m.get("name").and_then(J::as_str).map(String::from);
854                    let url = m.get("url").and_then(J::as_str).map(String::from);
855                    let email = m.get("email").and_then(J::as_str).map(String::from);
856                    if name.is_none() && url.is_none() && email.is_none() {
857                        None
858                    } else {
859                        Some(Contact { name, url, email })
860                    }
861                });
862        let license = info.get("license").and_then(|l| l.as_object());
863        let license_name = license
864            .and_then(|m| m.get("name"))
865            .and_then(J::as_str)
866            .map(String::from);
867        let license_url = license
868            .and_then(|m| m.get("url"))
869            .and_then(J::as_str)
870            .map(String::from);
871        let license_identifier = license
872            .and_then(|m| m.get("identifier"))
873            .and_then(J::as_str)
874            .map(String::from);
875        let extensions = operations::collect_extensions(ctx, info, ptr);
876        ctx.info = Some(ApiInfo {
877            title: title.to_string(),
878            version: version.to_string(),
879            summary,
880            description,
881            terms_of_service,
882            contact,
883            license_name,
884            license_url,
885            license_identifier,
886            extensions,
887        });
888    });
889}
890
891fn parse_servers(ctx: &mut Ctx, root: &serde_json::Map<String, J>, ptr: &mut Ptr) {
892    let servers = parse_servers_array(ctx, root.get("servers"), ptr);
893    ctx.servers.extend(servers);
894}
895
896/// Walk a `servers` array off any host (root, path-item, or operation)
897/// and return the parsed list. Empty / missing input returns `[]`. Used
898/// by `parse_servers` for the root list and by the operations walker
899/// for path-item / per-operation overrides.
900pub(crate) fn parse_servers_array(ctx: &mut Ctx, value: Option<&J>, ptr: &mut Ptr) -> Vec<Server> {
901    let Some(J::Array(items)) = value else {
902        return Vec::new();
903    };
904    let mut out = Vec::new();
905    ptr.with_token("servers", |ptr| {
906        for (i, item) in items.iter().enumerate() {
907            ptr.with_index(i, |ptr| {
908                let Some(map) = item.as_object() else {
909                    ctx.push_diag(diag::err(
910                        diag::E_INVALID_TYPE,
911                        "server must be an object",
912                        ptr.loc(ctx.file),
913                    ));
914                    return;
915                };
916                let Some(url) = map.get("url").and_then(J::as_str) else {
917                    ctx.push_diag(diag::err(
918                        diag::E_MISSING_FIELD,
919                        "server is missing `url`",
920                        ptr.loc(ctx.file),
921                    ));
922                    return;
923                };
924                // OAS §4.5: Server carries `description` only (+ name).
925                let description = crate::schema::description(map);
926                let server_name = map.get("name").and_then(J::as_str).map(String::from);
927                let mut variables: Vec<(String, ServerVariable)> = Vec::new();
928                if let Some(J::Object(vars)) = map.get("variables") {
929                    ptr.with_token("variables", |ptr| {
930                        for (name, v) in vars {
931                            ptr.with_token(name, |ptr| {
932                                let Some(vmap) = v.as_object() else { return };
933                                let Some(default) = vmap.get("default").and_then(J::as_str) else {
934                                    return;
935                                };
936                                let var_extensions = operations::collect_extensions(ctx, vmap, ptr);
937                                // OAS §4.6: ServerVariable carries `description` only.
938                                let var_description = crate::schema::description(vmap);
939                                variables.push((
940                                    name.clone(),
941                                    ServerVariable {
942                                        default: default.to_string(),
943                                        r#enum: vmap.get("enum").and_then(|e| {
944                                            e.as_array().map(|arr| {
945                                                arr.iter()
946                                                    .filter_map(|v| v.as_str().map(String::from))
947                                                    .collect()
948                                            })
949                                        }),
950                                        description: var_description,
951                                        extensions: var_extensions,
952                                    },
953                                ));
954                            });
955                        }
956                    });
957                }
958                let extensions = operations::collect_extensions(ctx, map, ptr);
959                out.push(Server {
960                    url: url.to_string(),
961                    description,
962                    name: server_name,
963                    variables,
964                    extensions,
965                });
966            });
967        }
968    });
969    out
970}
971
972fn register_component_schemas(ctx: &mut Ctx, root: &serde_json::Map<String, J>) {
973    let Some(J::Object(components)) = root.get("components") else {
974        return;
975    };
976    let Some(J::Object(schemas)) = components.get("schemas") else {
977        return;
978    };
979    for name in schemas.keys() {
980        let id = sanitize::ident(name);
981        ctx.refs_mut().register(&id);
982    }
983}
984
985fn walk_component_schemas(ctx: &mut Ctx, root: &serde_json::Map<String, J>, ptr: &mut Ptr) {
986    let Some(J::Object(components)) = root.get("components") else {
987        return;
988    };
989    let Some(J::Object(schemas)) = components.get("schemas") else {
990        return;
991    };
992    // Pre-register every `components.schemas.<X>: { $ref: ext.json#/Y }`
993    // mapping before walking. Without this step, a sibling component
994    // walked earlier whose body $ref's the same external schema would
995    // trigger that schema's walk under an `Inline` hint and pollute the
996    // dedup map with the prefixed id; the later `components.schemas.X`
997    // walk would then dedup-hit and inherit the wrong (prefixed) id.
998    pre_register_external_named_hints(ctx, schemas);
999    let order = order_components_by_allof(schemas);
1000    ptr.with_token("components", |ptr| {
1001        ptr.with_token("schemas", |ptr| {
1002            for name in &order {
1003                let Some(schema) = schemas.get(name) else {
1004                    continue;
1005                };
1006                ptr.with_token(name, |ptr| {
1007                    // Push the in-progress walk into `walking` so a
1008                    // cross-file ref that loops back at this schema
1009                    // recognises the cycle and returns the id without
1010                    // re-walking. The lazy walker uses the same key shape.
1011                    let key = (
1012                        ctx.current_doc.clone(),
1013                        format!("/components/schemas/{name}"),
1014                    );
1015                    ctx.walking.insert(key.clone());
1016                    let _ = parse_schema(ctx, schema, ptr, NameHint::Named(name.clone()));
1017                    ctx.walking.remove(&key);
1018                });
1019            }
1020        });
1021    });
1022}
1023
1024/// Walk `components.schemas` and seed the external-ref dedup map for
1025/// every entry whose body is a single `$ref` to a cross-document schema.
1026/// The seeded id is the component's *Named* id (e.g. `Pet`), so any
1027/// indirect resolution to the same target during sibling-schema walking
1028/// returns `Pet` instead of synthesising a fresh `<docprefix>Pet`.
1029///
1030/// This only seeds *direct* `$ref`s — schemas with `allOf`/`oneOf`/etc.
1031/// that internally use `$ref` get their dedup entry filled by the lazy
1032/// walker as usual.
1033fn pre_register_external_named_hints(ctx: &mut Ctx, schemas: &serde_json::Map<String, J>) {
1034    let current_doc = ctx.current_doc.clone();
1035    for (name, schema) in schemas {
1036        let Some(map) = schema.as_object() else {
1037            continue;
1038        };
1039        let Some(J::String(raw)) = map.get("$ref") else {
1040            continue;
1041        };
1042        let (file_part, fragment) = crate::external::split_ref(raw);
1043        if file_part.is_empty() || crate::external::is_url(file_part) {
1044            continue;
1045        }
1046        let Ok(loaded) = ctx.resolver.load(raw, &current_doc) else {
1047            continue;
1048        };
1049        let canonical = loaded.canonical_path.clone();
1050        crate::schema::ensure_doc_registered(ctx, &canonical, &loaded.root);
1051        ctx.external_ref_to_id
1052            .entry((canonical, fragment.to_string()))
1053            .or_insert_with(|| crate::sanitize::ident(name));
1054    }
1055}
1056
1057/// Order component schemas so any schema that uses `allOf: [{ $ref: X }]`
1058/// is walked *after* `X`. Cycles fall back to alphabetical order at the
1059/// end (the parser still produces best-effort output, and `finalize`
1060/// flags the cycle separately).
1061fn order_components_by_allof(schemas: &serde_json::Map<String, J>) -> Vec<String> {
1062    use std::collections::{BTreeMap, BTreeSet};
1063
1064    let mut deps: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
1065    for (name, schema) in schemas {
1066        let mut targets: BTreeSet<String> = BTreeSet::new();
1067        collect_allof_ref_targets(schema, &mut targets);
1068        targets.retain(|t| schemas.contains_key(t) && t != name);
1069        deps.insert(name.clone(), targets);
1070    }
1071
1072    let mut visited: BTreeSet<String> = BTreeSet::new();
1073    let mut ordered: Vec<String> = Vec::new();
1074    let mut all_names: Vec<String> = schemas.keys().cloned().collect();
1075    all_names.sort();
1076
1077    loop {
1078        let next = all_names.iter().find(|n| {
1079            !visited.contains(*n)
1080                && deps
1081                    .get(*n)
1082                    .map(|d| d.iter().all(|t| visited.contains(t)))
1083                    .unwrap_or(true)
1084        });
1085        match next {
1086            Some(name) => {
1087                let n = name.clone();
1088                visited.insert(n.clone());
1089                ordered.push(n);
1090            }
1091            None => break,
1092        }
1093    }
1094    // Cycle remainder: append alphabetically.
1095    for n in all_names {
1096        if !visited.contains(&n) {
1097            ordered.push(n);
1098        }
1099    }
1100    ordered
1101}
1102
1103fn collect_allof_ref_targets(value: &J, out: &mut std::collections::BTreeSet<String>) {
1104    let Some(map) = value.as_object() else {
1105        return;
1106    };
1107    if let Some(J::Array(parts)) = map.get("allOf") {
1108        for part in parts {
1109            if let Some(rs) = part
1110                .as_object()
1111                .and_then(|m| m.get("$ref"))
1112                .and_then(|r| r.as_str())
1113            {
1114                if let Some(name) = rs.strip_prefix("#/components/schemas/") {
1115                    out.insert(name.to_string());
1116                }
1117            }
1118        }
1119    }
1120}
1121
1122#[cfg(test)]
1123mod tests {
1124    use super::*;
1125
1126    #[test]
1127    fn empty_input_errors() {
1128        let err = parse_str("").unwrap_err();
1129        matches!(err, ParseError::Empty);
1130    }
1131
1132    #[test]
1133    fn invalid_json_errors() {
1134        let err = parse_str("{not json").unwrap_err();
1135        matches!(err, ParseError::InvalidJson(_));
1136    }
1137
1138    #[test]
1139    fn root_array_errors() {
1140        let err = parse_str("[]").unwrap_err();
1141        matches!(err, ParseError::NotObject);
1142    }
1143
1144    #[test]
1145    fn unsupported_version_diagnostic() {
1146        // 4.0 is not on the roadmap; it should fail-fast.
1147        let src = r#"{"openapi":"4.0.0","info":{"title":"x","version":"1"},"paths":{}}"#;
1148        let out = parse_str(src).unwrap();
1149        assert!(out.spec.is_none());
1150        assert_eq!(out.diagnostics.len(), 1);
1151        assert_eq!(out.diagnostics[0].code, diag::E_UNSUPPORTED_VERSION);
1152    }
1153
1154    #[test]
1155    fn minimal_spec_round_trips() {
1156        let src = r#"{
1157            "openapi":"3.0.3",
1158            "info":{"title":"t","version":"1"},
1159            "paths":{}
1160        }"#;
1161        let out = parse_str(src).unwrap();
1162        let ir = out.spec.unwrap();
1163        assert_eq!(ir.info.title, "t");
1164        assert!(ir.operations.is_empty());
1165        assert!(ir.types.is_empty());
1166    }
1167
1168    #[test]
1169    fn info_full_block_populates_every_field() {
1170        let src = r#"{
1171            "openapi":"3.1.0",
1172            "info":{
1173                "title":"t",
1174                "version":"1",
1175                "summary":"s",
1176                "description":"d",
1177                "termsOfService":"https://tos.example",
1178                "contact":{
1179                    "name":"API Team",
1180                    "url":"https://example.com",
1181                    "email":"team@example.com"
1182                },
1183                "license":{
1184                    "name":"Apache 2.0",
1185                    "url":"https://www.apache.org/licenses/LICENSE-2.0",
1186                    "identifier":"Apache-2.0"
1187                }
1188            },
1189            "paths":{}
1190        }"#;
1191        let ir = parse_str(src).unwrap().spec.unwrap();
1192        assert_eq!(ir.info.summary.as_deref(), Some("s"));
1193        assert_eq!(ir.info.description.as_deref(), Some("d"));
1194        assert_eq!(
1195            ir.info.terms_of_service.as_deref(),
1196            Some("https://tos.example")
1197        );
1198        let contact = ir.info.contact.expect("contact populated");
1199        assert_eq!(contact.name.as_deref(), Some("API Team"));
1200        assert_eq!(contact.url.as_deref(), Some("https://example.com"));
1201        assert_eq!(contact.email.as_deref(), Some("team@example.com"));
1202        assert_eq!(ir.info.license_name.as_deref(), Some("Apache 2.0"));
1203        assert_eq!(
1204            ir.info.license_url.as_deref(),
1205            Some("https://www.apache.org/licenses/LICENSE-2.0")
1206        );
1207        assert_eq!(ir.info.license_identifier.as_deref(), Some("Apache-2.0"));
1208    }
1209
1210    #[test]
1211    fn info_contact_object_with_no_known_keys_is_none() {
1212        // OAS allows `x-*` extensions on Contact; with no recognised
1213        // fields, the IR should leave `contact` as None rather than
1214        // emitting an empty Contact record.
1215        let src = r#"{
1216            "openapi":"3.0.0",
1217            "info":{
1218                "title":"t",
1219                "version":"1",
1220                "contact":{ "x-vendor": "acme" }
1221            },
1222            "paths":{}
1223        }"#;
1224        let ir = parse_str(src).unwrap().spec.unwrap();
1225        assert!(ir.info.contact.is_none());
1226    }
1227
1228    #[test]
1229    fn external_docs_populated_at_root_operation_and_schema() {
1230        let src = r#"{
1231            "openapi":"3.0.3",
1232            "info":{"title":"t","version":"1"},
1233            "externalDocs":{"description":"top","url":"https://example.com"},
1234            "paths":{
1235                "/x":{
1236                    "get":{
1237                        "operationId":"getX",
1238                        "externalDocs":{"url":"https://example.com/op"},
1239                        "responses":{"200":{"description":"ok"}}
1240                    }
1241                }
1242            },
1243            "components":{
1244                "schemas":{
1245                    "Foo":{
1246                        "type":"object",
1247                        "externalDocs":{"description":"d","url":"https://example.com/foo"}
1248                    }
1249                }
1250            }
1251        }"#;
1252        let ir = parse_str(src).unwrap().spec.unwrap();
1253        let root = ir.external_docs.expect("root externalDocs");
1254        assert_eq!(root.url, "https://example.com");
1255        assert_eq!(root.description.as_deref(), Some("top"));
1256
1257        let op_docs = ir.operations[0]
1258            .external_docs
1259            .as_ref()
1260            .expect("op externalDocs");
1261        assert_eq!(op_docs.url, "https://example.com/op");
1262        assert!(op_docs.description.is_none());
1263
1264        let foo = ir.types.iter().find(|t| t.id == "Foo").expect("Foo type");
1265        let schema_docs = foo.external_docs.as_ref().expect("schema externalDocs");
1266        assert_eq!(schema_docs.url, "https://example.com/foo");
1267        assert_eq!(schema_docs.description.as_deref(), Some("d"));
1268    }
1269
1270    #[test]
1271    fn external_docs_missing_url_warns_and_drops() {
1272        let src = r#"{
1273            "openapi":"3.0.3",
1274            "info":{"title":"t","version":"1"},
1275            "externalDocs":{"description":"oops"},
1276            "paths":{}
1277        }"#;
1278        let out = parse_str(src).unwrap();
1279        let ir = out.spec.unwrap();
1280        assert!(ir.external_docs.is_none());
1281        assert!(out
1282            .diagnostics
1283            .iter()
1284            .any(|d| d.code == diag::W_EXTERNAL_DOCS_NO_URL));
1285    }
1286
1287    #[test]
1288    fn webhooks_carry_routing_name_and_multiple_methods() {
1289        let src = r#"{
1290            "openapi":"3.1.0",
1291            "info":{"title":"t","version":"1"},
1292            "paths":{},
1293            "webhooks":{
1294                "newPet":{
1295                    "post":{
1296                        "operationId":"newPetCreated",
1297                        "responses":{"200":{"description":"ok"}}
1298                    },
1299                    "delete":{
1300                        "operationId":"newPetDeleted",
1301                        "responses":{"200":{"description":"ok"}}
1302                    }
1303                }
1304            }
1305        }"#;
1306        let ir = parse_str(src).unwrap().spec.unwrap();
1307        assert_eq!(ir.webhooks.len(), 1);
1308        let w = &ir.webhooks[0];
1309        assert_eq!(w.name, "newPet");
1310        // Path item has both `post` and `delete`; both surface as
1311        // operations on the same Webhook.
1312        assert_eq!(w.operations.len(), 2);
1313        assert!(w.operations.iter().any(|o| o.id == "newPetCreated"));
1314        assert!(w.operations.iter().any(|o| o.id == "newPetDeleted"));
1315    }
1316
1317    #[test]
1318    fn webhooks_sort_by_name() {
1319        let src = r#"{
1320            "openapi":"3.1.0",
1321            "info":{"title":"t","version":"1"},
1322            "paths":{},
1323            "webhooks":{
1324                "zebra":{"post":{"operationId":"z","responses":{"200":{"description":"ok"}}}},
1325                "alpha":{"post":{"operationId":"a","responses":{"200":{"description":"ok"}}}}
1326            }
1327        }"#;
1328        let ir = parse_str(src).unwrap().spec.unwrap();
1329        assert_eq!(ir.webhooks[0].name, "alpha");
1330        assert_eq!(ir.webhooks[1].name, "zebra");
1331    }
1332
1333    #[test]
1334    fn response_headers_use_dedicated_header_struct() {
1335        let src = r#"{
1336            "openapi":"3.0.3",
1337            "info":{"title":"t","version":"1"},
1338            "paths":{
1339                "/x":{
1340                    "get":{
1341                        "operationId":"x",
1342                        "responses":{
1343                            "200":{
1344                                "description":"ok",
1345                                "headers":{
1346                                    "X-Trace":{
1347                                        "description":"trace id",
1348                                        "required":true,
1349                                        "schema":{"type":"string"}
1350                                    }
1351                                }
1352                            }
1353                        }
1354                    }
1355                }
1356            }
1357        }"#;
1358        let ir = parse_str(src).unwrap().spec.unwrap();
1359        let resp = &ir.operations[0].responses[0];
1360        assert_eq!(resp.headers.len(), 1);
1361        let (name, header) = &resp.headers[0];
1362        assert_eq!(name, "X-Trace");
1363        assert!(header.required);
1364        assert_eq!(header.description.as_deref(), Some("trace id"));
1365        // No `name`, `style`, `explode`, etc. — Header struct doesn't
1366        // carry those (they don't apply to OAS headers).
1367    }
1368
1369    #[test]
1370    fn openid_connect_security_scheme_round_trips() {
1371        let src = r#"{
1372            "openapi":"3.0.3",
1373            "info":{"title":"t","version":"1"},
1374            "paths":{},
1375            "components":{
1376                "securitySchemes":{
1377                    "oidc":{
1378                        "type":"openIdConnect",
1379                        "openIdConnectUrl":"https://example.com/.well-known/openid-configuration"
1380                    }
1381                }
1382            }
1383        }"#;
1384        let ir = parse_str(src).unwrap().spec.unwrap();
1385        let scheme = ir
1386            .security_schemes
1387            .iter()
1388            .find(|s| s.id == "oidc")
1389            .expect("oidc scheme present");
1390        match &scheme.kind {
1391            forge_ir::SecuritySchemeKind::OpenIdConnect { url } => {
1392                assert_eq!(url, "https://example.com/.well-known/openid-configuration");
1393            }
1394            other => panic!("expected OpenIdConnect, got {other:?}"),
1395        }
1396    }
1397
1398    #[test]
1399    fn openid_connect_missing_url_errors() {
1400        let src = r#"{
1401            "openapi":"3.0.3",
1402            "info":{"title":"t","version":"1"},
1403            "paths":{},
1404            "components":{
1405                "securitySchemes":{
1406                    "oidc":{"type":"openIdConnect"}
1407                }
1408            }
1409        }"#;
1410        let out = parse_str(src).unwrap();
1411        // Scheme is dropped (None return) — security_schemes empty.
1412        assert!(out.spec.unwrap().security_schemes.is_empty());
1413        // Missing-field error fires.
1414        assert!(out
1415            .diagnostics
1416            .iter()
1417            .any(|d| d.code == diag::E_MISSING_FIELD));
1418    }
1419
1420    #[test]
1421    fn ref_siblings_warn_on_oas_3_0() {
1422        let src = r##"{
1423            "openapi":"3.0.3",
1424            "info":{"title":"t","version":"1"},
1425            "paths":{},
1426            "components":{
1427                "schemas":{
1428                    "A":{"type":"string"},
1429                    "B":{"$ref":"#/components/schemas/A","description":"sibling"}
1430                }
1431            }
1432        }"##;
1433        let out = parse_str(src).unwrap();
1434        let diags = out.diagnostics;
1435        let warning = diags
1436            .iter()
1437            .find(|d| d.code == diag::W_REF_SIBLINGS_3_0)
1438            .expect("warning emitted");
1439        assert!(warning.message.contains("description"));
1440    }
1441
1442    #[test]
1443    fn ref_siblings_dont_warn_on_oas_3_1() {
1444        let src = r##"{
1445            "openapi":"3.1.0",
1446            "info":{"title":"t","version":"1"},
1447            "paths":{},
1448            "components":{
1449                "schemas":{
1450                    "A":{"type":"string"},
1451                    "B":{"$ref":"#/components/schemas/A","description":"sibling"}
1452                }
1453            }
1454        }"##;
1455        let out = parse_str(src).unwrap();
1456        assert!(!out
1457            .diagnostics
1458            .iter()
1459            .any(|d| d.code == diag::W_REF_SIBLINGS_3_0));
1460    }
1461
1462    #[test]
1463    fn ref_with_only_x_extensions_does_not_warn() {
1464        // x-* extensions are always allowed alongside $ref (per OAS).
1465        let src = r##"{
1466            "openapi":"3.0.3",
1467            "info":{"title":"t","version":"1"},
1468            "paths":{},
1469            "components":{
1470                "schemas":{
1471                    "A":{"type":"string"},
1472                    "B":{"$ref":"#/components/schemas/A","x-vendor":"acme"}
1473                }
1474            }
1475        }"##;
1476        let out = parse_str(src).unwrap();
1477        assert!(!out
1478            .diagnostics
1479            .iter()
1480            .any(|d| d.code == diag::W_REF_SIBLINGS_3_0));
1481    }
1482
1483    #[test]
1484    fn referenced_component_path_item_lands_in_operations() {
1485        let src = r##"{
1486            "openapi":"3.1.0",
1487            "info":{"title":"t","version":"1"},
1488            "paths":{
1489                "/items":{"$ref":"#/components/pathItems/ItemsPath"}
1490            },
1491            "components":{
1492                "pathItems":{
1493                    "ItemsPath":{
1494                        "get":{"operationId":"list","responses":{"200":{"description":"ok"}}}
1495                    }
1496                }
1497            }
1498        }"##;
1499        let out = parse_str(src).unwrap();
1500        let ir = out.spec.unwrap();
1501        // Operation came in via $ref.
1502        assert!(ir.operations.iter().any(|o| o.id == "list"));
1503        // No unused warning since the only declaration is referenced.
1504        assert!(!out
1505            .diagnostics
1506            .iter()
1507            .any(|d| d.code == diag::W_COMPONENT_PATH_ITEM_UNUSED));
1508    }
1509
1510    #[test]
1511    fn unused_component_path_item_warns() {
1512        let src = r##"{
1513            "openapi":"3.1.0",
1514            "info":{"title":"t","version":"1"},
1515            "paths":{},
1516            "components":{
1517                "pathItems":{
1518                    "Orphan":{
1519                        "get":{"operationId":"orphan","responses":{"200":{"description":"ok"}}}
1520                    }
1521                }
1522            }
1523        }"##;
1524        let out = parse_str(src).unwrap();
1525        let ir = out.spec.unwrap();
1526        // The orphan operation never lands in IR — only the warning.
1527        assert!(ir.operations.is_empty());
1528        assert!(out
1529            .diagnostics
1530            .iter()
1531            .any(|d| d.code == diag::W_COMPONENT_PATH_ITEM_UNUSED));
1532    }
1533
1534    #[test]
1535    fn webhook_ref_into_component_path_item_counts_as_use() {
1536        let src = r##"{
1537            "openapi":"3.1.0",
1538            "info":{"title":"t","version":"1"},
1539            "paths":{},
1540            "webhooks":{
1541                "ev":{"$ref":"#/components/pathItems/EventPath"}
1542            },
1543            "components":{
1544                "pathItems":{
1545                    "EventPath":{
1546                        "post":{"operationId":"ev","responses":{"200":{"description":"ok"}}}
1547                    }
1548                }
1549            }
1550        }"##;
1551        let out = parse_str(src).unwrap();
1552        // Webhook reference counts — no unused warning.
1553        assert!(!out
1554            .diagnostics
1555            .iter()
1556            .any(|d| d.code == diag::W_COMPONENT_PATH_ITEM_UNUSED));
1557    }
1558
1559    #[test]
1560    fn callbacks_walk_inline_and_via_ref() {
1561        let src = r##"{
1562            "openapi":"3.0.3",
1563            "info":{"title":"t","version":"1"},
1564            "paths":{
1565                "/sub":{
1566                    "post":{
1567                        "operationId":"sub",
1568                        "responses":{"200":{"description":"ok"}},
1569                        "callbacks":{
1570                            "evt":{
1571                                "{$request.body#/url}":{
1572                                    "post":{
1573                                        "operationId":"evtCb",
1574                                        "responses":{"200":{"description":"ok"}}
1575                                    }
1576                                }
1577                            },
1578                            "shared":{"$ref":"#/components/callbacks/Shared"}
1579                        }
1580                    }
1581                }
1582            },
1583            "components":{
1584                "callbacks":{
1585                    "Shared":{
1586                        "{$request.body#/sharedUrl}":{
1587                            "post":{
1588                                "operationId":"sharedCb",
1589                                "responses":{"200":{"description":"ok"}}
1590                            }
1591                        }
1592                    }
1593                }
1594            }
1595        }"##;
1596        let ir = parse_str(src).unwrap().spec.unwrap();
1597        let sub = ir.operations.iter().find(|o| o.id == "sub").unwrap();
1598        assert_eq!(sub.callbacks.len(), 2);
1599        let evt = sub.callbacks.iter().find(|c| c.name == "evt").unwrap();
1600        assert_eq!(evt.expression, "{$request.body#/url}");
1601        assert_eq!(evt.operation_ids, vec!["evtCb".to_string()]);
1602        let shared = sub.callbacks.iter().find(|c| c.name == "shared").unwrap();
1603        assert_eq!(shared.expression, "{$request.body#/sharedUrl}");
1604        assert_eq!(shared.operation_ids, vec!["sharedCb".to_string()]);
1605        // Callback operations live in Ir.operations alongside the
1606        // top-level operation.
1607        assert!(ir.operations.iter().any(|o| o.id == "evtCb"));
1608        assert!(ir.operations.iter().any(|o| o.id == "sharedCb"));
1609    }
1610
1611    #[test]
1612    fn callback_op_id_collides_with_top_level_emits_dup_error() {
1613        // Callback operationIds share the global namespace with
1614        // top-level operations per OAS.
1615        let src = r##"{
1616            "openapi":"3.0.3",
1617            "info":{"title":"t","version":"1"},
1618            "paths":{
1619                "/a":{
1620                    "post":{
1621                        "operationId":"foo",
1622                        "responses":{"200":{"description":"ok"}},
1623                        "callbacks":{
1624                            "x":{
1625                                "{$req}":{
1626                                    "post":{
1627                                        "operationId":"foo",
1628                                        "responses":{"200":{"description":"ok"}}
1629                                    }
1630                                }
1631                            }
1632                        }
1633                    }
1634                }
1635            }
1636        }"##;
1637        let out = parse_str(src).unwrap();
1638        assert!(out
1639            .diagnostics
1640            .iter()
1641            .any(|d| d.code == diag::E_DUPLICATE_OPERATION_ID));
1642    }
1643
1644    #[test]
1645    fn response_links_populate_inline_and_via_ref() {
1646        let src = r##"{
1647            "openapi":"3.0.3",
1648            "info":{"title":"t","version":"1"},
1649            "paths":{
1650                "/u":{
1651                    "get":{
1652                        "operationId":"getU",
1653                        "responses":{
1654                            "200":{
1655                                "description":"ok",
1656                                "links":{
1657                                    "addr":{
1658                                        "operationId":"getA",
1659                                        "parameters":{"id":"$response.body#/id"},
1660                                        "description":"docs"
1661                                    },
1662                                    "shared":{"$ref":"#/components/links/Shared"}
1663                                }
1664                            }
1665                        }
1666                    }
1667                },
1668                "/a":{
1669                    "get":{
1670                        "operationId":"getA",
1671                        "responses":{"200":{"description":"ok"}}
1672                    }
1673                }
1674            },
1675            "components":{
1676                "links":{
1677                    "Shared":{"operationId":"getA","description":"shared"}
1678                }
1679            }
1680        }"##;
1681        let ir = parse_str(src).unwrap().spec.unwrap();
1682        let op = ir.operations.iter().find(|o| o.id == "getU").unwrap();
1683        let links = &op.responses[0].links;
1684        assert_eq!(links.len(), 2);
1685        let addr = &links.iter().find(|(k, _)| k == "addr").unwrap().1;
1686        assert_eq!(addr.operation_id.as_deref(), Some("getA"));
1687        assert_eq!(addr.parameters.len(), 1);
1688        assert_eq!(addr.parameters[0].0, "id");
1689        assert_eq!(addr.description.as_deref(), Some("docs"));
1690        let shared = &links.iter().find(|(k, _)| k == "shared").unwrap().1;
1691        assert_eq!(shared.description.as_deref(), Some("shared"));
1692        assert_eq!(shared.operation_id.as_deref(), Some("getA"));
1693    }
1694
1695    #[test]
1696    fn link_with_both_operation_ref_and_id_keeps_ref() {
1697        let src = r##"{
1698            "openapi":"3.0.3",
1699            "info":{"title":"t","version":"1"},
1700            "paths":{
1701                "/u":{
1702                    "get":{
1703                        "operationId":"getU",
1704                        "responses":{
1705                            "200":{
1706                                "description":"ok",
1707                                "links":{
1708                                    "x":{
1709                                        "operationRef":"#/paths/~1a/get",
1710                                        "operationId":"getA"
1711                                    }
1712                                }
1713                            }
1714                        }
1715                    }
1716                }
1717            }
1718        }"##;
1719        let out = parse_str(src).unwrap();
1720        let ir = out.spec.unwrap();
1721        let link = &ir.operations[0].responses[0].links[0].1;
1722        assert!(link.operation_ref.is_some());
1723        assert!(link.operation_id.is_none());
1724        assert!(out
1725            .diagnostics
1726            .iter()
1727            .any(|d| d.code == diag::E_LINK_OP_CONFLICT));
1728    }
1729
1730    #[test]
1731    fn link_compound_parameter_survives_via_value_pool() {
1732        let src = r##"{
1733            "openapi":"3.0.3",
1734            "info":{"title":"t","version":"1"},
1735            "paths":{
1736                "/u":{
1737                    "get":{
1738                        "operationId":"getU",
1739                        "responses":{
1740                            "200":{
1741                                "description":"ok",
1742                                "links":{
1743                                    "x":{
1744                                        "operationId":"foo",
1745                                        "parameters":{"complex":["a","b"]}
1746                                    }
1747                                }
1748                            }
1749                        }
1750                    }
1751                }
1752            }
1753        }"##;
1754        let out = parse_str(src).unwrap();
1755        let ir = out.spec.unwrap();
1756        let link = &ir.operations[0].responses[0].links[0].1;
1757        // Compound parameters survive: the entry resolves to a List in the
1758        // value pool. No W-*-DROPPED warnings any more.
1759        assert_eq!(link.parameters.len(), 1);
1760        let r = link.parameters[0].1 as usize;
1761        assert!(matches!(ir.values[r], forge_ir::Value::List { .. }));
1762    }
1763
1764    #[test]
1765    fn xml_block_populates_with_all_fields() {
1766        let src = r#"{
1767            "openapi":"3.0.3",
1768            "info":{"title":"t","version":"1"},
1769            "paths":{},
1770            "components":{
1771                "schemas":{
1772                    "Pet":{
1773                        "type":"object",
1774                        "xml":{
1775                            "name":"Pet",
1776                            "namespace":"http://example.com/pet",
1777                            "prefix":"pt",
1778                            "attribute":false,
1779                            "wrapped":true,
1780                            "x-vendor":"acme"
1781                        }
1782                    }
1783                }
1784            }
1785        }"#;
1786        let ir = parse_str(src).unwrap().spec.unwrap();
1787        let pet = ir.types.iter().find(|t| t.id == "Pet").unwrap();
1788        let xml = pet.xml.as_ref().expect("xml populated");
1789        assert_eq!(xml.name.as_deref(), Some("Pet"));
1790        assert_eq!(xml.namespace.as_deref(), Some("http://example.com/pet"));
1791        assert_eq!(xml.prefix.as_deref(), Some("pt"));
1792        assert!(!xml.attribute);
1793        assert!(xml.wrapped);
1794        assert_eq!(xml.extensions.len(), 1);
1795        assert_eq!(xml.extensions[0].0, "x-vendor");
1796    }
1797
1798    #[test]
1799    fn xml_attribute_defaults_to_false() {
1800        let src = r#"{
1801            "openapi":"3.0.3",
1802            "info":{"title":"t","version":"1"},
1803            "paths":{},
1804            "components":{
1805                "schemas":{
1806                    "Foo":{"type":"string","xml":{"name":"Foo"}}
1807                }
1808            }
1809        }"#;
1810        let ir = parse_str(src).unwrap().spec.unwrap();
1811        let foo = ir.types.iter().find(|t| t.id == "Foo").unwrap();
1812        let xml = foo.xml.as_ref().unwrap();
1813        assert!(!xml.attribute);
1814        assert!(!xml.wrapped);
1815    }
1816
1817    #[test]
1818    fn xml_absent_leaves_field_none() {
1819        let src = r#"{
1820            "openapi":"3.0.3",
1821            "info":{"title":"t","version":"1"},
1822            "paths":{},
1823            "components":{"schemas":{"Foo":{"type":"string"}}}
1824        }"#;
1825        let ir = parse_str(src).unwrap().spec.unwrap();
1826        let foo = ir.types.iter().find(|t| t.id == "Foo").unwrap();
1827        assert!(foo.xml.is_none());
1828    }
1829
1830    #[test]
1831    fn examples_populate_at_parameter_and_schema_sites() {
1832        let src = r##"{
1833            "openapi":"3.0.3",
1834            "info":{"title":"t","version":"1"},
1835            "paths":{
1836                "/x/{id}":{
1837                    "get":{
1838                        "operationId":"getX",
1839                        "parameters":[{
1840                            "name":"id","in":"path","required":true,
1841                            "schema":{"type":"string"},
1842                            "examples":{
1843                                "short":{"summary":"S","value":"42"},
1844                                "uuid":{"$ref":"#/components/examples/UuidExample"}
1845                            }
1846                        }],
1847                        "responses":{"204":{"description":"ok"}}
1848                    }
1849                }
1850            },
1851            "components":{
1852                "examples":{
1853                    "UuidExample":{"summary":"UUID","value":"abc"}
1854                },
1855                "schemas":{
1856                    "Foo":{"type":"string","example":"hello"}
1857                }
1858            }
1859        }"##;
1860        let ir = parse_str(src).unwrap().spec.unwrap();
1861        // Parameter examples (inline + ref'd).
1862        let param = &ir.operations[0].path_params[0];
1863        assert_eq!(param.examples.len(), 2);
1864        assert_eq!(param.examples[0].0, "short");
1865        let r0 = param.examples[0].1.value.unwrap() as usize;
1866        assert_eq!(ir.values[r0], forge_ir::Value::s("42"));
1867        assert_eq!(param.examples[1].0, "uuid");
1868        let r1 = param.examples[1].1.value.unwrap() as usize;
1869        assert_eq!(ir.values[r1], forge_ir::Value::s("abc"));
1870        // Schema-level 3.0 single-example lands under "_default".
1871        let foo = ir.types.iter().find(|t| t.id == "Foo").unwrap();
1872        assert_eq!(foo.examples.len(), 1);
1873        assert_eq!(foo.examples[0].0, "_default");
1874        let r2 = foo.examples[0].1.value.unwrap() as usize;
1875        assert_eq!(ir.values[r2], forge_ir::Value::s("hello"));
1876    }
1877
1878    #[test]
1879    fn compound_example_survives_via_value_pool() {
1880        let src = r#"{
1881            "openapi":"3.0.3",
1882            "info":{"title":"t","version":"1"},
1883            "paths":{},
1884            "components":{
1885                "schemas":{
1886                    "Foo":{"type":"object","example":{"k":"v"}}
1887                }
1888            }
1889        }"#;
1890        let out = parse_str(src).unwrap();
1891        let ir = out.spec.unwrap();
1892        let foo = ir.types.iter().find(|t| t.id == "Foo").cloned().unwrap();
1893        // Compound example survives in the pool.
1894        assert_eq!(foo.examples.len(), 1);
1895        assert_eq!(foo.examples[0].0, "_default");
1896        let r = foo.examples[0].1.value.unwrap() as usize;
1897        let resolved = &ir.values[r];
1898        let forge_ir::Value::Object { fields } = resolved else {
1899            panic!("expected object example, got {resolved:?}");
1900        };
1901        assert_eq!(fields.len(), 1);
1902        assert_eq!(fields[0].0, "k");
1903        assert_eq!(ir.values[fields[0].1 as usize], forge_ir::Value::s("v"));
1904    }
1905
1906    #[test]
1907    fn example_with_value_and_external_value_keeps_value() {
1908        let src = r##"{
1909            "openapi":"3.0.3",
1910            "info":{"title":"t","version":"1"},
1911            "paths":{},
1912            "components":{
1913                "examples":{
1914                    "Conflict":{
1915                        "value":"inline",
1916                        "externalValue":"https://example.com/blob"
1917                    }
1918                },
1919                "schemas":{
1920                    "Foo":{
1921                        "type":"string",
1922                        "examples":{"a":{"$ref":"#/components/examples/Conflict"}}
1923                    }
1924                }
1925            }
1926        }"##;
1927        let out = parse_str(src).unwrap();
1928        let ir = out.spec.as_ref().unwrap();
1929        let foo = ir.types.iter().find(|t| t.id == "Foo").unwrap();
1930        let ex = &foo.examples[0].1;
1931        let r = ex.value.unwrap() as usize;
1932        assert_eq!(ir.values[r], forge_ir::Value::s("inline"));
1933        assert!(ex.external_value.is_none());
1934        assert!(out
1935            .diagnostics
1936            .iter()
1937            .any(|d| d.code == diag::E_EXAMPLE_VALUE_CONFLICT));
1938    }
1939
1940    #[test]
1941    fn json_schema_examples_array_lowers_at_schema_and_property_sites() {
1942        // JSON-Schema 2020-12 array-form `examples` on a Schema Object
1943        // (and on an object property's schema) must reach the IR under
1944        // index-keyed synthetic names, alongside the singular `example`.
1945        let src = r##"{
1946            "openapi":"3.1.0",
1947            "info":{"title":"t","version":"1"},
1948            "paths":{},
1949            "components":{
1950                "schemas":{
1951                    "ArrayBadSchema":{
1952                        "type":"integer","format":"int32",
1953                        "examples":["not-an-int", 7]
1954                    },
1955                    "ObjArrayBadProp":{
1956                        "type":"object",
1957                        "properties":{
1958                            "n":{"type":"integer","format":"int32","examples":["also-not-an-int"]}
1959                        },
1960                        "required":["n"],
1961                        "additionalProperties":false
1962                    }
1963                }
1964            }
1965        }"##;
1966        let ir = parse_str(src).unwrap().spec.unwrap();
1967        // Schema-level array form: one entry per element, index-keyed.
1968        let arr = ir.types.iter().find(|t| t.id == "ArrayBadSchema").unwrap();
1969        assert_eq!(arr.examples.len(), 2);
1970        assert_eq!(arr.examples[0].0, "_examples[0]");
1971        let r0 = arr.examples[0].1.value.unwrap() as usize;
1972        assert_eq!(ir.values[r0], forge_ir::Value::s("not-an-int"));
1973        assert_eq!(arr.examples[1].0, "_examples[1]");
1974        let r1 = arr.examples[1].1.value.unwrap() as usize;
1975        assert_eq!(ir.values[r1], forge_ir::Value::Int { value: 7 });
1976        // Property-level array form reaches `Property.examples`.
1977        let obj_ty = ir.types.iter().find(|t| t.id == "ObjArrayBadProp").unwrap();
1978        let forge_ir::TypeDef::Object(obj) = &obj_ty.definition else {
1979            panic!("expected object");
1980        };
1981        let n = obj.properties.iter().find(|p| p.name == "n").unwrap();
1982        assert_eq!(n.examples.len(), 1);
1983        assert_eq!(n.examples[0].0, "_examples[0]");
1984        let rp = n.examples[0].1.value.unwrap() as usize;
1985        assert_eq!(ir.values[rp], forge_ir::Value::s("also-not-an-int"));
1986    }
1987
1988    #[test]
1989    fn singular_example_and_examples_array_coexist() {
1990        // A schema may carry both keywords; they land under distinct
1991        // synthetic keys without colliding.
1992        let src = r##"{
1993            "openapi":"3.1.0",
1994            "info":{"title":"t","version":"1"},
1995            "paths":{},
1996            "components":{
1997                "schemas":{
1998                    "Both":{"type":"string","example":"a","examples":["b","c"]}
1999                }
2000            }
2001        }"##;
2002        let ir = parse_str(src).unwrap().spec.unwrap();
2003        let both = ir.types.iter().find(|t| t.id == "Both").unwrap();
2004        let keys: Vec<&str> = both.examples.iter().map(|(k, _)| k.as_str()).collect();
2005        assert_eq!(keys, ["_default", "_examples[0]", "_examples[1]"]);
2006    }
2007
2008    #[test]
2009    fn item_schema_populates_item_schema_and_type() {
2010        // OAS 3.2: itemSchema-only entry. type is set to the item-type
2011        // ref so non-streaming generators see a usable type;
2012        // item_schema is populated for streaming-aware generators.
2013        let src = r##"{
2014            "openapi":"3.2.0",
2015            "info":{"title":"t","version":"1"},
2016            "paths":{
2017                "/events":{
2018                    "get":{
2019                        "operationId":"stream",
2020                        "responses":{
2021                            "200":{
2022                                "description":"jsonl",
2023                                "content":{
2024                                    "application/jsonl":{
2025                                        "itemSchema":{"$ref":"#/components/schemas/Event"}
2026                                    }
2027                                }
2028                            }
2029                        }
2030                    }
2031                }
2032            },
2033            "components":{
2034                "schemas":{
2035                    "Event":{"type":"object","properties":{"id":{"type":"string"}}}
2036                }
2037            }
2038        }"##;
2039        let ir = parse_str(src).unwrap().spec.unwrap();
2040        let op = &ir.operations[0];
2041        let content = &op.responses[0].content[0];
2042        assert_eq!(content.media_type, "application/jsonl");
2043        assert_eq!(content.r#type, "Event");
2044        assert_eq!(content.item_schema.as_deref(), Some("Event"));
2045    }
2046
2047    #[test]
2048    fn schema_only_leaves_item_schema_none() {
2049        // Plain schema should not populate item_schema.
2050        let src = r#"{
2051            "openapi":"3.0.3",
2052            "info":{"title":"t","version":"1"},
2053            "paths":{
2054                "/x":{
2055                    "get":{
2056                        "operationId":"x",
2057                        "responses":{
2058                            "200":{"description":"ok","content":{
2059                                "application/json":{"schema":{"type":"string"}}
2060                            }}
2061                        }
2062                    }
2063                }
2064            }
2065        }"#;
2066        let ir = parse_str(src).unwrap().spec.unwrap();
2067        let content = &ir.operations[0].responses[0].content[0];
2068        assert!(content.item_schema.is_none());
2069    }
2070
2071    #[test]
2072    fn schema_and_item_schema_together_emit_conflict_error() {
2073        let src = r#"{
2074            "openapi":"3.2.0",
2075            "info":{"title":"t","version":"1"},
2076            "paths":{
2077                "/x":{
2078                    "get":{
2079                        "operationId":"x",
2080                        "responses":{
2081                            "200":{"description":"ok","content":{
2082                                "application/json":{
2083                                    "schema":{"type":"string"},
2084                                    "itemSchema":{"type":"string"}
2085                                }
2086                            }}
2087                        }
2088                    }
2089                }
2090            }
2091        }"#;
2092        let out = parse_str(src).unwrap();
2093        assert!(out
2094            .diagnostics
2095            .iter()
2096            .any(|d| d.code == diag::E_CONTENT_SCHEMA_CONFLICT));
2097    }
2098
2099    #[test]
2100    fn additional_operations_walk_into_other_method() {
2101        let src = r#"{
2102            "openapi":"3.2.0",
2103            "info":{"title":"t","version":"1"},
2104            "paths":{
2105                "/items":{
2106                    "get":{"operationId":"listItems","responses":{"204":{"description":"ok"}}},
2107                    "additionalOperations":{
2108                        "QUERY":{
2109                            "operationId":"queryItems",
2110                            "responses":{"204":{"description":"ok"}}
2111                        }
2112                    }
2113                }
2114            }
2115        }"#;
2116        let ir = parse_str(src).unwrap().spec.unwrap();
2117        let query_op = ir
2118            .operations
2119            .iter()
2120            .find(|o| o.id == "queryItems")
2121            .expect("queryItems present");
2122        assert_eq!(query_op.method, forge_ir::HttpMethod::Other("QUERY".into()));
2123        // Standard method untouched.
2124        let list_op = ir.operations.iter().find(|o| o.id == "listItems").unwrap();
2125        assert_eq!(list_op.method, forge_ir::HttpMethod::Get);
2126    }
2127
2128    #[test]
2129    fn additional_operations_method_normalised_to_uppercase() {
2130        // RFC 7230 §3.1.1 method names are case-sensitive but
2131        // conventionally uppercase. The parser uppercases so generators
2132        // emitting `Method::from_bytes(b"...")` see a single canonical
2133        // form.
2134        let src = r#"{
2135            "openapi":"3.2.0",
2136            "info":{"title":"t","version":"1"},
2137            "paths":{
2138                "/x":{
2139                    "additionalOperations":{
2140                        "Query":{
2141                            "operationId":"qx",
2142                            "responses":{"204":{"description":"ok"}}
2143                        }
2144                    }
2145                }
2146            }
2147        }"#;
2148        let ir = parse_str(src).unwrap().spec.unwrap();
2149        assert_eq!(
2150            ir.operations[0].method,
2151            forge_ir::HttpMethod::Other("QUERY".into())
2152        );
2153    }
2154
2155    #[test]
2156    fn http_method_as_str_returns_wire_form() {
2157        use forge_ir::HttpMethod as M;
2158        assert_eq!(M::Get.as_str(), "GET");
2159        assert_eq!(M::Patch.as_str(), "PATCH");
2160        assert_eq!(M::Other("QUERY".into()).as_str(), "QUERY");
2161    }
2162
2163    #[test]
2164    fn schema_defaults_populate_named_type_and_property() {
2165        let src = r#"{
2166            "openapi":"3.0.3",
2167            "info":{"title":"t","version":"1"},
2168            "paths":{},
2169            "components":{
2170                "schemas":{
2171                    "PageSize":{"type":"integer","default":25},
2172                    "Pet":{
2173                        "type":"object",
2174                        "properties":{
2175                            "name":{"type":"string","default":"Rex"}
2176                        }
2177                    }
2178                }
2179            }
2180        }"#;
2181        let ir = parse_str(src).unwrap().spec.unwrap();
2182        let page_size = ir.types.iter().find(|t| t.id == "PageSize").unwrap();
2183        let r = page_size.default.unwrap() as usize;
2184        assert_eq!(ir.values[r], forge_ir::Value::Int { value: 25 });
2185        let pet = ir.types.iter().find(|t| t.id == "Pet").unwrap();
2186        let forge_ir::TypeDef::Object(pet_obj) = &pet.definition else {
2187            panic!("Pet should be object");
2188        };
2189        let name_prop = pet_obj
2190            .properties
2191            .iter()
2192            .find(|p| p.name == "name")
2193            .unwrap();
2194        let r = name_prop.default.unwrap() as usize;
2195        assert_eq!(ir.values[r], forge_ir::Value::s("Rex"));
2196    }
2197
2198    #[test]
2199    fn schema_default_null_round_trips() {
2200        // JSON `null` is a scalar; round-trips as Value::Null in the pool.
2201        let src = r#"{
2202            "openapi":"3.0.3",
2203            "info":{"title":"t","version":"1"},
2204            "paths":{},
2205            "components":{
2206                "schemas":{
2207                    "Empty":{"type":"string","default":null}
2208                }
2209            }
2210        }"#;
2211        let ir = parse_str(src).unwrap().spec.unwrap();
2212        let empty = ir.types.iter().find(|t| t.id == "Empty").unwrap();
2213        let r = empty.default.unwrap() as usize;
2214        assert_eq!(ir.values[r], forge_ir::Value::Null);
2215    }
2216
2217    #[test]
2218    fn empty_and_freeform_schemas_lower_to_any_not_object() {
2219        // A schema with no `type` — `{}` or an annotation-only schema — is the
2220        // JSON Schema "any" schema (equivalent to boolean `true`, JSON Schema
2221        // 2020-12 §4.3.2): it validates ANY instance, not just objects. It must
2222        // lower to `TypeDef::Any`, NOT to `{"type":"object"}` (an `Object` with
2223        // permissive additionalProperties), which would reject non-object
2224        // instances like strings or numbers.
2225        let src = r#"{
2226            "openapi":"3.1.0",
2227            "info":{"title":"t","version":"1"},
2228            "paths":{},
2229            "components":{
2230                "schemas":{
2231                    "AnyVal":{},
2232                    "OpaqueDoc":{"description":"any JSON value"},
2233                    "RealObject":{"type":"object"}
2234                }
2235            }
2236        }"#;
2237        let ir = parse_str(src).unwrap().spec.unwrap();
2238        let def = |id: &str| {
2239            &ir.types
2240                .iter()
2241                .find(|t| t.id == id)
2242                .unwrap_or_else(|| panic!("missing type {id}"))
2243                .definition
2244        };
2245        assert!(
2246            matches!(def("AnyVal"), forge_ir::TypeDef::Any),
2247            "`{{}}` must lower to Any, got {:?}",
2248            def("AnyVal")
2249        );
2250        assert!(
2251            matches!(def("OpaqueDoc"), forge_ir::TypeDef::Any),
2252            "an annotation-only schema must lower to Any, got {:?}",
2253            def("OpaqueDoc")
2254        );
2255        // A schema that *does* declare `type: object` stays an object.
2256        assert!(
2257            matches!(def("RealObject"), forge_ir::TypeDef::Object(_)),
2258            "`{{\"type\":\"object\"}}` must stay Object, got {:?}",
2259            def("RealObject")
2260        );
2261    }
2262
2263    #[test]
2264    fn schema_compound_default_survives_via_value_pool() {
2265        let src = r#"{
2266            "openapi":"3.0.3",
2267            "info":{"title":"t","version":"1"},
2268            "paths":{},
2269            "components":{
2270                "schemas":{
2271                    "Cfg":{"type":"object","default":{"k":"v"}}
2272                }
2273            }
2274        }"#;
2275        let out = parse_str(src).unwrap();
2276        let ir = out.spec.unwrap();
2277        let cfg = ir.types.iter().find(|t| t.id == "Cfg").unwrap();
2278        let r = cfg.default.unwrap() as usize;
2279        let forge_ir::Value::Object { fields } = &ir.values[r] else {
2280            panic!("expected object default");
2281        };
2282        assert_eq!(fields.len(), 1);
2283        assert_eq!(fields[0].0, "k");
2284        assert_eq!(ir.values[fields[0].1 as usize], forge_ir::Value::s("v"));
2285    }
2286
2287    #[test]
2288    fn tags_walk_into_structured_records() {
2289        let src = r#"{
2290            "openapi":"3.2.0",
2291            "info":{"title":"t","version":"1"},
2292            "tags":[
2293                {
2294                    "name":"pets",
2295                    "summary":"S",
2296                    "description":"D",
2297                    "kind":"audience",
2298                    "externalDocs":{"url":"https://example.com"}
2299                },
2300                {"name":"cats","parent":"pets"}
2301            ],
2302            "paths":{}
2303        }"#;
2304        let ir = parse_str(src).unwrap().spec.unwrap();
2305        // Sorted by name for determinism — cats before pets.
2306        assert_eq!(ir.tags[0].name, "cats");
2307        assert_eq!(ir.tags[0].parent.as_deref(), Some("pets"));
2308        assert_eq!(ir.tags[1].name, "pets");
2309        assert_eq!(ir.tags[1].summary.as_deref(), Some("S"));
2310        assert_eq!(ir.tags[1].description.as_deref(), Some("D"));
2311        assert_eq!(ir.tags[1].kind.as_deref(), Some("audience"));
2312        assert_eq!(
2313            ir.tags[1].external_docs.as_ref().unwrap().url,
2314            "https://example.com"
2315        );
2316    }
2317
2318    #[test]
2319    fn tag_parent_dangling_drops_ref_keeps_tag() {
2320        let src = r#"{
2321            "openapi":"3.2.0",
2322            "info":{"title":"t","version":"1"},
2323            "tags":[
2324                {"name":"cats","parent":"no-such-tag"}
2325            ],
2326            "paths":{}
2327        }"#;
2328        let out = parse_str(src).unwrap();
2329        let ir = out.spec.unwrap();
2330        assert_eq!(ir.tags.len(), 1);
2331        assert_eq!(ir.tags[0].name, "cats");
2332        // Parent reference dropped; the tag itself survives.
2333        assert!(ir.tags[0].parent.is_none());
2334        assert!(out
2335            .diagnostics
2336            .iter()
2337            .any(|d| d.code == diag::W_TAG_PARENT_DANGLING));
2338    }
2339
2340    #[test]
2341    fn tags_extensions_round_trip() {
2342        let src = r#"{
2343            "openapi":"3.0.3",
2344            "info":{"title":"t","version":"1"},
2345            "tags":[
2346                {"name":"pets","x-priority":5}
2347            ],
2348            "paths":{}
2349        }"#;
2350        let ir = parse_str(src).unwrap().spec.unwrap();
2351        let ext = &ir.tags[0].extensions;
2352        assert_eq!(ext.len(), 1);
2353        assert_eq!(ext[0].0, "x-priority");
2354    }
2355
2356    #[test]
2357    fn operation_servers_resolution_picks_most_specific() {
2358        let src = r#"{
2359            "openapi":"3.0.3",
2360            "info":{"title":"t","version":"1"},
2361            "servers":[{"url":"https://root"}],
2362            "paths":{
2363                "/a":{
2364                    "get":{"operationId":"opA","responses":{"204":{"description":"ok"}}}
2365                },
2366                "/b":{
2367                    "servers":[{"url":"https://path-b"}],
2368                    "get":{"operationId":"opB","responses":{"204":{"description":"ok"}}},
2369                    "post":{
2370                        "operationId":"opC",
2371                        "servers":[{"url":"https://op-c"}],
2372                        "responses":{"204":{"description":"ok"}}
2373                    }
2374                }
2375            }
2376        }"#;
2377        let ir = parse_str(src).unwrap().spec.unwrap();
2378        let by_id = |id: &str| {
2379            ir.operations
2380                .iter()
2381                .find(|o| o.id == id)
2382                .unwrap_or_else(|| panic!("operation {id} not found"))
2383        };
2384        assert_eq!(by_id("opA").servers[0].url, "https://root");
2385        assert_eq!(by_id("opB").servers[0].url, "https://path-b");
2386        assert_eq!(by_id("opC").servers[0].url, "https://op-c");
2387    }
2388
2389    #[test]
2390    fn operation_servers_empty_when_no_root_or_overrides() {
2391        // No `servers` anywhere — operation list stays empty rather than
2392        // synthesising a default URL.
2393        let src = r#"{
2394            "openapi":"3.0.3",
2395            "info":{"title":"t","version":"1"},
2396            "paths":{
2397                "/x":{"get":{"operationId":"x","responses":{"204":{"description":"ok"}}}}
2398            }
2399        }"#;
2400        let ir = parse_str(src).unwrap().spec.unwrap();
2401        assert!(ir.operations[0].servers.is_empty());
2402        assert!(ir.servers.is_empty());
2403    }
2404
2405    #[test]
2406    fn operation_servers_explicit_empty_array_falls_through_to_root() {
2407        // OAS doesn't define semantics for an empty `servers: []` on an
2408        // operation. We treat it as "no override" and inherit the root —
2409        // matches the empty-vs-absent distinction we already do for
2410        // `security`. Document the choice in the test.
2411        let src = r#"{
2412            "openapi":"3.0.3",
2413            "info":{"title":"t","version":"1"},
2414            "servers":[{"url":"https://root"}],
2415            "paths":{
2416                "/x":{"get":{
2417                    "operationId":"x",
2418                    "servers":[],
2419                    "responses":{"204":{"description":"ok"}}
2420                }}
2421            }
2422        }"#;
2423        let ir = parse_str(src).unwrap().spec.unwrap();
2424        assert_eq!(ir.operations[0].servers[0].url, "https://root");
2425    }
2426
2427    #[test]
2428    fn external_docs_absent_leaves_field_none() {
2429        let src = r#"{
2430            "openapi":"3.0.3",
2431            "info":{"title":"t","version":"1"},
2432            "paths":{}
2433        }"#;
2434        let ir = parse_str(src).unwrap().spec.unwrap();
2435        assert!(ir.external_docs.is_none());
2436    }
2437
2438    #[test]
2439    fn info_license_name_only_round_trips() {
2440        // 3.0 specs commonly carry `license.name` with no `identifier`;
2441        // it must not be lost.
2442        let src = r#"{
2443            "openapi":"3.0.0",
2444            "info":{
2445                "title":"t",
2446                "version":"1",
2447                "license":{"name":"MIT"}
2448            },
2449            "paths":{}
2450        }"#;
2451        let ir = parse_str(src).unwrap().spec.unwrap();
2452        assert_eq!(ir.info.license_name.as_deref(), Some("MIT"));
2453        assert!(ir.info.license_url.is_none());
2454        assert!(ir.info.license_identifier.is_none());
2455    }
2456
2457    #[test]
2458    fn extensions_populate_on_every_specification_object() {
2459        use forge_ir::{SecuritySchemeKind, TypeDef};
2460        // Issue #112. One spec exercising every site that gained an
2461        // `extensions` field: info, server, server-variable, schema /
2462        // property, parameter, request body / body content / encoding,
2463        // response, security scheme, oauth2 flow.
2464        let src = r##"{
2465            "openapi":"3.0.3",
2466            "info":{
2467                "title":"t",
2468                "version":"1",
2469                "x-info":"info-ext"
2470            },
2471            "servers":[{
2472                "url":"https://api.example.com/{tier}",
2473                "x-server":"server-ext",
2474                "variables":{
2475                    "tier":{
2476                        "default":"v1",
2477                        "x-var":"var-ext"
2478                    }
2479                }
2480            }],
2481            "paths":{
2482                "/things":{
2483                    "post":{
2484                        "operationId":"create",
2485                        "parameters":[{
2486                            "name":"q",
2487                            "in":"query",
2488                            "schema":{"type":"string"},
2489                            "x-param":"param-ext"
2490                        }],
2491                        "requestBody":{
2492                            "x-body":"body-ext",
2493                            "content":{
2494                                "multipart/form-data":{
2495                                    "x-content":"content-ext",
2496                                    "schema":{"$ref":"#/components/schemas/Thing"},
2497                                    "encoding":{
2498                                        "name":{
2499                                            "contentType":"text/plain",
2500                                            "x-encoding":"encoding-ext"
2501                                        }
2502                                    }
2503                                }
2504                            }
2505                        },
2506                        "responses":{
2507                            "200":{
2508                                "description":"ok",
2509                                "x-response":"response-ext"
2510                            }
2511                        }
2512                    }
2513                }
2514            },
2515            "components":{
2516                "schemas":{
2517                    "Thing":{
2518                        "type":"object",
2519                        "x-schema":"schema-ext",
2520                        "properties":{
2521                            "name":{
2522                                "type":"string",
2523                                "x-prop":"prop-ext"
2524                            }
2525                        }
2526                    }
2527                },
2528                "securitySchemes":{
2529                    "OAuth":{
2530                        "type":"oauth2",
2531                        "x-scheme":"scheme-ext",
2532                        "flows":{
2533                            "authorizationCode":{
2534                                "authorizationUrl":"https://a",
2535                                "tokenUrl":"https://t",
2536                                "scopes":{},
2537                                "x-flow":"flow-ext"
2538                            }
2539                        }
2540                    }
2541                }
2542            }
2543        }"##;
2544        let ir = parse_str(src).unwrap().spec.unwrap();
2545
2546        // info
2547        let exts = &ir.info.extensions;
2548        assert!(
2549            exts.iter().any(|(k, _)| k == "x-info"),
2550            "info.extensions missing x-info: {exts:?}"
2551        );
2552
2553        // server + server-variable
2554        let server = &ir.servers[0];
2555        assert!(server.extensions.iter().any(|(k, _)| k == "x-server"));
2556        let (_var_name, var) = &server.variables[0];
2557        assert!(var.extensions.iter().any(|(k, _)| k == "x-var"));
2558
2559        // schema (NamedType) + property
2560        let thing = ir.types.iter().find(|t| t.id == "Thing").unwrap();
2561        assert!(thing.extensions.iter().any(|(k, _)| k == "x-schema"));
2562        let TypeDef::Object(obj) = &thing.definition else {
2563            panic!("expected object")
2564        };
2565        let name_prop = obj.properties.iter().find(|p| p.name == "name").unwrap();
2566        assert!(name_prop.extensions.iter().any(|(k, _)| k == "x-prop"));
2567
2568        // operation pieces
2569        let op = &ir.operations[0];
2570        let p = &op.query_params[0];
2571        assert!(p.extensions.iter().any(|(k, _)| k == "x-param"));
2572        let body = op.request_body.as_ref().unwrap();
2573        assert!(body.extensions.iter().any(|(k, _)| k == "x-body"));
2574        let content = &body.content[0];
2575        assert!(content.extensions.iter().any(|(k, _)| k == "x-content"));
2576        let (_enc_name, enc) = &content.encoding[0];
2577        assert!(enc.extensions.iter().any(|(k, _)| k == "x-encoding"));
2578        let resp = &op.responses[0];
2579        assert!(resp.extensions.iter().any(|(k, _)| k == "x-response"));
2580
2581        // security scheme + oauth2 flow
2582        let scheme = ir
2583            .security_schemes
2584            .iter()
2585            .find(|s| s.id == "OAuth")
2586            .unwrap();
2587        assert!(scheme.extensions.iter().any(|(k, _)| k == "x-scheme"));
2588        let SecuritySchemeKind::Oauth2(o) = &scheme.kind else {
2589            panic!("expected oauth2");
2590        };
2591        let flow = &o.flows[0];
2592        assert!(flow.extensions.iter().any(|(k, _)| k == "x-flow"));
2593    }
2594
2595    #[test]
2596    fn compound_extensions_survive_via_value_pool() {
2597        // List / object `x-*` values now survive the WIT boundary via the
2598        // value pool (ADR-0007 amendment). The parser interns the array
2599        // into the pool and `info.extensions` references it by `ValueRef`.
2600        let src = r#"{
2601            "openapi":"3.0.3",
2602            "info":{
2603                "title":"t",
2604                "version":"1",
2605                "x-array":[1,2,3]
2606            },
2607            "paths":{}
2608        }"#;
2609        let ir = parse_str(src).unwrap().spec.unwrap();
2610        let entry = ir
2611            .info
2612            .extensions
2613            .iter()
2614            .find(|(k, _)| k == "x-array")
2615            .expect("x-array extension survives");
2616        let r = entry.1 as usize;
2617        let forge_ir::Value::List { items } = &ir.values[r] else {
2618            panic!("expected list, got {:?}", ir.values[r]);
2619        };
2620        assert_eq!(items.len(), 3);
2621    }
2622
2623    #[test]
2624    fn server_name_3_2_round_trips() {
2625        // OAS 3.2 added `Server.name` as a short label distinct from
2626        // `description`. Capture it verbatim.
2627        let src = r#"{
2628            "openapi":"3.2.0",
2629            "info":{"title":"t","version":"1"},
2630            "servers":[
2631                {"url":"https://api.example.com","name":"production"},
2632                {"url":"https://staging.example.com"}
2633            ],
2634            "paths":{}
2635        }"#;
2636        let ir = parse_str(src).unwrap().spec.unwrap();
2637        assert_eq!(ir.servers[0].name.as_deref(), Some("production"));
2638        assert!(ir.servers[1].name.is_none());
2639    }
2640
2641    #[test]
2642    fn parameter_querystring_3_2_routes_to_new_bucket() {
2643        // OAS 3.2 added `in: querystring`. Should land on the new
2644        // `Operation.querystring_params` slot, not the regular
2645        // `query_params`.
2646        let src = r#"{
2647            "openapi":"3.2.0",
2648            "info":{"title":"t","version":"1"},
2649            "paths":{
2650                "/search":{"get":{
2651                    "operationId":"search",
2652                    "parameters":[
2653                        {"name":"raw","in":"querystring","schema":{"type":"string"}}
2654                    ],
2655                    "responses":{"200":{"description":"ok"}}
2656                }}
2657            }
2658        }"#;
2659        let ir = parse_str(src).unwrap().spec.unwrap();
2660        let op = &ir.operations[0];
2661        assert!(op.query_params.is_empty(), "must not land in query_params");
2662        assert_eq!(op.querystring_params.len(), 1);
2663        assert_eq!(op.querystring_params[0].name, "raw");
2664    }
2665
2666    #[test]
2667    fn example_data_value_serialized_value_3_2() {
2668        // OAS 3.2 split `value` into `dataValue` (parsed) and
2669        // `serializedValue` (wire form). Both must round-trip.
2670        let src = r##"{
2671            "openapi":"3.2.0",
2672            "info":{"title":"t","version":"1"},
2673            "paths":{},
2674            "components":{
2675                "schemas":{
2676                    "Thing":{
2677                        "type":"string",
2678                        "examples":[
2679                            {"summary":"alice","dataValue":"alice","serializedValue":"\"alice\""}
2680                        ]
2681                    }
2682                }
2683            }
2684        }"##;
2685        // Use parameter-level examples since schema-level `examples` array (plural)
2686        // is in a different code path; here exercise the parameter / media-type path.
2687        let src2 = r##"{
2688            "openapi":"3.2.0",
2689            "info":{"title":"t","version":"1"},
2690            "paths":{
2691                "/thing":{"post":{
2692                    "operationId":"create",
2693                    "requestBody":{"content":{"application/json":{
2694                        "schema":{"type":"string"},
2695                        "examples":{
2696                            "alice":{"dataValue":"alice","serializedValue":"\"alice\""}
2697                        }
2698                    }}},
2699                    "responses":{"200":{"description":"ok"}}
2700                }}
2701            }
2702        }"##;
2703        let _ = src; // schema-level examples (plural) handled separately; smoke-load
2704        let ir = parse_str(src2).unwrap().spec.unwrap();
2705        let body = ir.operations[0].request_body.as_ref().unwrap();
2706        let example = &body.content[0].examples[0].1;
2707        let r = example.data_value.unwrap() as usize;
2708        assert_eq!(ir.values[r], forge_ir::Value::s("alice"));
2709        assert_eq!(example.serialized_value.as_deref(), Some("\"alice\""));
2710    }
2711
2712    #[test]
2713    fn xml_text_ordered_3_2() {
2714        // OAS 3.2 added `text` and `ordered` flags to the XML Object.
2715        let src = r#"{
2716            "openapi":"3.2.0",
2717            "info":{"title":"t","version":"1"},
2718            "paths":{},
2719            "components":{
2720                "schemas":{
2721                    "Title":{"type":"string","xml":{"text":true}},
2722                    "Steps":{"type":"array","items":{"type":"string"},"xml":{"wrapped":true,"ordered":true}}
2723                }
2724            }
2725        }"#;
2726        let ir = parse_str(src).unwrap().spec.unwrap();
2727        let title = ir.types.iter().find(|t| t.id == "Title").unwrap();
2728        let title_xml = title.xml.as_ref().unwrap();
2729        assert!(title_xml.text);
2730        assert!(!title_xml.ordered);
2731
2732        let steps = ir.types.iter().find(|t| t.id == "Steps").unwrap();
2733        let steps_xml = steps.xml.as_ref().unwrap();
2734        assert!(steps_xml.ordered);
2735        assert!(!steps_xml.text);
2736    }
2737
2738    #[test]
2739    fn mutual_tls_security_scheme_round_trips() {
2740        use forge_ir::SecuritySchemeKind;
2741        let src = r#"{
2742            "openapi":"3.0.3",
2743            "info":{"title":"t","version":"1"},
2744            "paths":{},
2745            "components":{"securitySchemes":{
2746                "mtls":{"type":"mutualTLS","description":"client-cert auth"}
2747            }}
2748        }"#;
2749        let ir = parse_str(src).unwrap().spec.unwrap();
2750        let scheme = &ir.security_schemes[0];
2751        assert_eq!(scheme.id, "mtls");
2752        assert!(matches!(scheme.kind, SecuritySchemeKind::MutualTls));
2753        assert_eq!(scheme.description.as_deref(), Some("client-cert auth"));
2754    }
2755
2756    #[test]
2757    fn oauth2_all_four_flows_succeed() {
2758        use forge_ir::{OAuth2FlowKind, SecuritySchemeKind};
2759        let src = r#"{
2760            "openapi":"3.0.3",
2761            "info":{"title":"t","version":"1"},
2762            "paths":{},
2763            "components":{"securitySchemes":{
2764                "auth":{"type":"oauth2","flows":{
2765                    "implicit":{
2766                        "authorizationUrl":"https://a/auth",
2767                        "scopes":{"read":"r"}
2768                    },
2769                    "password":{
2770                        "tokenUrl":"https://a/token",
2771                        "scopes":{"read":"r"}
2772                    },
2773                    "clientCredentials":{
2774                        "tokenUrl":"https://a/token",
2775                        "scopes":{"read":"r"}
2776                    },
2777                    "authorizationCode":{
2778                        "authorizationUrl":"https://a/auth",
2779                        "tokenUrl":"https://a/token",
2780                        "scopes":{"read":"r"}
2781                    }
2782                }}
2783            }}
2784        }"#;
2785        let ir = parse_str(src).unwrap().spec.unwrap();
2786        let scheme = &ir.security_schemes[0];
2787        let SecuritySchemeKind::Oauth2(o) = &scheme.kind else {
2788            panic!("expected oauth2 kind");
2789        };
2790        assert_eq!(o.flows.len(), 4, "all four flows surface");
2791        let kinds: Vec<OAuth2FlowKind> = o.flows.iter().map(|f| f.kind).collect();
2792        assert!(kinds.contains(&OAuth2FlowKind::Implicit));
2793        assert!(kinds.contains(&OAuth2FlowKind::Password));
2794        assert!(kinds.contains(&OAuth2FlowKind::ClientCredentials));
2795        assert!(kinds.contains(&OAuth2FlowKind::AuthorizationCode));
2796    }
2797
2798    #[test]
2799    fn oauth2_missing_required_url_errors() {
2800        // `password` flow requires `tokenUrl`; absence emits
2801        // `parser/E-OAUTH2-MISSING-URL`.
2802        let src = r#"{
2803            "openapi":"3.0.3",
2804            "info":{"title":"t","version":"1"},
2805            "paths":{},
2806            "components":{"securitySchemes":{
2807                "auth":{"type":"oauth2","flows":{
2808                    "password":{"scopes":{"read":"r"}}
2809                }}
2810            }}
2811        }"#;
2812        let out = parse_str(src).unwrap();
2813        assert!(
2814            out.diagnostics
2815                .iter()
2816                .any(|d| d.code == diag::E_OAUTH2_MISSING_URL),
2817            "expected E-OAUTH2-MISSING-URL"
2818        );
2819    }
2820
2821    #[test]
2822    fn content_encoding_keywords_round_trip() {
2823        // JSON Schema 2020-12 / OAS 3.2 contentEncoding +
2824        // contentMediaType + contentSchema land on
2825        // PrimitiveConstraints. contentSchema's body lifts into the
2826        // type pool under a `<owner>_content_schema` id.
2827        use forge_ir::TypeDef;
2828        let src = r#"{
2829            "openapi":"3.2.0",
2830            "info":{"title":"t","version":"1"},
2831            "paths":{},
2832            "components":{"schemas":{
2833                "Avatar":{
2834                    "type":"string",
2835                    "contentEncoding":"base64",
2836                    "contentMediaType":"image/png"
2837                },
2838                "Embedded":{
2839                    "type":"string",
2840                    "contentMediaType":"application/json",
2841                    "contentSchema":{
2842                        "type":"object",
2843                        "properties":{"id":{"type":"string"}}
2844                    }
2845                }
2846            }}
2847        }"#;
2848        let ir = parse_str(src).unwrap().spec.unwrap();
2849
2850        let avatar = ir.types.iter().find(|t| t.id == "Avatar").unwrap();
2851        let TypeDef::Primitive(p) = &avatar.definition else {
2852            panic!("expected primitive");
2853        };
2854        assert_eq!(p.constraints.content_encoding.as_deref(), Some("base64"));
2855        assert_eq!(
2856            p.constraints.content_media_type.as_deref(),
2857            Some("image/png")
2858        );
2859        assert!(p.constraints.content_schema.is_none());
2860
2861        let embedded = ir.types.iter().find(|t| t.id == "Embedded").unwrap();
2862        let TypeDef::Primitive(p) = &embedded.definition else {
2863            panic!("expected primitive");
2864        };
2865        assert_eq!(
2866            p.constraints.content_media_type.as_deref(),
2867            Some("application/json")
2868        );
2869        let cs_ref = p
2870            .constraints
2871            .content_schema
2872            .as_deref()
2873            .expect("content_schema set");
2874        // The decoded payload's type must be reachable in the type pool.
2875        assert!(ir.types.iter().any(|t| t.id == cs_ref));
2876    }
2877
2878    #[test]
2879    fn components_media_types_pool_resolves_refs() {
2880        // OAS 3.2 `components.mediaTypes` — `$ref` from `requestBody.content.<media>`
2881        // and `response.content.<media>` resolves through the pool.
2882        // Unused entries warn with `parser/W-COMPONENT-MEDIA-TYPE-UNUSED`.
2883        let src = r##"{
2884            "openapi":"3.2.0",
2885            "info":{"title":"t","version":"1"},
2886            "paths":{
2887                "/things":{"post":{
2888                    "operationId":"create",
2889                    "requestBody":{"content":{
2890                        "application/json":{"$ref":"#/components/mediaTypes/ThingJson"}
2891                    }},
2892                    "responses":{"204":{"description":"ok"}}
2893                }}
2894            },
2895            "components":{
2896                "schemas":{
2897                    "Thing":{"type":"object","properties":{"id":{"type":"string"}}}
2898                },
2899                "mediaTypes":{
2900                    "ThingJson":{"schema":{"$ref":"#/components/schemas/Thing"}},
2901                    "Unused":{"schema":{"type":"string"}}
2902                }
2903            }
2904        }"##;
2905        let out = parse_str(src).unwrap();
2906        let ir = out.spec.unwrap();
2907        let body = ir.operations[0].request_body.as_ref().unwrap();
2908        // The ref should resolve and the type should point to Thing.
2909        assert_eq!(body.content[0].r#type, "Thing");
2910        // Unused entry warns.
2911        assert!(
2912            out.diagnostics
2913                .iter()
2914                .any(|d| d.code == diag::W_COMPONENT_MEDIA_TYPE_UNUSED
2915                    && d.message.contains("Unused")),
2916            "expected W-COMPONENT-MEDIA-TYPE-UNUSED for `Unused`"
2917        );
2918        // ThingJson was referenced — must NOT warn.
2919        assert!(
2920            !out.diagnostics
2921                .iter()
2922                .any(|d| d.code == diag::W_COMPONENT_MEDIA_TYPE_UNUSED
2923                    && d.message.contains("ThingJson")),
2924            "ThingJson is referenced; should not warn"
2925        );
2926    }
2927
2928    #[test]
2929    fn json_schema_deferred_keywords_warn_not_error() {
2930        // #144: deferred 2020-12 keywords (dependentRequired,
2931        // dependentSchemas, unevaluatedProperties, $dynamicRef,
2932        // $dynamicAnchor) used to error and reject the whole spec.
2933        // Now they warn and the rest of the schema parses.
2934        let src = r#"{
2935            "openapi":"3.1.0",
2936            "info":{"title":"t","version":"1"},
2937            "paths":{},
2938            "components":{"schemas":{
2939                "Bad":{
2940                    "type":"object",
2941                    "dependentRequired":{"a":["b"]},
2942                    "unevaluatedProperties":false,
2943                    "properties":{"id":{"type":"string"}}
2944                }
2945            }}
2946        }"#;
2947        let out = parse_str(src).unwrap();
2948        let ir = out.spec.expect("spec parses despite deferred keywords");
2949        // The schema landed in the type pool — `Bad` exists with its
2950        // declared `id` property.
2951        assert!(ir.types.iter().any(|t| t.id == "Bad"));
2952        // Both deferred keywords surfaced as warnings, not errors.
2953        let warns: Vec<&str> = out
2954            .diagnostics
2955            .iter()
2956            .filter(|d| d.severity == forge_ir::Severity::Warning)
2957            .map(|d| d.code.as_str())
2958            .collect();
2959        assert!(
2960            warns.contains(&diag::W_DEPENDENT_REQUIRED_DROPPED),
2961            "expected W-DEPENDENT-REQUIRED-DROPPED, got {warns:?}"
2962        );
2963        assert!(
2964            warns.contains(&diag::W_UNEVALUATED_PROPERTIES_DROPPED),
2965            "expected W-UNEVALUATED-PROPERTIES-DROPPED, got {warns:?}"
2966        );
2967        // No errors — these used to reject the spec entirely.
2968        let errs: Vec<&str> = out
2969            .diagnostics
2970            .iter()
2971            .filter(|d| d.severity == forge_ir::Severity::Error)
2972            .map(|d| d.code.as_str())
2973            .collect();
2974        assert!(errs.is_empty(), "no errors expected, got {errs:?}");
2975    }
2976
2977    #[test]
2978    fn root_json_schema_dialect_and_self_round_trip() {
2979        // #143, #147. Capture jsonSchemaDialect and $self verbatim.
2980        let src = r##"{
2981            "openapi":"3.2.0",
2982            "$self":"https://example.com/api.json",
2983            "jsonSchemaDialect":"https://json-schema.org/draft/2020-12/schema",
2984            "info":{"title":"t","version":"1"},
2985            "paths":{}
2986        }"##;
2987        let ir = parse_str(src).unwrap().spec.unwrap();
2988        assert_eq!(
2989            ir.json_schema_dialect.as_deref(),
2990            Some("https://json-schema.org/draft/2020-12/schema")
2991        );
2992        assert_eq!(ir.self_url.as_deref(), Some("https://example.com/api.json"));
2993    }
2994
2995    #[test]
2996    fn header_style_explode_round_trip() {
2997        // #145. Header object's serialization fields populate the IR
2998        // even though the spec fixes style to `simple`.
2999        use forge_ir::ParameterStyle;
3000        let src = r#"{
3001            "openapi":"3.0.3",
3002            "info":{"title":"t","version":"1"},
3003            "paths":{"/x":{"get":{
3004                "operationId":"x",
3005                "responses":{"200":{
3006                    "description":"ok",
3007                    "headers":{
3008                        "X-Rate":{
3009                            "schema":{"type":"integer"},
3010                            "style":"simple",
3011                            "explode":true,
3012                            "allowReserved":false,
3013                            "allowEmptyValue":false
3014                        }
3015                    }
3016                }}
3017            }}}
3018        }"#;
3019        let ir = parse_str(src).unwrap().spec.unwrap();
3020        let resp = &ir.operations[0].responses[0];
3021        let (_name, h) = &resp.headers[0];
3022        assert_eq!(h.style, Some(ParameterStyle::Simple));
3023        assert!(h.explode);
3024        assert!(!h.allow_reserved);
3025        assert!(!h.allow_empty_value);
3026    }
3027
3028    #[test]
3029    fn ref_siblings_3_1_plus_merge_onto_target() {
3030        // #146 (covers #139 too): in 3.1+, sibling keywords on a `$ref`
3031        // override the resolved target's same-keyed fields. OAS 3.2
3032        // codifies `summary` / `description` on Reference Object.
3033        let src = r##"{
3034            "openapi":"3.2.0",
3035            "info":{"title":"t","version":"1"},
3036            "paths":{"/x":{"get":{
3037                "operationId":"x",
3038                "responses":{"200":{
3039                    "$ref":"#/components/responses/Shared",
3040                    "description":"per-call override"
3041                }}
3042            }}},
3043            "components":{"responses":{
3044                "Shared":{
3045                    "description":"shared default",
3046                    "content":{"application/json":{"schema":{"type":"string"}}}
3047                }
3048            }}
3049        }"##;
3050        let ir = parse_str(src).unwrap().spec.unwrap();
3051        let resp = &ir.operations[0].responses[0];
3052        assert_eq!(
3053            resp.description.as_deref(),
3054            Some("per-call override"),
3055            "the sibling `description` wins over the shared default"
3056        );
3057    }
3058
3059    /// OAS 3.2 §4.23: Response now carries `summary` as well as
3060    /// `description`. A Reference Object's `summary` override should
3061    /// land on `Response.summary`.
3062    #[test]
3063    fn ref_summary_override_on_response_3_2() {
3064        let src = r##"{
3065            "openapi":"3.2.0",
3066            "info":{"title":"t","version":"1"},
3067            "paths":{"/x":{"get":{
3068                "operationId":"x",
3069                "responses":{"200":{
3070                    "$ref":"#/components/responses/Shared",
3071                    "summary":"per-call summary",
3072                    "description":"per-call desc"
3073                }}
3074            }}},
3075            "components":{"responses":{
3076                "Shared":{
3077                    "summary":"shared summary",
3078                    "description":"shared desc"
3079                }
3080            }}
3081        }"##;
3082        let ir = parse_str(src).unwrap().spec.unwrap();
3083        let resp = &ir.operations[0].responses[0];
3084        assert_eq!(resp.summary.as_deref(), Some("per-call summary"));
3085        assert_eq!(resp.description.as_deref(), Some("per-call desc"));
3086    }
3087
3088    /// Parameter Object (§4.12) only carries `description`. The
3089    /// Reference Object's `summary` should silently have no effect —
3090    /// the target type doesn't read it. `description` overrides.
3091    #[test]
3092    fn ref_override_on_parameter_summary_has_no_effect() {
3093        let src = r##"{
3094            "openapi":"3.2.0",
3095            "info":{"title":"t","version":"1"},
3096            "paths":{"/x/{id}":{
3097                "parameters":[{
3098                    "$ref":"#/components/parameters/Id",
3099                    "summary":"override summary",
3100                    "description":"override desc"
3101                }],
3102                "get":{"operationId":"x","responses":{"200":{"description":"ok"}}}
3103            }},
3104            "components":{"parameters":{
3105                "Id":{"name":"id","in":"path","required":true,"schema":{"type":"string"},
3106                      "description":"shared desc"}
3107            }}
3108        }"##;
3109        let ir = parse_str(src).unwrap().spec.unwrap();
3110        let param = &ir.operations[0].path_params[0];
3111        // description on Parameter: ref-site value wins.
3112        assert_eq!(param.description.as_deref(), Some("override desc"));
3113        // summary on Parameter: OAS §4.12 doesn't define summary on
3114        // Parameter, so the IR has no slot for it — type-level
3115        // enforcement of "this field has no effect" per §4.23. The
3116        // override value lands nowhere visible.
3117    }
3118
3119    /// Header Object (§4.21) only carries `description`. Same
3120    /// no-effect rule for `summary` as Parameter.
3121    #[test]
3122    fn ref_description_override_on_header() {
3123        let src = r##"{
3124            "openapi":"3.2.0",
3125            "info":{"title":"t","version":"1"},
3126            "paths":{"/x":{"get":{
3127                "operationId":"x",
3128                "responses":{"200":{
3129                    "description":"ok",
3130                    "headers":{
3131                        "X-Trace":{
3132                            "$ref":"#/components/headers/Trace",
3133                            "description":"override"
3134                        }
3135                    }
3136                }}
3137            }}},
3138            "components":{"headers":{
3139                "Trace":{"schema":{"type":"string"},"description":"shared"}
3140            }}
3141        }"##;
3142        let ir = parse_str(src).unwrap().spec.unwrap();
3143        let (_n, h) = &ir.operations[0].responses[0].headers[0];
3144        assert_eq!(h.description.as_deref(), Some("override"));
3145    }
3146
3147    /// Example Object (§4.19) carries `summary` and `description`.
3148    /// Both must override the target's values.
3149    #[test]
3150    fn ref_override_on_example_summary_and_description() {
3151        let src = r##"{
3152            "openapi":"3.2.0",
3153            "info":{"title":"t","version":"1"},
3154            "paths":{},
3155            "components":{
3156                "examples":{
3157                    "Shared":{"summary":"shared sum","description":"shared d","value":"v"}
3158                },
3159                "schemas":{
3160                    "Foo":{
3161                        "type":"string",
3162                        "examples":{
3163                            "ref":{
3164                                "$ref":"#/components/examples/Shared",
3165                                "summary":"call sum",
3166                                "description":"call d"
3167                            }
3168                        }
3169                    }
3170                }
3171            }
3172        }"##;
3173        let ir = parse_str(src).unwrap().spec.unwrap();
3174        let foo = ir.types.iter().find(|t| t.id == "Foo").unwrap();
3175        let ex = &foo.examples[0].1;
3176        assert_eq!(ex.summary.as_deref(), Some("call sum"));
3177        assert_eq!(ex.description.as_deref(), Some("call d"));
3178    }
3179
3180    /// Link Object (§4.20) only carries `description`. The current
3181    /// Reference Object override path supplies it. Covered alongside
3182    /// the broader sibling-merge test, but called out explicitly.
3183    #[test]
3184    fn ref_description_override_on_link() {
3185        let src = r##"{
3186            "openapi":"3.2.0",
3187            "info":{"title":"t","version":"1"},
3188            "paths":{"/x":{"get":{
3189                "operationId":"x",
3190                "responses":{"200":{
3191                    "description":"ok",
3192                    "links":{"next":{
3193                        "$ref":"#/components/links/Shared",
3194                        "description":"per-call link doc"
3195                    }}
3196                }}
3197            }}},
3198            "components":{"links":{
3199                "Shared":{"operationId":"x","description":"shared link doc"}
3200            }}
3201        }"##;
3202        let ir = parse_str(src).unwrap().spec.unwrap();
3203        let link = &ir.operations[0].responses[0].links[0].1;
3204        assert_eq!(link.description.as_deref(), Some("per-call link doc"));
3205    }
3206
3207    /// RequestBody (§4.13) only carries `description`. Override
3208    /// applies via the Reference Object path.
3209    #[test]
3210    fn ref_description_override_on_request_body() {
3211        let src = r##"{
3212            "openapi":"3.2.0",
3213            "info":{"title":"t","version":"1"},
3214            "paths":{"/x":{"post":{
3215                "operationId":"x",
3216                "requestBody":{
3217                    "$ref":"#/components/requestBodies/Shared",
3218                    "description":"per-call"
3219                },
3220                "responses":{"200":{"description":"ok"}}
3221            }}},
3222            "components":{"requestBodies":{
3223                "Shared":{
3224                    "description":"shared",
3225                    "content":{"application/json":{"schema":{"type":"string"}}}
3226                }
3227            }}
3228        }"##;
3229        let ir = parse_str(src).unwrap().spec.unwrap();
3230        let body = ir.operations[0].request_body.as_ref().unwrap();
3231        assert_eq!(body.description.as_deref(), Some("per-call"));
3232    }
3233
3234    /// SecurityScheme (§4.27) carries `description`. Reference Object
3235    /// override applies. Tests the `securitySchemes` ref path.
3236    #[test]
3237    fn ref_description_override_on_security_scheme() {
3238        let src = r##"{
3239            "openapi":"3.2.0",
3240            "info":{"title":"t","version":"1"},
3241            "paths":{},
3242            "components":{"securitySchemes":{
3243                "Wrap":{
3244                    "$ref":"#/components/securitySchemes/Shared",
3245                    "description":"per-call"
3246                },
3247                "Shared":{"type":"http","scheme":"bearer","description":"shared"}
3248            }}
3249        }"##;
3250        let ir = parse_str(src).unwrap().spec.unwrap();
3251        let scheme = ir
3252            .security_schemes
3253            .iter()
3254            .find(|s| s.id == "Wrap")
3255            .expect("Wrap scheme present");
3256        assert_eq!(scheme.description.as_deref(), Some("per-call"));
3257    }
3258
3259    /// OAS 3.2 §4.23: Reference Object "cannot be extended with
3260    /// additional properties, and any properties added SHALL be
3261    /// ignored." Verify the parser drops the extras and emits
3262    /// `W-REF-SIBLINGS-INVALID`.
3263    #[test]
3264    fn ref_with_invalid_siblings_warns_and_drops() {
3265        let src = r##"{
3266            "openapi":"3.2.0",
3267            "info":{"title":"t","version":"1"},
3268            "paths":{"/x":{"get":{
3269                "operationId":"x",
3270                "parameters":[{
3271                    "$ref":"#/components/parameters/Id",
3272                    "description":"ok",
3273                    "required":false,
3274                    "deprecated":true
3275                }],
3276                "responses":{"200":{"description":"ok"}}
3277            }}},
3278            "components":{"parameters":{
3279                "Id":{"name":"id","in":"path","required":true,"schema":{"type":"string"}}
3280            }}
3281        }"##;
3282        let out = parse_str(src).unwrap();
3283        let ir = out.spec.as_ref().unwrap();
3284        let param = &ir.operations[0].path_params[0];
3285        // `required` and `deprecated` on the ref site SHALL be
3286        // ignored; the target's values survive.
3287        assert!(param.required);
3288        assert!(!param.deprecated);
3289        // `description` on the ref site applies.
3290        assert_eq!(param.description.as_deref(), Some("ok"));
3291        assert!(
3292            out.diagnostics
3293                .iter()
3294                .any(|d| d.code == diag::W_REF_SIBLINGS_INVALID),
3295            "expected W-REF-SIBLINGS-INVALID; got: {:?}",
3296            out.diagnostics.iter().map(|d| &d.code).collect::<Vec<_>>()
3297        );
3298    }
3299
3300    /// `x-*` extensions on a Reference Object are also invalid per
3301    /// §4.23 ("cannot be extended with additional properties"). Verify
3302    /// the warning fires and the extension does not propagate.
3303    #[test]
3304    fn ref_with_x_extension_sibling_warns() {
3305        let src = r##"{
3306            "openapi":"3.2.0",
3307            "info":{"title":"t","version":"1"},
3308            "paths":{"/x":{"get":{
3309                "operationId":"x",
3310                "responses":{"200":{
3311                    "$ref":"#/components/responses/Shared",
3312                    "x-vendor":"yes"
3313                }}
3314            }}},
3315            "components":{"responses":{
3316                "Shared":{"description":"shared"}
3317            }}
3318        }"##;
3319        let out = parse_str(src).unwrap();
3320        assert!(
3321            out.diagnostics
3322                .iter()
3323                .any(|d| d.code == diag::W_REF_SIBLINGS_INVALID),
3324            "expected W-REF-SIBLINGS-INVALID; got: {:?}",
3325            out.diagnostics.iter().map(|d| &d.code).collect::<Vec<_>>()
3326        );
3327    }
3328
3329    /// MediaType (§4.14) has no `description` / `summary` fields.
3330    /// A Reference Object pointing at a MediaType with override docs
3331    /// applies neither — the target type doesn't read those fields.
3332    /// (Tested via the `mediaTypes` components map, OAS 3.2.)
3333    #[test]
3334    fn ref_override_on_media_type_has_no_effect() {
3335        let src = r##"{
3336            "openapi":"3.2.0",
3337            "info":{"title":"t","version":"1"},
3338            "paths":{"/x":{"get":{
3339                "operationId":"x",
3340                "responses":{"200":{
3341                    "description":"ok",
3342                    "content":{"application/json":{
3343                        "$ref":"#/components/mediaTypes/Shared",
3344                        "description":"ignored"
3345                    }}
3346                }}
3347            }}},
3348            "components":{"mediaTypes":{
3349                "Shared":{"schema":{"type":"string"}}
3350            }}
3351        }"##;
3352        let out = parse_str(src).unwrap();
3353        let ir = out.spec.as_ref().unwrap();
3354        let content = &ir.operations[0].responses[0].content[0];
3355        // MediaType (§4.14) has no `description` field per spec — the
3356        // IR's BodyContent has no description slot. The override
3357        // overlays the JSON but lands nowhere in the IR, matching
3358        // §4.23's "this field has no effect" clause at the type level.
3359        assert_eq!(content.media_type, "application/json");
3360        assert!(content.examples.is_empty());
3361    }
3362
3363    /// Reference Object on the target side of a multi-hop chain
3364    /// (`A → B → final`) still gets its `summary`/`description`
3365    /// applied at the outermost site. Only the outermost siblings
3366    /// participate — intermediates are pure pass-through.
3367    #[test]
3368    fn ref_chain_only_outermost_siblings_apply() {
3369        let src = r##"{
3370            "openapi":"3.2.0",
3371            "info":{"title":"t","version":"1"},
3372            "paths":{"/x":{"get":{
3373                "operationId":"x",
3374                "responses":{"200":{
3375                    "$ref":"#/components/responses/Outer",
3376                    "description":"call-site"
3377                }}
3378            }}},
3379            "components":{"responses":{
3380                "Outer":{"$ref":"#/components/responses/Inner"},
3381                "Inner":{"description":"deepest"}
3382            }}
3383        }"##;
3384        let out = parse_str(src).unwrap();
3385        let resp = &out.spec.as_ref().unwrap().operations[0].responses[0];
3386        assert_eq!(resp.description.as_deref(), Some("call-site"));
3387    }
3388
3389    /// OAS 3.0 dropped siblings silently from non-schema refs (the
3390    /// schema-side path emits W-REF-SIBLINGS-3-0 separately). Verify
3391    /// the override is not applied for 3.0 docs.
3392    #[test]
3393    fn ref_override_skipped_for_oas_3_0() {
3394        let src = r##"{
3395            "openapi":"3.0.3",
3396            "info":{"title":"t","version":"1"},
3397            "paths":{"/x":{"get":{
3398                "operationId":"x",
3399                "responses":{"200":{
3400                    "$ref":"#/components/responses/Shared",
3401                    "description":"would-be override"
3402                }}
3403            }}},
3404            "components":{"responses":{
3405                "Shared":{"description":"shared"}
3406            }}
3407        }"##;
3408        let ir = parse_str(src).unwrap().spec.unwrap();
3409        let resp = &ir.operations[0].responses[0];
3410        // 3.0: siblings are dropped, target's description survives.
3411        assert_eq!(resp.description.as_deref(), Some("shared"));
3412    }
3413
3414    /// $ref with no siblings — basic happy path. Target's docs survive
3415    /// unchanged; no warnings emitted.
3416    #[test]
3417    fn ref_with_only_dollar_ref_no_overrides() {
3418        let src = r##"{
3419            "openapi":"3.2.0",
3420            "info":{"title":"t","version":"1"},
3421            "paths":{"/x":{"get":{
3422                "operationId":"x",
3423                "responses":{"200":{"$ref":"#/components/responses/Shared"}}
3424            }}},
3425            "components":{"responses":{
3426                "Shared":{"description":"shared","summary":"shared sum"}
3427            }}
3428        }"##;
3429        let out = parse_str(src).unwrap();
3430        let resp = &out.spec.as_ref().unwrap().operations[0].responses[0];
3431        assert_eq!(resp.summary.as_deref(), Some("shared sum"));
3432        assert_eq!(resp.description.as_deref(), Some("shared"));
3433        assert!(
3434            !out.diagnostics
3435                .iter()
3436                .any(|d| d.code == diag::W_REF_SIBLINGS_INVALID),
3437            "no W-REF-SIBLINGS-INVALID for a bare $ref"
3438        );
3439    }
3440
3441    /// $ref override of `summary` only — `description` from target
3442    /// survives unchanged. The two fields are independent.
3443    #[test]
3444    fn ref_summary_override_only_leaves_description_intact() {
3445        let src = r##"{
3446            "openapi":"3.2.0",
3447            "info":{"title":"t","version":"1"},
3448            "paths":{"/x":{"get":{
3449                "operationId":"x",
3450                "responses":{"200":{
3451                    "$ref":"#/components/responses/Shared",
3452                    "summary":"call-site sum"
3453                }}
3454            }}},
3455            "components":{"responses":{
3456                "Shared":{"summary":"shared sum","description":"shared d"}
3457            }}
3458        }"##;
3459        let resp = &parse_str(src).unwrap().spec.unwrap().operations[0].responses[0];
3460        assert_eq!(resp.summary.as_deref(), Some("call-site sum"));
3461        assert_eq!(resp.description.as_deref(), Some("shared d"));
3462    }
3463
3464    /// $ref override of `description` only — `summary` from target
3465    /// survives unchanged.
3466    #[test]
3467    fn ref_description_override_only_leaves_summary_intact() {
3468        let src = r##"{
3469            "openapi":"3.2.0",
3470            "info":{"title":"t","version":"1"},
3471            "paths":{"/x":{"get":{
3472                "operationId":"x",
3473                "responses":{"200":{
3474                    "$ref":"#/components/responses/Shared",
3475                    "description":"call-site d"
3476                }}
3477            }}},
3478            "components":{"responses":{
3479                "Shared":{"summary":"shared sum","description":"shared d"}
3480            }}
3481        }"##;
3482        let resp = &parse_str(src).unwrap().spec.unwrap().operations[0].responses[0];
3483        assert_eq!(resp.summary.as_deref(), Some("shared sum"));
3484        assert_eq!(resp.description.as_deref(), Some("call-site d"));
3485    }
3486
3487    // ---- Per-node strict spec doc-field population ----
3488
3489    /// Operation `summary` populates independently from `description`.
3490    /// OAS 3.1 introduced summary as a distinct field; this verifies the
3491    /// two slots don't collapse.
3492    #[test]
3493    fn operation_summary_independent_of_description() {
3494        let src = r#"{
3495            "openapi":"3.2.0",
3496            "info":{"title":"t","version":"1"},
3497            "paths":{"/x":{"get":{
3498                "operationId":"x",
3499                "summary":"short",
3500                "description":"long form",
3501                "responses":{"200":{"description":"ok"}}
3502            }}}
3503        }"#;
3504        let op = &parse_str(src).unwrap().spec.unwrap().operations[0];
3505        assert_eq!(op.summary.as_deref(), Some("short"));
3506        assert_eq!(op.description.as_deref(), Some("long form"));
3507    }
3508
3509    /// PathItem-level `summary` falls back into `Operation.summary` when
3510    /// the operation has none. OAS §4.9.
3511    #[test]
3512    fn operation_summary_falls_back_from_path_item() {
3513        let src = r#"{
3514            "openapi":"3.2.0",
3515            "info":{"title":"t","version":"1"},
3516            "paths":{"/x":{
3517                "summary":"path-item sum",
3518                "description":"path-item desc",
3519                "get":{"operationId":"x","responses":{"200":{"description":"ok"}}}
3520            }}
3521        }"#;
3522        let op = &parse_str(src).unwrap().spec.unwrap().operations[0];
3523        assert_eq!(op.summary.as_deref(), Some("path-item sum"));
3524        assert_eq!(op.description.as_deref(), Some("path-item desc"));
3525    }
3526
3527    /// PathItem-level fallback is per-field independent — operation
3528    /// `summary` overrides PathItem.summary, but PathItem.description
3529    /// still flows through when the operation has none.
3530    #[test]
3531    fn path_item_fallback_per_field_independent() {
3532        let src = r#"{
3533            "openapi":"3.2.0",
3534            "info":{"title":"t","version":"1"},
3535            "paths":{"/x":{
3536                "summary":"path-item sum",
3537                "description":"path-item desc",
3538                "get":{
3539                    "operationId":"x",
3540                    "summary":"op sum",
3541                    "responses":{"200":{"description":"ok"}}
3542                }
3543            }}
3544        }"#;
3545        let op = &parse_str(src).unwrap().spec.unwrap().operations[0];
3546        assert_eq!(op.summary.as_deref(), Some("op sum"));
3547        assert_eq!(op.description.as_deref(), Some("path-item desc"));
3548    }
3549
3550    /// `Operation.deprecated` populates from the spec.
3551    #[test]
3552    fn operation_deprecated_populates() {
3553        let src = r#"{
3554            "openapi":"3.2.0",
3555            "info":{"title":"t","version":"1"},
3556            "paths":{"/x":{"get":{
3557                "operationId":"x",
3558                "deprecated":true,
3559                "responses":{"200":{"description":"ok"}}
3560            }}}
3561        }"#;
3562        let op = &parse_str(src).unwrap().spec.unwrap().operations[0];
3563        assert!(op.deprecated);
3564    }
3565
3566    /// Webhooks carry PathItem-level `summary` / `description` per OAS
3567    /// §4.9. Both fields populate independently.
3568    #[test]
3569    fn webhook_path_item_summary_and_description_populate() {
3570        let src = r#"{
3571            "openapi":"3.2.0",
3572            "info":{"title":"t","version":"1"},
3573            "paths":{},
3574            "webhooks":{
3575                "newPet":{
3576                    "summary":"hook sum",
3577                    "description":"hook desc",
3578                    "post":{"operationId":"onNewPet","responses":{"200":{"description":"ok"}}}
3579                }
3580            }
3581        }"#;
3582        let ir = parse_str(src).unwrap().spec.unwrap();
3583        let webhook = ir.webhooks.iter().find(|w| w.name == "newPet").unwrap();
3584        assert_eq!(webhook.summary.as_deref(), Some("hook sum"));
3585        assert_eq!(webhook.description.as_deref(), Some("hook desc"));
3586    }
3587
3588    /// OAS 3.2 added `summary` to Response Object. Verify it populates
3589    /// independently from `description` (which was already there).
3590    #[test]
3591    fn response_summary_3_2_populates() {
3592        let src = r#"{
3593            "openapi":"3.2.0",
3594            "info":{"title":"t","version":"1"},
3595            "paths":{"/x":{"get":{
3596                "operationId":"x",
3597                "responses":{"200":{
3598                    "summary":"short label",
3599                    "description":"long form"
3600                }}
3601            }}}
3602        }"#;
3603        let resp = &parse_str(src).unwrap().spec.unwrap().operations[0].responses[0];
3604        assert_eq!(resp.summary.as_deref(), Some("short label"));
3605        assert_eq!(resp.description.as_deref(), Some("long form"));
3606    }
3607
3608    /// `Property.title` populates from each schema property's JSON
3609    /// Schema `title` field.
3610    #[test]
3611    fn property_title_populates() {
3612        let src = r##"{
3613            "openapi":"3.2.0",
3614            "info":{"title":"t","version":"1"},
3615            "paths":{},
3616            "components":{"schemas":{
3617                "User":{
3618                    "type":"object",
3619                    "properties":{
3620                        "id":{"type":"string","title":"User ID"}
3621                    }
3622                }
3623            }}
3624        }"##;
3625        let ir = parse_str(src).unwrap().spec.unwrap();
3626        let user = ir.types.iter().find(|t| t.id == "User").unwrap();
3627        let forge_ir::TypeDef::Object(obj) = &user.definition else {
3628            panic!("expected object");
3629        };
3630        let id_prop = obj.properties.iter().find(|p| p.name == "id").unwrap();
3631        assert_eq!(id_prop.title.as_deref(), Some("User ID"));
3632    }
3633
3634    /// `Property.external_docs` populates from the property schema's
3635    /// `externalDocs` block.
3636    #[test]
3637    fn property_external_docs_populates() {
3638        let src = r##"{
3639            "openapi":"3.2.0",
3640            "info":{"title":"t","version":"1"},
3641            "paths":{},
3642            "components":{"schemas":{
3643                "User":{
3644                    "type":"object",
3645                    "properties":{
3646                        "id":{
3647                            "type":"string",
3648                            "externalDocs":{"url":"https://example.com","description":"d"}
3649                        }
3650                    }
3651                }
3652            }}
3653        }"##;
3654        let ir = parse_str(src).unwrap().spec.unwrap();
3655        let user = ir.types.iter().find(|t| t.id == "User").unwrap();
3656        let forge_ir::TypeDef::Object(obj) = &user.definition else {
3657            panic!("expected object");
3658        };
3659        let id_prop = obj.properties.iter().find(|p| p.name == "id").unwrap();
3660        let ed = id_prop.external_docs.as_ref().unwrap();
3661        assert_eq!(ed.url, "https://example.com");
3662        assert_eq!(ed.description.as_deref(), Some("d"));
3663    }
3664
3665    /// `Property.examples` populates from per-property schema-level
3666    /// `example` (3.0 form, stored under `_default`).
3667    #[test]
3668    fn property_examples_populates() {
3669        let src = r##"{
3670            "openapi":"3.2.0",
3671            "info":{"title":"t","version":"1"},
3672            "paths":{},
3673            "components":{"schemas":{
3674                "User":{
3675                    "type":"object",
3676                    "properties":{
3677                        "id":{"type":"string","example":"42"}
3678                    }
3679                }
3680            }}
3681        }"##;
3682        let ir = parse_str(src).unwrap().spec.unwrap();
3683        let user = ir.types.iter().find(|t| t.id == "User").unwrap();
3684        let forge_ir::TypeDef::Object(obj) = &user.definition else {
3685            panic!("expected object");
3686        };
3687        let id_prop = obj.properties.iter().find(|p| p.name == "id").unwrap();
3688        assert_eq!(id_prop.examples.len(), 1);
3689        assert_eq!(id_prop.examples[0].0, "_default");
3690    }
3691
3692    /// `Property.deprecated` populates from JSON Schema 2020-12
3693    /// `deprecated`.
3694    #[test]
3695    fn property_deprecated_populates() {
3696        let src = r##"{
3697            "openapi":"3.2.0",
3698            "info":{"title":"t","version":"1"},
3699            "paths":{},
3700            "components":{"schemas":{
3701                "User":{
3702                    "type":"object",
3703                    "properties":{
3704                        "id":{"type":"string","deprecated":true}
3705                    }
3706                }
3707            }}
3708        }"##;
3709        let ir = parse_str(src).unwrap().spec.unwrap();
3710        let user = ir.types.iter().find(|t| t.id == "User").unwrap();
3711        let forge_ir::TypeDef::Object(obj) = &user.definition else {
3712            panic!("expected object");
3713        };
3714        let id_prop = obj.properties.iter().find(|p| p.name == "id").unwrap();
3715        assert!(id_prop.deprecated);
3716    }
3717
3718    /// `NamedType.deprecated` populates from JSON Schema 2020-12
3719    /// `deprecated` at the schema level.
3720    #[test]
3721    fn named_type_deprecated_populates() {
3722        let src = r##"{
3723            "openapi":"3.2.0",
3724            "info":{"title":"t","version":"1"},
3725            "paths":{},
3726            "components":{"schemas":{
3727                "Legacy":{"type":"string","deprecated":true}
3728            }}
3729        }"##;
3730        let ir = parse_str(src).unwrap().spec.unwrap();
3731        let legacy = ir.types.iter().find(|t| t.id == "Legacy").unwrap();
3732        assert!(legacy.deprecated);
3733    }
3734
3735    /// OAS 3.2 §4.27: SecurityScheme adds `deprecated`. Verify it
3736    /// populates.
3737    #[test]
3738    fn security_scheme_deprecated_3_2_populates() {
3739        let src = r##"{
3740            "openapi":"3.2.0",
3741            "info":{"title":"t","version":"1"},
3742            "paths":{},
3743            "components":{"securitySchemes":{
3744                "Old":{
3745                    "type":"http",
3746                    "scheme":"basic",
3747                    "description":"do not use",
3748                    "deprecated":true
3749                }
3750            }}
3751        }"##;
3752        let ir = parse_str(src).unwrap().spec.unwrap();
3753        let scheme = ir.security_schemes.iter().find(|s| s.id == "Old").unwrap();
3754        assert!(scheme.deprecated);
3755        assert_eq!(scheme.description.as_deref(), Some("do not use"));
3756    }
3757
3758    /// `NamedType.title` populates separately from `description`.
3759    /// JSON Schema treats title as a short human label.
3760    #[test]
3761    fn named_type_title_populates() {
3762        let src = r##"{
3763            "openapi":"3.2.0",
3764            "info":{"title":"t","version":"1"},
3765            "paths":{},
3766            "components":{"schemas":{
3767                "Foo":{"type":"string","title":"Foo Type","description":"long"}
3768            }}
3769        }"##;
3770        let ir = parse_str(src).unwrap().spec.unwrap();
3771        let foo = ir.types.iter().find(|t| t.id == "Foo").unwrap();
3772        assert_eq!(foo.title.as_deref(), Some("Foo Type"));
3773        assert_eq!(foo.description.as_deref(), Some("long"));
3774    }
3775
3776    #[test]
3777    fn primitive_kind_carries_only_jsonschema_type_values() {
3778        // #105: PrimitiveKind is exactly the JSON Schema `type`
3779        // keyword's leaf values. All format refinements land in
3780        // `format_extension` verbatim — including ones the IR used
3781        // to fold into rich kinds (`int32`, `date`, `email`, `byte`).
3782        use forge_ir::{PrimitiveKind, TypeDef};
3783        let src = r#"{
3784            "openapi":"3.0.3",
3785            "info":{"title":"t","version":"1"},
3786            "paths":{},
3787            "components":{"schemas":{
3788                "Plain":      {"type":"string"},
3789                "Stamp":      {"type":"string","format":"date-time"},
3790                "Mail":       {"type":"string","format":"email"},
3791                "Avatar":     {"type":"string","format":"byte"},
3792                "Tally":      {"type":"integer","format":"int32"},
3793                "Big":        {"type":"integer","format":"int64"},
3794                "Money":      {"type":"string","format":"decimal"},
3795                "Flag":       {"type":"boolean"}
3796            }}
3797        }"#;
3798        let ir = parse_str(src).unwrap().spec.unwrap();
3799        let prim = |id: &str| -> (PrimitiveKind, Option<String>) {
3800            let nt = ir.types.iter().find(|t| t.id == id).unwrap();
3801            let TypeDef::Primitive(p) = &nt.definition else {
3802                panic!("{id} not primitive");
3803            };
3804            (p.kind, p.constraints.format_extension.clone())
3805        };
3806        assert_eq!(prim("Plain"), (PrimitiveKind::String, None));
3807        assert_eq!(
3808            prim("Stamp"),
3809            (PrimitiveKind::String, Some("date-time".into()))
3810        );
3811        assert_eq!(prim("Mail"), (PrimitiveKind::String, Some("email".into())));
3812        assert_eq!(prim("Avatar"), (PrimitiveKind::String, Some("byte".into())));
3813        assert_eq!(
3814            prim("Tally"),
3815            (PrimitiveKind::Integer, Some("int32".into()))
3816        );
3817        assert_eq!(prim("Big"), (PrimitiveKind::Integer, Some("int64".into())));
3818        assert_eq!(
3819            prim("Money"),
3820            (PrimitiveKind::String, Some("decimal".into()))
3821        );
3822        assert_eq!(prim("Flag"), (PrimitiveKind::Bool, None));
3823    }
3824}