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