Skip to main content

forge_parser/
lib.rs

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