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 schema_compound_default_survives_via_value_pool() {
2121        let src = r#"{
2122            "openapi":"3.0.3",
2123            "info":{"title":"t","version":"1"},
2124            "paths":{},
2125            "components":{
2126                "schemas":{
2127                    "Cfg":{"type":"object","default":{"k":"v"}}
2128                }
2129            }
2130        }"#;
2131        let out = parse_str(src).unwrap();
2132        let ir = out.spec.unwrap();
2133        let cfg = ir.types.iter().find(|t| t.id == "Cfg").unwrap();
2134        let r = cfg.default.unwrap() as usize;
2135        let forge_ir::Value::Object { fields } = &ir.values[r] else {
2136            panic!("expected object default");
2137        };
2138        assert_eq!(fields.len(), 1);
2139        assert_eq!(fields[0].0, "k");
2140        assert_eq!(ir.values[fields[0].1 as usize], forge_ir::Value::s("v"));
2141    }
2142
2143    #[test]
2144    fn tags_walk_into_structured_records() {
2145        let src = r#"{
2146            "openapi":"3.2.0",
2147            "info":{"title":"t","version":"1"},
2148            "tags":[
2149                {
2150                    "name":"pets",
2151                    "summary":"S",
2152                    "description":"D",
2153                    "kind":"audience",
2154                    "externalDocs":{"url":"https://example.com"}
2155                },
2156                {"name":"cats","parent":"pets"}
2157            ],
2158            "paths":{}
2159        }"#;
2160        let ir = parse_str(src).unwrap().spec.unwrap();
2161        // Sorted by name for determinism — cats before pets.
2162        assert_eq!(ir.tags[0].name, "cats");
2163        assert_eq!(ir.tags[0].parent.as_deref(), Some("pets"));
2164        assert_eq!(ir.tags[1].name, "pets");
2165        assert_eq!(ir.tags[1].summary.as_deref(), Some("S"));
2166        assert_eq!(ir.tags[1].description.as_deref(), Some("D"));
2167        assert_eq!(ir.tags[1].kind.as_deref(), Some("audience"));
2168        assert_eq!(
2169            ir.tags[1].external_docs.as_ref().unwrap().url,
2170            "https://example.com"
2171        );
2172    }
2173
2174    #[test]
2175    fn tag_parent_dangling_drops_ref_keeps_tag() {
2176        let src = r#"{
2177            "openapi":"3.2.0",
2178            "info":{"title":"t","version":"1"},
2179            "tags":[
2180                {"name":"cats","parent":"no-such-tag"}
2181            ],
2182            "paths":{}
2183        }"#;
2184        let out = parse_str(src).unwrap();
2185        let ir = out.spec.unwrap();
2186        assert_eq!(ir.tags.len(), 1);
2187        assert_eq!(ir.tags[0].name, "cats");
2188        // Parent reference dropped; the tag itself survives.
2189        assert!(ir.tags[0].parent.is_none());
2190        assert!(out
2191            .diagnostics
2192            .iter()
2193            .any(|d| d.code == diag::W_TAG_PARENT_DANGLING));
2194    }
2195
2196    #[test]
2197    fn tags_extensions_round_trip() {
2198        let src = r#"{
2199            "openapi":"3.0.3",
2200            "info":{"title":"t","version":"1"},
2201            "tags":[
2202                {"name":"pets","x-priority":5}
2203            ],
2204            "paths":{}
2205        }"#;
2206        let ir = parse_str(src).unwrap().spec.unwrap();
2207        let ext = &ir.tags[0].extensions;
2208        assert_eq!(ext.len(), 1);
2209        assert_eq!(ext[0].0, "x-priority");
2210    }
2211
2212    #[test]
2213    fn operation_servers_resolution_picks_most_specific() {
2214        let src = r#"{
2215            "openapi":"3.0.3",
2216            "info":{"title":"t","version":"1"},
2217            "servers":[{"url":"https://root"}],
2218            "paths":{
2219                "/a":{
2220                    "get":{"operationId":"opA","responses":{"204":{"description":"ok"}}}
2221                },
2222                "/b":{
2223                    "servers":[{"url":"https://path-b"}],
2224                    "get":{"operationId":"opB","responses":{"204":{"description":"ok"}}},
2225                    "post":{
2226                        "operationId":"opC",
2227                        "servers":[{"url":"https://op-c"}],
2228                        "responses":{"204":{"description":"ok"}}
2229                    }
2230                }
2231            }
2232        }"#;
2233        let ir = parse_str(src).unwrap().spec.unwrap();
2234        let by_id = |id: &str| {
2235            ir.operations
2236                .iter()
2237                .find(|o| o.id == id)
2238                .unwrap_or_else(|| panic!("operation {id} not found"))
2239        };
2240        assert_eq!(by_id("opA").servers[0].url, "https://root");
2241        assert_eq!(by_id("opB").servers[0].url, "https://path-b");
2242        assert_eq!(by_id("opC").servers[0].url, "https://op-c");
2243    }
2244
2245    #[test]
2246    fn operation_servers_empty_when_no_root_or_overrides() {
2247        // No `servers` anywhere — operation list stays empty rather than
2248        // synthesising a default URL.
2249        let src = r#"{
2250            "openapi":"3.0.3",
2251            "info":{"title":"t","version":"1"},
2252            "paths":{
2253                "/x":{"get":{"operationId":"x","responses":{"204":{"description":"ok"}}}}
2254            }
2255        }"#;
2256        let ir = parse_str(src).unwrap().spec.unwrap();
2257        assert!(ir.operations[0].servers.is_empty());
2258        assert!(ir.servers.is_empty());
2259    }
2260
2261    #[test]
2262    fn operation_servers_explicit_empty_array_falls_through_to_root() {
2263        // OAS doesn't define semantics for an empty `servers: []` on an
2264        // operation. We treat it as "no override" and inherit the root —
2265        // matches the empty-vs-absent distinction we already do for
2266        // `security`. Document the choice in the test.
2267        let src = r#"{
2268            "openapi":"3.0.3",
2269            "info":{"title":"t","version":"1"},
2270            "servers":[{"url":"https://root"}],
2271            "paths":{
2272                "/x":{"get":{
2273                    "operationId":"x",
2274                    "servers":[],
2275                    "responses":{"204":{"description":"ok"}}
2276                }}
2277            }
2278        }"#;
2279        let ir = parse_str(src).unwrap().spec.unwrap();
2280        assert_eq!(ir.operations[0].servers[0].url, "https://root");
2281    }
2282
2283    #[test]
2284    fn external_docs_absent_leaves_field_none() {
2285        let src = r#"{
2286            "openapi":"3.0.3",
2287            "info":{"title":"t","version":"1"},
2288            "paths":{}
2289        }"#;
2290        let ir = parse_str(src).unwrap().spec.unwrap();
2291        assert!(ir.external_docs.is_none());
2292    }
2293
2294    #[test]
2295    fn info_license_name_only_round_trips() {
2296        // 3.0 specs commonly carry `license.name` with no `identifier`;
2297        // it must not be lost.
2298        let src = r#"{
2299            "openapi":"3.0.0",
2300            "info":{
2301                "title":"t",
2302                "version":"1",
2303                "license":{"name":"MIT"}
2304            },
2305            "paths":{}
2306        }"#;
2307        let ir = parse_str(src).unwrap().spec.unwrap();
2308        assert_eq!(ir.info.license_name.as_deref(), Some("MIT"));
2309        assert!(ir.info.license_url.is_none());
2310        assert!(ir.info.license_identifier.is_none());
2311    }
2312
2313    #[test]
2314    fn extensions_populate_on_every_specification_object() {
2315        use forge_ir::{SecuritySchemeKind, TypeDef};
2316        // Issue #112. One spec exercising every site that gained an
2317        // `extensions` field: info, server, server-variable, schema /
2318        // property, parameter, request body / body content / encoding,
2319        // response, security scheme, oauth2 flow.
2320        let src = r##"{
2321            "openapi":"3.0.3",
2322            "info":{
2323                "title":"t",
2324                "version":"1",
2325                "x-info":"info-ext"
2326            },
2327            "servers":[{
2328                "url":"https://api.example.com/{tier}",
2329                "x-server":"server-ext",
2330                "variables":{
2331                    "tier":{
2332                        "default":"v1",
2333                        "x-var":"var-ext"
2334                    }
2335                }
2336            }],
2337            "paths":{
2338                "/things":{
2339                    "post":{
2340                        "operationId":"create",
2341                        "parameters":[{
2342                            "name":"q",
2343                            "in":"query",
2344                            "schema":{"type":"string"},
2345                            "x-param":"param-ext"
2346                        }],
2347                        "requestBody":{
2348                            "x-body":"body-ext",
2349                            "content":{
2350                                "multipart/form-data":{
2351                                    "x-content":"content-ext",
2352                                    "schema":{"$ref":"#/components/schemas/Thing"},
2353                                    "encoding":{
2354                                        "name":{
2355                                            "contentType":"text/plain",
2356                                            "x-encoding":"encoding-ext"
2357                                        }
2358                                    }
2359                                }
2360                            }
2361                        },
2362                        "responses":{
2363                            "200":{
2364                                "description":"ok",
2365                                "x-response":"response-ext"
2366                            }
2367                        }
2368                    }
2369                }
2370            },
2371            "components":{
2372                "schemas":{
2373                    "Thing":{
2374                        "type":"object",
2375                        "x-schema":"schema-ext",
2376                        "properties":{
2377                            "name":{
2378                                "type":"string",
2379                                "x-prop":"prop-ext"
2380                            }
2381                        }
2382                    }
2383                },
2384                "securitySchemes":{
2385                    "OAuth":{
2386                        "type":"oauth2",
2387                        "x-scheme":"scheme-ext",
2388                        "flows":{
2389                            "authorizationCode":{
2390                                "authorizationUrl":"https://a",
2391                                "tokenUrl":"https://t",
2392                                "scopes":{},
2393                                "x-flow":"flow-ext"
2394                            }
2395                        }
2396                    }
2397                }
2398            }
2399        }"##;
2400        let ir = parse_str(src).unwrap().spec.unwrap();
2401
2402        // info
2403        let exts = &ir.info.extensions;
2404        assert!(
2405            exts.iter().any(|(k, _)| k == "x-info"),
2406            "info.extensions missing x-info: {exts:?}"
2407        );
2408
2409        // server + server-variable
2410        let server = &ir.servers[0];
2411        assert!(server.extensions.iter().any(|(k, _)| k == "x-server"));
2412        let (_var_name, var) = &server.variables[0];
2413        assert!(var.extensions.iter().any(|(k, _)| k == "x-var"));
2414
2415        // schema (NamedType) + property
2416        let thing = ir.types.iter().find(|t| t.id == "Thing").unwrap();
2417        assert!(thing.extensions.iter().any(|(k, _)| k == "x-schema"));
2418        let TypeDef::Object(obj) = &thing.definition else {
2419            panic!("expected object")
2420        };
2421        let name_prop = obj.properties.iter().find(|p| p.name == "name").unwrap();
2422        assert!(name_prop.extensions.iter().any(|(k, _)| k == "x-prop"));
2423
2424        // operation pieces
2425        let op = &ir.operations[0];
2426        let p = &op.query_params[0];
2427        assert!(p.extensions.iter().any(|(k, _)| k == "x-param"));
2428        let body = op.request_body.as_ref().unwrap();
2429        assert!(body.extensions.iter().any(|(k, _)| k == "x-body"));
2430        let content = &body.content[0];
2431        assert!(content.extensions.iter().any(|(k, _)| k == "x-content"));
2432        let (_enc_name, enc) = &content.encoding[0];
2433        assert!(enc.extensions.iter().any(|(k, _)| k == "x-encoding"));
2434        let resp = &op.responses[0];
2435        assert!(resp.extensions.iter().any(|(k, _)| k == "x-response"));
2436
2437        // security scheme + oauth2 flow
2438        let scheme = ir
2439            .security_schemes
2440            .iter()
2441            .find(|s| s.id == "OAuth")
2442            .unwrap();
2443        assert!(scheme.extensions.iter().any(|(k, _)| k == "x-scheme"));
2444        let SecuritySchemeKind::Oauth2(o) = &scheme.kind else {
2445            panic!("expected oauth2");
2446        };
2447        let flow = &o.flows[0];
2448        assert!(flow.extensions.iter().any(|(k, _)| k == "x-flow"));
2449    }
2450
2451    #[test]
2452    fn compound_extensions_survive_via_value_pool() {
2453        // List / object `x-*` values now survive the WIT boundary via the
2454        // value pool (ADR-0007 amendment). The parser interns the array
2455        // into the pool and `info.extensions` references it by `ValueRef`.
2456        let src = r#"{
2457            "openapi":"3.0.3",
2458            "info":{
2459                "title":"t",
2460                "version":"1",
2461                "x-array":[1,2,3]
2462            },
2463            "paths":{}
2464        }"#;
2465        let ir = parse_str(src).unwrap().spec.unwrap();
2466        let entry = ir
2467            .info
2468            .extensions
2469            .iter()
2470            .find(|(k, _)| k == "x-array")
2471            .expect("x-array extension survives");
2472        let r = entry.1 as usize;
2473        let forge_ir::Value::List { items } = &ir.values[r] else {
2474            panic!("expected list, got {:?}", ir.values[r]);
2475        };
2476        assert_eq!(items.len(), 3);
2477    }
2478
2479    #[test]
2480    fn server_name_3_2_round_trips() {
2481        // OAS 3.2 added `Server.name` as a short label distinct from
2482        // `description`. Capture it verbatim.
2483        let src = r#"{
2484            "openapi":"3.2.0",
2485            "info":{"title":"t","version":"1"},
2486            "servers":[
2487                {"url":"https://api.example.com","name":"production"},
2488                {"url":"https://staging.example.com"}
2489            ],
2490            "paths":{}
2491        }"#;
2492        let ir = parse_str(src).unwrap().spec.unwrap();
2493        assert_eq!(ir.servers[0].name.as_deref(), Some("production"));
2494        assert!(ir.servers[1].name.is_none());
2495    }
2496
2497    #[test]
2498    fn parameter_querystring_3_2_routes_to_new_bucket() {
2499        // OAS 3.2 added `in: querystring`. Should land on the new
2500        // `Operation.querystring_params` slot, not the regular
2501        // `query_params`.
2502        let src = r#"{
2503            "openapi":"3.2.0",
2504            "info":{"title":"t","version":"1"},
2505            "paths":{
2506                "/search":{"get":{
2507                    "operationId":"search",
2508                    "parameters":[
2509                        {"name":"raw","in":"querystring","schema":{"type":"string"}}
2510                    ],
2511                    "responses":{"200":{"description":"ok"}}
2512                }}
2513            }
2514        }"#;
2515        let ir = parse_str(src).unwrap().spec.unwrap();
2516        let op = &ir.operations[0];
2517        assert!(op.query_params.is_empty(), "must not land in query_params");
2518        assert_eq!(op.querystring_params.len(), 1);
2519        assert_eq!(op.querystring_params[0].name, "raw");
2520    }
2521
2522    #[test]
2523    fn example_data_value_serialized_value_3_2() {
2524        // OAS 3.2 split `value` into `dataValue` (parsed) and
2525        // `serializedValue` (wire form). Both must round-trip.
2526        let src = r##"{
2527            "openapi":"3.2.0",
2528            "info":{"title":"t","version":"1"},
2529            "paths":{},
2530            "components":{
2531                "schemas":{
2532                    "Thing":{
2533                        "type":"string",
2534                        "examples":[
2535                            {"summary":"alice","dataValue":"alice","serializedValue":"\"alice\""}
2536                        ]
2537                    }
2538                }
2539            }
2540        }"##;
2541        // Use parameter-level examples since schema-level `examples` array (plural)
2542        // is in a different code path; here exercise the parameter / media-type path.
2543        let src2 = r##"{
2544            "openapi":"3.2.0",
2545            "info":{"title":"t","version":"1"},
2546            "paths":{
2547                "/thing":{"post":{
2548                    "operationId":"create",
2549                    "requestBody":{"content":{"application/json":{
2550                        "schema":{"type":"string"},
2551                        "examples":{
2552                            "alice":{"dataValue":"alice","serializedValue":"\"alice\""}
2553                        }
2554                    }}},
2555                    "responses":{"200":{"description":"ok"}}
2556                }}
2557            }
2558        }"##;
2559        let _ = src; // schema-level examples (plural) handled separately; smoke-load
2560        let ir = parse_str(src2).unwrap().spec.unwrap();
2561        let body = ir.operations[0].request_body.as_ref().unwrap();
2562        let example = &body.content[0].examples[0].1;
2563        let r = example.data_value.unwrap() as usize;
2564        assert_eq!(ir.values[r], forge_ir::Value::s("alice"));
2565        assert_eq!(example.serialized_value.as_deref(), Some("\"alice\""));
2566    }
2567
2568    #[test]
2569    fn xml_text_ordered_3_2() {
2570        // OAS 3.2 added `text` and `ordered` flags to the XML Object.
2571        let src = r#"{
2572            "openapi":"3.2.0",
2573            "info":{"title":"t","version":"1"},
2574            "paths":{},
2575            "components":{
2576                "schemas":{
2577                    "Title":{"type":"string","xml":{"text":true}},
2578                    "Steps":{"type":"array","items":{"type":"string"},"xml":{"wrapped":true,"ordered":true}}
2579                }
2580            }
2581        }"#;
2582        let ir = parse_str(src).unwrap().spec.unwrap();
2583        let title = ir.types.iter().find(|t| t.id == "Title").unwrap();
2584        let title_xml = title.xml.as_ref().unwrap();
2585        assert!(title_xml.text);
2586        assert!(!title_xml.ordered);
2587
2588        let steps = ir.types.iter().find(|t| t.id == "Steps").unwrap();
2589        let steps_xml = steps.xml.as_ref().unwrap();
2590        assert!(steps_xml.ordered);
2591        assert!(!steps_xml.text);
2592    }
2593
2594    #[test]
2595    fn mutual_tls_security_scheme_round_trips() {
2596        use forge_ir::SecuritySchemeKind;
2597        let src = r#"{
2598            "openapi":"3.0.3",
2599            "info":{"title":"t","version":"1"},
2600            "paths":{},
2601            "components":{"securitySchemes":{
2602                "mtls":{"type":"mutualTLS","description":"client-cert auth"}
2603            }}
2604        }"#;
2605        let ir = parse_str(src).unwrap().spec.unwrap();
2606        let scheme = &ir.security_schemes[0];
2607        assert_eq!(scheme.id, "mtls");
2608        assert!(matches!(scheme.kind, SecuritySchemeKind::MutualTls));
2609        assert_eq!(scheme.description.as_deref(), Some("client-cert auth"));
2610    }
2611
2612    #[test]
2613    fn oauth2_all_four_flows_succeed() {
2614        use forge_ir::{OAuth2FlowKind, SecuritySchemeKind};
2615        let src = r#"{
2616            "openapi":"3.0.3",
2617            "info":{"title":"t","version":"1"},
2618            "paths":{},
2619            "components":{"securitySchemes":{
2620                "auth":{"type":"oauth2","flows":{
2621                    "implicit":{
2622                        "authorizationUrl":"https://a/auth",
2623                        "scopes":{"read":"r"}
2624                    },
2625                    "password":{
2626                        "tokenUrl":"https://a/token",
2627                        "scopes":{"read":"r"}
2628                    },
2629                    "clientCredentials":{
2630                        "tokenUrl":"https://a/token",
2631                        "scopes":{"read":"r"}
2632                    },
2633                    "authorizationCode":{
2634                        "authorizationUrl":"https://a/auth",
2635                        "tokenUrl":"https://a/token",
2636                        "scopes":{"read":"r"}
2637                    }
2638                }}
2639            }}
2640        }"#;
2641        let ir = parse_str(src).unwrap().spec.unwrap();
2642        let scheme = &ir.security_schemes[0];
2643        let SecuritySchemeKind::Oauth2(o) = &scheme.kind else {
2644            panic!("expected oauth2 kind");
2645        };
2646        assert_eq!(o.flows.len(), 4, "all four flows surface");
2647        let kinds: Vec<OAuth2FlowKind> = o.flows.iter().map(|f| f.kind).collect();
2648        assert!(kinds.contains(&OAuth2FlowKind::Implicit));
2649        assert!(kinds.contains(&OAuth2FlowKind::Password));
2650        assert!(kinds.contains(&OAuth2FlowKind::ClientCredentials));
2651        assert!(kinds.contains(&OAuth2FlowKind::AuthorizationCode));
2652    }
2653
2654    #[test]
2655    fn oauth2_missing_required_url_errors() {
2656        // `password` flow requires `tokenUrl`; absence emits
2657        // `parser/E-OAUTH2-MISSING-URL`.
2658        let src = r#"{
2659            "openapi":"3.0.3",
2660            "info":{"title":"t","version":"1"},
2661            "paths":{},
2662            "components":{"securitySchemes":{
2663                "auth":{"type":"oauth2","flows":{
2664                    "password":{"scopes":{"read":"r"}}
2665                }}
2666            }}
2667        }"#;
2668        let out = parse_str(src).unwrap();
2669        assert!(
2670            out.diagnostics
2671                .iter()
2672                .any(|d| d.code == diag::E_OAUTH2_MISSING_URL),
2673            "expected E-OAUTH2-MISSING-URL"
2674        );
2675    }
2676
2677    #[test]
2678    fn content_encoding_keywords_round_trip() {
2679        // JSON Schema 2020-12 / OAS 3.2 contentEncoding +
2680        // contentMediaType + contentSchema land on
2681        // PrimitiveConstraints. contentSchema's body lifts into the
2682        // type pool under a `<owner>_content_schema` id.
2683        use forge_ir::TypeDef;
2684        let src = r#"{
2685            "openapi":"3.2.0",
2686            "info":{"title":"t","version":"1"},
2687            "paths":{},
2688            "components":{"schemas":{
2689                "Avatar":{
2690                    "type":"string",
2691                    "contentEncoding":"base64",
2692                    "contentMediaType":"image/png"
2693                },
2694                "Embedded":{
2695                    "type":"string",
2696                    "contentMediaType":"application/json",
2697                    "contentSchema":{
2698                        "type":"object",
2699                        "properties":{"id":{"type":"string"}}
2700                    }
2701                }
2702            }}
2703        }"#;
2704        let ir = parse_str(src).unwrap().spec.unwrap();
2705
2706        let avatar = ir.types.iter().find(|t| t.id == "Avatar").unwrap();
2707        let TypeDef::Primitive(p) = &avatar.definition else {
2708            panic!("expected primitive");
2709        };
2710        assert_eq!(p.constraints.content_encoding.as_deref(), Some("base64"));
2711        assert_eq!(
2712            p.constraints.content_media_type.as_deref(),
2713            Some("image/png")
2714        );
2715        assert!(p.constraints.content_schema.is_none());
2716
2717        let embedded = ir.types.iter().find(|t| t.id == "Embedded").unwrap();
2718        let TypeDef::Primitive(p) = &embedded.definition else {
2719            panic!("expected primitive");
2720        };
2721        assert_eq!(
2722            p.constraints.content_media_type.as_deref(),
2723            Some("application/json")
2724        );
2725        let cs_ref = p
2726            .constraints
2727            .content_schema
2728            .as_deref()
2729            .expect("content_schema set");
2730        // The decoded payload's type must be reachable in the type pool.
2731        assert!(ir.types.iter().any(|t| t.id == cs_ref));
2732    }
2733
2734    #[test]
2735    fn components_media_types_pool_resolves_refs() {
2736        // OAS 3.2 `components.mediaTypes` — `$ref` from `requestBody.content.<media>`
2737        // and `response.content.<media>` resolves through the pool.
2738        // Unused entries warn with `parser/W-COMPONENT-MEDIA-TYPE-UNUSED`.
2739        let src = r##"{
2740            "openapi":"3.2.0",
2741            "info":{"title":"t","version":"1"},
2742            "paths":{
2743                "/things":{"post":{
2744                    "operationId":"create",
2745                    "requestBody":{"content":{
2746                        "application/json":{"$ref":"#/components/mediaTypes/ThingJson"}
2747                    }},
2748                    "responses":{"204":{"description":"ok"}}
2749                }}
2750            },
2751            "components":{
2752                "schemas":{
2753                    "Thing":{"type":"object","properties":{"id":{"type":"string"}}}
2754                },
2755                "mediaTypes":{
2756                    "ThingJson":{"schema":{"$ref":"#/components/schemas/Thing"}},
2757                    "Unused":{"schema":{"type":"string"}}
2758                }
2759            }
2760        }"##;
2761        let out = parse_str(src).unwrap();
2762        let ir = out.spec.unwrap();
2763        let body = ir.operations[0].request_body.as_ref().unwrap();
2764        // The ref should resolve and the type should point to Thing.
2765        assert_eq!(body.content[0].r#type, "Thing");
2766        // Unused entry warns.
2767        assert!(
2768            out.diagnostics
2769                .iter()
2770                .any(|d| d.code == diag::W_COMPONENT_MEDIA_TYPE_UNUSED
2771                    && d.message.contains("Unused")),
2772            "expected W-COMPONENT-MEDIA-TYPE-UNUSED for `Unused`"
2773        );
2774        // ThingJson was referenced — must NOT warn.
2775        assert!(
2776            !out.diagnostics
2777                .iter()
2778                .any(|d| d.code == diag::W_COMPONENT_MEDIA_TYPE_UNUSED
2779                    && d.message.contains("ThingJson")),
2780            "ThingJson is referenced; should not warn"
2781        );
2782    }
2783
2784    #[test]
2785    fn json_schema_deferred_keywords_warn_not_error() {
2786        // #144: deferred 2020-12 keywords (dependentRequired,
2787        // dependentSchemas, unevaluatedProperties, $dynamicRef,
2788        // $dynamicAnchor) used to error and reject the whole spec.
2789        // Now they warn and the rest of the schema parses.
2790        let src = r#"{
2791            "openapi":"3.1.0",
2792            "info":{"title":"t","version":"1"},
2793            "paths":{},
2794            "components":{"schemas":{
2795                "Bad":{
2796                    "type":"object",
2797                    "dependentRequired":{"a":["b"]},
2798                    "unevaluatedProperties":false,
2799                    "properties":{"id":{"type":"string"}}
2800                }
2801            }}
2802        }"#;
2803        let out = parse_str(src).unwrap();
2804        let ir = out.spec.expect("spec parses despite deferred keywords");
2805        // The schema landed in the type pool — `Bad` exists with its
2806        // declared `id` property.
2807        assert!(ir.types.iter().any(|t| t.id == "Bad"));
2808        // Both deferred keywords surfaced as warnings, not errors.
2809        let warns: Vec<&str> = out
2810            .diagnostics
2811            .iter()
2812            .filter(|d| d.severity == forge_ir::Severity::Warning)
2813            .map(|d| d.code.as_str())
2814            .collect();
2815        assert!(
2816            warns.contains(&diag::W_DEPENDENT_REQUIRED_DROPPED),
2817            "expected W-DEPENDENT-REQUIRED-DROPPED, got {warns:?}"
2818        );
2819        assert!(
2820            warns.contains(&diag::W_UNEVALUATED_PROPERTIES_DROPPED),
2821            "expected W-UNEVALUATED-PROPERTIES-DROPPED, got {warns:?}"
2822        );
2823        // No errors — these used to reject the spec entirely.
2824        let errs: Vec<&str> = out
2825            .diagnostics
2826            .iter()
2827            .filter(|d| d.severity == forge_ir::Severity::Error)
2828            .map(|d| d.code.as_str())
2829            .collect();
2830        assert!(errs.is_empty(), "no errors expected, got {errs:?}");
2831    }
2832
2833    #[test]
2834    fn root_json_schema_dialect_and_self_round_trip() {
2835        // #143, #147. Capture jsonSchemaDialect and $self verbatim.
2836        let src = r##"{
2837            "openapi":"3.2.0",
2838            "$self":"https://example.com/api.json",
2839            "jsonSchemaDialect":"https://json-schema.org/draft/2020-12/schema",
2840            "info":{"title":"t","version":"1"},
2841            "paths":{}
2842        }"##;
2843        let ir = parse_str(src).unwrap().spec.unwrap();
2844        assert_eq!(
2845            ir.json_schema_dialect.as_deref(),
2846            Some("https://json-schema.org/draft/2020-12/schema")
2847        );
2848        assert_eq!(ir.self_url.as_deref(), Some("https://example.com/api.json"));
2849    }
2850
2851    #[test]
2852    fn header_style_explode_round_trip() {
2853        // #145. Header object's serialization fields populate the IR
2854        // even though the spec fixes style to `simple`.
2855        use forge_ir::ParameterStyle;
2856        let src = r#"{
2857            "openapi":"3.0.3",
2858            "info":{"title":"t","version":"1"},
2859            "paths":{"/x":{"get":{
2860                "operationId":"x",
2861                "responses":{"200":{
2862                    "description":"ok",
2863                    "headers":{
2864                        "X-Rate":{
2865                            "schema":{"type":"integer"},
2866                            "style":"simple",
2867                            "explode":true,
2868                            "allowReserved":false,
2869                            "allowEmptyValue":false
2870                        }
2871                    }
2872                }}
2873            }}}
2874        }"#;
2875        let ir = parse_str(src).unwrap().spec.unwrap();
2876        let resp = &ir.operations[0].responses[0];
2877        let (_name, h) = &resp.headers[0];
2878        assert_eq!(h.style, Some(ParameterStyle::Simple));
2879        assert!(h.explode);
2880        assert!(!h.allow_reserved);
2881        assert!(!h.allow_empty_value);
2882    }
2883
2884    #[test]
2885    fn ref_siblings_3_1_plus_merge_onto_target() {
2886        // #146 (covers #139 too): in 3.1+, sibling keywords on a `$ref`
2887        // override the resolved target's same-keyed fields. OAS 3.2
2888        // codifies `summary` / `description` on Reference Object.
2889        let src = r##"{
2890            "openapi":"3.2.0",
2891            "info":{"title":"t","version":"1"},
2892            "paths":{"/x":{"get":{
2893                "operationId":"x",
2894                "responses":{"200":{
2895                    "$ref":"#/components/responses/Shared",
2896                    "description":"per-call override"
2897                }}
2898            }}},
2899            "components":{"responses":{
2900                "Shared":{
2901                    "description":"shared default",
2902                    "content":{"application/json":{"schema":{"type":"string"}}}
2903                }
2904            }}
2905        }"##;
2906        let ir = parse_str(src).unwrap().spec.unwrap();
2907        let resp = &ir.operations[0].responses[0];
2908        assert_eq!(
2909            resp.description.as_deref(),
2910            Some("per-call override"),
2911            "the sibling `description` wins over the shared default"
2912        );
2913    }
2914
2915    /// OAS 3.2 §4.23: Response now carries `summary` as well as
2916    /// `description`. A Reference Object's `summary` override should
2917    /// land on `Response.summary`.
2918    #[test]
2919    fn ref_summary_override_on_response_3_2() {
2920        let src = r##"{
2921            "openapi":"3.2.0",
2922            "info":{"title":"t","version":"1"},
2923            "paths":{"/x":{"get":{
2924                "operationId":"x",
2925                "responses":{"200":{
2926                    "$ref":"#/components/responses/Shared",
2927                    "summary":"per-call summary",
2928                    "description":"per-call desc"
2929                }}
2930            }}},
2931            "components":{"responses":{
2932                "Shared":{
2933                    "summary":"shared summary",
2934                    "description":"shared desc"
2935                }
2936            }}
2937        }"##;
2938        let ir = parse_str(src).unwrap().spec.unwrap();
2939        let resp = &ir.operations[0].responses[0];
2940        assert_eq!(resp.summary.as_deref(), Some("per-call summary"));
2941        assert_eq!(resp.description.as_deref(), Some("per-call desc"));
2942    }
2943
2944    /// Parameter Object (§4.12) only carries `description`. The
2945    /// Reference Object's `summary` should silently have no effect —
2946    /// the target type doesn't read it. `description` overrides.
2947    #[test]
2948    fn ref_override_on_parameter_summary_has_no_effect() {
2949        let src = r##"{
2950            "openapi":"3.2.0",
2951            "info":{"title":"t","version":"1"},
2952            "paths":{"/x/{id}":{
2953                "parameters":[{
2954                    "$ref":"#/components/parameters/Id",
2955                    "summary":"override summary",
2956                    "description":"override desc"
2957                }],
2958                "get":{"operationId":"x","responses":{"200":{"description":"ok"}}}
2959            }},
2960            "components":{"parameters":{
2961                "Id":{"name":"id","in":"path","required":true,"schema":{"type":"string"},
2962                      "description":"shared desc"}
2963            }}
2964        }"##;
2965        let ir = parse_str(src).unwrap().spec.unwrap();
2966        let param = &ir.operations[0].path_params[0];
2967        // description on Parameter: ref-site value wins.
2968        assert_eq!(param.description.as_deref(), Some("override desc"));
2969        // summary on Parameter: OAS §4.12 doesn't define summary on
2970        // Parameter, so the IR has no slot for it — type-level
2971        // enforcement of "this field has no effect" per §4.23. The
2972        // override value lands nowhere visible.
2973    }
2974
2975    /// Header Object (§4.21) only carries `description`. Same
2976    /// no-effect rule for `summary` as Parameter.
2977    #[test]
2978    fn ref_description_override_on_header() {
2979        let src = r##"{
2980            "openapi":"3.2.0",
2981            "info":{"title":"t","version":"1"},
2982            "paths":{"/x":{"get":{
2983                "operationId":"x",
2984                "responses":{"200":{
2985                    "description":"ok",
2986                    "headers":{
2987                        "X-Trace":{
2988                            "$ref":"#/components/headers/Trace",
2989                            "description":"override"
2990                        }
2991                    }
2992                }}
2993            }}},
2994            "components":{"headers":{
2995                "Trace":{"schema":{"type":"string"},"description":"shared"}
2996            }}
2997        }"##;
2998        let ir = parse_str(src).unwrap().spec.unwrap();
2999        let (_n, h) = &ir.operations[0].responses[0].headers[0];
3000        assert_eq!(h.description.as_deref(), Some("override"));
3001    }
3002
3003    /// Example Object (§4.19) carries `summary` and `description`.
3004    /// Both must override the target's values.
3005    #[test]
3006    fn ref_override_on_example_summary_and_description() {
3007        let src = r##"{
3008            "openapi":"3.2.0",
3009            "info":{"title":"t","version":"1"},
3010            "paths":{},
3011            "components":{
3012                "examples":{
3013                    "Shared":{"summary":"shared sum","description":"shared d","value":"v"}
3014                },
3015                "schemas":{
3016                    "Foo":{
3017                        "type":"string",
3018                        "examples":{
3019                            "ref":{
3020                                "$ref":"#/components/examples/Shared",
3021                                "summary":"call sum",
3022                                "description":"call d"
3023                            }
3024                        }
3025                    }
3026                }
3027            }
3028        }"##;
3029        let ir = parse_str(src).unwrap().spec.unwrap();
3030        let foo = ir.types.iter().find(|t| t.id == "Foo").unwrap();
3031        let ex = &foo.examples[0].1;
3032        assert_eq!(ex.summary.as_deref(), Some("call sum"));
3033        assert_eq!(ex.description.as_deref(), Some("call d"));
3034    }
3035
3036    /// Link Object (§4.20) only carries `description`. The current
3037    /// Reference Object override path supplies it. Covered alongside
3038    /// the broader sibling-merge test, but called out explicitly.
3039    #[test]
3040    fn ref_description_override_on_link() {
3041        let src = r##"{
3042            "openapi":"3.2.0",
3043            "info":{"title":"t","version":"1"},
3044            "paths":{"/x":{"get":{
3045                "operationId":"x",
3046                "responses":{"200":{
3047                    "description":"ok",
3048                    "links":{"next":{
3049                        "$ref":"#/components/links/Shared",
3050                        "description":"per-call link doc"
3051                    }}
3052                }}
3053            }}},
3054            "components":{"links":{
3055                "Shared":{"operationId":"x","description":"shared link doc"}
3056            }}
3057        }"##;
3058        let ir = parse_str(src).unwrap().spec.unwrap();
3059        let link = &ir.operations[0].responses[0].links[0].1;
3060        assert_eq!(link.description.as_deref(), Some("per-call link doc"));
3061    }
3062
3063    /// RequestBody (§4.13) only carries `description`. Override
3064    /// applies via the Reference Object path.
3065    #[test]
3066    fn ref_description_override_on_request_body() {
3067        let src = r##"{
3068            "openapi":"3.2.0",
3069            "info":{"title":"t","version":"1"},
3070            "paths":{"/x":{"post":{
3071                "operationId":"x",
3072                "requestBody":{
3073                    "$ref":"#/components/requestBodies/Shared",
3074                    "description":"per-call"
3075                },
3076                "responses":{"200":{"description":"ok"}}
3077            }}},
3078            "components":{"requestBodies":{
3079                "Shared":{
3080                    "description":"shared",
3081                    "content":{"application/json":{"schema":{"type":"string"}}}
3082                }
3083            }}
3084        }"##;
3085        let ir = parse_str(src).unwrap().spec.unwrap();
3086        let body = ir.operations[0].request_body.as_ref().unwrap();
3087        assert_eq!(body.description.as_deref(), Some("per-call"));
3088    }
3089
3090    /// SecurityScheme (§4.27) carries `description`. Reference Object
3091    /// override applies. Tests the `securitySchemes` ref path.
3092    #[test]
3093    fn ref_description_override_on_security_scheme() {
3094        let src = r##"{
3095            "openapi":"3.2.0",
3096            "info":{"title":"t","version":"1"},
3097            "paths":{},
3098            "components":{"securitySchemes":{
3099                "Wrap":{
3100                    "$ref":"#/components/securitySchemes/Shared",
3101                    "description":"per-call"
3102                },
3103                "Shared":{"type":"http","scheme":"bearer","description":"shared"}
3104            }}
3105        }"##;
3106        let ir = parse_str(src).unwrap().spec.unwrap();
3107        let scheme = ir
3108            .security_schemes
3109            .iter()
3110            .find(|s| s.id == "Wrap")
3111            .expect("Wrap scheme present");
3112        assert_eq!(scheme.description.as_deref(), Some("per-call"));
3113    }
3114
3115    /// OAS 3.2 §4.23: Reference Object "cannot be extended with
3116    /// additional properties, and any properties added SHALL be
3117    /// ignored." Verify the parser drops the extras and emits
3118    /// `W-REF-SIBLINGS-INVALID`.
3119    #[test]
3120    fn ref_with_invalid_siblings_warns_and_drops() {
3121        let src = r##"{
3122            "openapi":"3.2.0",
3123            "info":{"title":"t","version":"1"},
3124            "paths":{"/x":{"get":{
3125                "operationId":"x",
3126                "parameters":[{
3127                    "$ref":"#/components/parameters/Id",
3128                    "description":"ok",
3129                    "required":false,
3130                    "deprecated":true
3131                }],
3132                "responses":{"200":{"description":"ok"}}
3133            }}},
3134            "components":{"parameters":{
3135                "Id":{"name":"id","in":"path","required":true,"schema":{"type":"string"}}
3136            }}
3137        }"##;
3138        let out = parse_str(src).unwrap();
3139        let ir = out.spec.as_ref().unwrap();
3140        let param = &ir.operations[0].path_params[0];
3141        // `required` and `deprecated` on the ref site SHALL be
3142        // ignored; the target's values survive.
3143        assert!(param.required);
3144        assert!(!param.deprecated);
3145        // `description` on the ref site applies.
3146        assert_eq!(param.description.as_deref(), Some("ok"));
3147        assert!(
3148            out.diagnostics
3149                .iter()
3150                .any(|d| d.code == diag::W_REF_SIBLINGS_INVALID),
3151            "expected W-REF-SIBLINGS-INVALID; got: {:?}",
3152            out.diagnostics.iter().map(|d| &d.code).collect::<Vec<_>>()
3153        );
3154    }
3155
3156    /// `x-*` extensions on a Reference Object are also invalid per
3157    /// §4.23 ("cannot be extended with additional properties"). Verify
3158    /// the warning fires and the extension does not propagate.
3159    #[test]
3160    fn ref_with_x_extension_sibling_warns() {
3161        let src = r##"{
3162            "openapi":"3.2.0",
3163            "info":{"title":"t","version":"1"},
3164            "paths":{"/x":{"get":{
3165                "operationId":"x",
3166                "responses":{"200":{
3167                    "$ref":"#/components/responses/Shared",
3168                    "x-vendor":"yes"
3169                }}
3170            }}},
3171            "components":{"responses":{
3172                "Shared":{"description":"shared"}
3173            }}
3174        }"##;
3175        let out = parse_str(src).unwrap();
3176        assert!(
3177            out.diagnostics
3178                .iter()
3179                .any(|d| d.code == diag::W_REF_SIBLINGS_INVALID),
3180            "expected W-REF-SIBLINGS-INVALID; got: {:?}",
3181            out.diagnostics.iter().map(|d| &d.code).collect::<Vec<_>>()
3182        );
3183    }
3184
3185    /// MediaType (§4.14) has no `description` / `summary` fields.
3186    /// A Reference Object pointing at a MediaType with override docs
3187    /// applies neither — the target type doesn't read those fields.
3188    /// (Tested via the `mediaTypes` components map, OAS 3.2.)
3189    #[test]
3190    fn ref_override_on_media_type_has_no_effect() {
3191        let src = r##"{
3192            "openapi":"3.2.0",
3193            "info":{"title":"t","version":"1"},
3194            "paths":{"/x":{"get":{
3195                "operationId":"x",
3196                "responses":{"200":{
3197                    "description":"ok",
3198                    "content":{"application/json":{
3199                        "$ref":"#/components/mediaTypes/Shared",
3200                        "description":"ignored"
3201                    }}
3202                }}
3203            }}},
3204            "components":{"mediaTypes":{
3205                "Shared":{"schema":{"type":"string"}}
3206            }}
3207        }"##;
3208        let out = parse_str(src).unwrap();
3209        let ir = out.spec.as_ref().unwrap();
3210        let content = &ir.operations[0].responses[0].content[0];
3211        // MediaType (§4.14) has no `description` field per spec — the
3212        // IR's BodyContent has no description slot. The override
3213        // overlays the JSON but lands nowhere in the IR, matching
3214        // §4.23's "this field has no effect" clause at the type level.
3215        assert_eq!(content.media_type, "application/json");
3216        assert!(content.examples.is_empty());
3217    }
3218
3219    /// Reference Object on the target side of a multi-hop chain
3220    /// (`A → B → final`) still gets its `summary`/`description`
3221    /// applied at the outermost site. Only the outermost siblings
3222    /// participate — intermediates are pure pass-through.
3223    #[test]
3224    fn ref_chain_only_outermost_siblings_apply() {
3225        let src = r##"{
3226            "openapi":"3.2.0",
3227            "info":{"title":"t","version":"1"},
3228            "paths":{"/x":{"get":{
3229                "operationId":"x",
3230                "responses":{"200":{
3231                    "$ref":"#/components/responses/Outer",
3232                    "description":"call-site"
3233                }}
3234            }}},
3235            "components":{"responses":{
3236                "Outer":{"$ref":"#/components/responses/Inner"},
3237                "Inner":{"description":"deepest"}
3238            }}
3239        }"##;
3240        let out = parse_str(src).unwrap();
3241        let resp = &out.spec.as_ref().unwrap().operations[0].responses[0];
3242        assert_eq!(resp.description.as_deref(), Some("call-site"));
3243    }
3244
3245    /// OAS 3.0 dropped siblings silently from non-schema refs (the
3246    /// schema-side path emits W-REF-SIBLINGS-3-0 separately). Verify
3247    /// the override is not applied for 3.0 docs.
3248    #[test]
3249    fn ref_override_skipped_for_oas_3_0() {
3250        let src = r##"{
3251            "openapi":"3.0.3",
3252            "info":{"title":"t","version":"1"},
3253            "paths":{"/x":{"get":{
3254                "operationId":"x",
3255                "responses":{"200":{
3256                    "$ref":"#/components/responses/Shared",
3257                    "description":"would-be override"
3258                }}
3259            }}},
3260            "components":{"responses":{
3261                "Shared":{"description":"shared"}
3262            }}
3263        }"##;
3264        let ir = parse_str(src).unwrap().spec.unwrap();
3265        let resp = &ir.operations[0].responses[0];
3266        // 3.0: siblings are dropped, target's description survives.
3267        assert_eq!(resp.description.as_deref(), Some("shared"));
3268    }
3269
3270    /// $ref with no siblings — basic happy path. Target's docs survive
3271    /// unchanged; no warnings emitted.
3272    #[test]
3273    fn ref_with_only_dollar_ref_no_overrides() {
3274        let src = r##"{
3275            "openapi":"3.2.0",
3276            "info":{"title":"t","version":"1"},
3277            "paths":{"/x":{"get":{
3278                "operationId":"x",
3279                "responses":{"200":{"$ref":"#/components/responses/Shared"}}
3280            }}},
3281            "components":{"responses":{
3282                "Shared":{"description":"shared","summary":"shared sum"}
3283            }}
3284        }"##;
3285        let out = parse_str(src).unwrap();
3286        let resp = &out.spec.as_ref().unwrap().operations[0].responses[0];
3287        assert_eq!(resp.summary.as_deref(), Some("shared sum"));
3288        assert_eq!(resp.description.as_deref(), Some("shared"));
3289        assert!(
3290            !out.diagnostics
3291                .iter()
3292                .any(|d| d.code == diag::W_REF_SIBLINGS_INVALID),
3293            "no W-REF-SIBLINGS-INVALID for a bare $ref"
3294        );
3295    }
3296
3297    /// $ref override of `summary` only — `description` from target
3298    /// survives unchanged. The two fields are independent.
3299    #[test]
3300    fn ref_summary_override_only_leaves_description_intact() {
3301        let src = r##"{
3302            "openapi":"3.2.0",
3303            "info":{"title":"t","version":"1"},
3304            "paths":{"/x":{"get":{
3305                "operationId":"x",
3306                "responses":{"200":{
3307                    "$ref":"#/components/responses/Shared",
3308                    "summary":"call-site sum"
3309                }}
3310            }}},
3311            "components":{"responses":{
3312                "Shared":{"summary":"shared sum","description":"shared d"}
3313            }}
3314        }"##;
3315        let resp = &parse_str(src).unwrap().spec.unwrap().operations[0].responses[0];
3316        assert_eq!(resp.summary.as_deref(), Some("call-site sum"));
3317        assert_eq!(resp.description.as_deref(), Some("shared d"));
3318    }
3319
3320    /// $ref override of `description` only — `summary` from target
3321    /// survives unchanged.
3322    #[test]
3323    fn ref_description_override_only_leaves_summary_intact() {
3324        let src = r##"{
3325            "openapi":"3.2.0",
3326            "info":{"title":"t","version":"1"},
3327            "paths":{"/x":{"get":{
3328                "operationId":"x",
3329                "responses":{"200":{
3330                    "$ref":"#/components/responses/Shared",
3331                    "description":"call-site d"
3332                }}
3333            }}},
3334            "components":{"responses":{
3335                "Shared":{"summary":"shared sum","description":"shared d"}
3336            }}
3337        }"##;
3338        let resp = &parse_str(src).unwrap().spec.unwrap().operations[0].responses[0];
3339        assert_eq!(resp.summary.as_deref(), Some("shared sum"));
3340        assert_eq!(resp.description.as_deref(), Some("call-site d"));
3341    }
3342
3343    // ---- Per-node strict spec doc-field population ----
3344
3345    /// Operation `summary` populates independently from `description`.
3346    /// OAS 3.1 introduced summary as a distinct field; this verifies the
3347    /// two slots don't collapse.
3348    #[test]
3349    fn operation_summary_independent_of_description() {
3350        let src = r#"{
3351            "openapi":"3.2.0",
3352            "info":{"title":"t","version":"1"},
3353            "paths":{"/x":{"get":{
3354                "operationId":"x",
3355                "summary":"short",
3356                "description":"long form",
3357                "responses":{"200":{"description":"ok"}}
3358            }}}
3359        }"#;
3360        let op = &parse_str(src).unwrap().spec.unwrap().operations[0];
3361        assert_eq!(op.summary.as_deref(), Some("short"));
3362        assert_eq!(op.description.as_deref(), Some("long form"));
3363    }
3364
3365    /// PathItem-level `summary` falls back into `Operation.summary` when
3366    /// the operation has none. OAS §4.9.
3367    #[test]
3368    fn operation_summary_falls_back_from_path_item() {
3369        let src = r#"{
3370            "openapi":"3.2.0",
3371            "info":{"title":"t","version":"1"},
3372            "paths":{"/x":{
3373                "summary":"path-item sum",
3374                "description":"path-item desc",
3375                "get":{"operationId":"x","responses":{"200":{"description":"ok"}}}
3376            }}
3377        }"#;
3378        let op = &parse_str(src).unwrap().spec.unwrap().operations[0];
3379        assert_eq!(op.summary.as_deref(), Some("path-item sum"));
3380        assert_eq!(op.description.as_deref(), Some("path-item desc"));
3381    }
3382
3383    /// PathItem-level fallback is per-field independent — operation
3384    /// `summary` overrides PathItem.summary, but PathItem.description
3385    /// still flows through when the operation has none.
3386    #[test]
3387    fn path_item_fallback_per_field_independent() {
3388        let src = r#"{
3389            "openapi":"3.2.0",
3390            "info":{"title":"t","version":"1"},
3391            "paths":{"/x":{
3392                "summary":"path-item sum",
3393                "description":"path-item desc",
3394                "get":{
3395                    "operationId":"x",
3396                    "summary":"op sum",
3397                    "responses":{"200":{"description":"ok"}}
3398                }
3399            }}
3400        }"#;
3401        let op = &parse_str(src).unwrap().spec.unwrap().operations[0];
3402        assert_eq!(op.summary.as_deref(), Some("op sum"));
3403        assert_eq!(op.description.as_deref(), Some("path-item desc"));
3404    }
3405
3406    /// `Operation.deprecated` populates from the spec.
3407    #[test]
3408    fn operation_deprecated_populates() {
3409        let src = r#"{
3410            "openapi":"3.2.0",
3411            "info":{"title":"t","version":"1"},
3412            "paths":{"/x":{"get":{
3413                "operationId":"x",
3414                "deprecated":true,
3415                "responses":{"200":{"description":"ok"}}
3416            }}}
3417        }"#;
3418        let op = &parse_str(src).unwrap().spec.unwrap().operations[0];
3419        assert!(op.deprecated);
3420    }
3421
3422    /// Webhooks carry PathItem-level `summary` / `description` per OAS
3423    /// §4.9. Both fields populate independently.
3424    #[test]
3425    fn webhook_path_item_summary_and_description_populate() {
3426        let src = r#"{
3427            "openapi":"3.2.0",
3428            "info":{"title":"t","version":"1"},
3429            "paths":{},
3430            "webhooks":{
3431                "newPet":{
3432                    "summary":"hook sum",
3433                    "description":"hook desc",
3434                    "post":{"operationId":"onNewPet","responses":{"200":{"description":"ok"}}}
3435                }
3436            }
3437        }"#;
3438        let ir = parse_str(src).unwrap().spec.unwrap();
3439        let webhook = ir.webhooks.iter().find(|w| w.name == "newPet").unwrap();
3440        assert_eq!(webhook.summary.as_deref(), Some("hook sum"));
3441        assert_eq!(webhook.description.as_deref(), Some("hook desc"));
3442    }
3443
3444    /// OAS 3.2 added `summary` to Response Object. Verify it populates
3445    /// independently from `description` (which was already there).
3446    #[test]
3447    fn response_summary_3_2_populates() {
3448        let src = r#"{
3449            "openapi":"3.2.0",
3450            "info":{"title":"t","version":"1"},
3451            "paths":{"/x":{"get":{
3452                "operationId":"x",
3453                "responses":{"200":{
3454                    "summary":"short label",
3455                    "description":"long form"
3456                }}
3457            }}}
3458        }"#;
3459        let resp = &parse_str(src).unwrap().spec.unwrap().operations[0].responses[0];
3460        assert_eq!(resp.summary.as_deref(), Some("short label"));
3461        assert_eq!(resp.description.as_deref(), Some("long form"));
3462    }
3463
3464    /// `Property.title` populates from each schema property's JSON
3465    /// Schema `title` field.
3466    #[test]
3467    fn property_title_populates() {
3468        let src = r##"{
3469            "openapi":"3.2.0",
3470            "info":{"title":"t","version":"1"},
3471            "paths":{},
3472            "components":{"schemas":{
3473                "User":{
3474                    "type":"object",
3475                    "properties":{
3476                        "id":{"type":"string","title":"User ID"}
3477                    }
3478                }
3479            }}
3480        }"##;
3481        let ir = parse_str(src).unwrap().spec.unwrap();
3482        let user = ir.types.iter().find(|t| t.id == "User").unwrap();
3483        let forge_ir::TypeDef::Object(obj) = &user.definition else {
3484            panic!("expected object");
3485        };
3486        let id_prop = obj.properties.iter().find(|p| p.name == "id").unwrap();
3487        assert_eq!(id_prop.title.as_deref(), Some("User ID"));
3488    }
3489
3490    /// `Property.external_docs` populates from the property schema's
3491    /// `externalDocs` block.
3492    #[test]
3493    fn property_external_docs_populates() {
3494        let src = r##"{
3495            "openapi":"3.2.0",
3496            "info":{"title":"t","version":"1"},
3497            "paths":{},
3498            "components":{"schemas":{
3499                "User":{
3500                    "type":"object",
3501                    "properties":{
3502                        "id":{
3503                            "type":"string",
3504                            "externalDocs":{"url":"https://example.com","description":"d"}
3505                        }
3506                    }
3507                }
3508            }}
3509        }"##;
3510        let ir = parse_str(src).unwrap().spec.unwrap();
3511        let user = ir.types.iter().find(|t| t.id == "User").unwrap();
3512        let forge_ir::TypeDef::Object(obj) = &user.definition else {
3513            panic!("expected object");
3514        };
3515        let id_prop = obj.properties.iter().find(|p| p.name == "id").unwrap();
3516        let ed = id_prop.external_docs.as_ref().unwrap();
3517        assert_eq!(ed.url, "https://example.com");
3518        assert_eq!(ed.description.as_deref(), Some("d"));
3519    }
3520
3521    /// `Property.examples` populates from per-property schema-level
3522    /// `example` (3.0 form, stored under `_default`).
3523    #[test]
3524    fn property_examples_populates() {
3525        let src = r##"{
3526            "openapi":"3.2.0",
3527            "info":{"title":"t","version":"1"},
3528            "paths":{},
3529            "components":{"schemas":{
3530                "User":{
3531                    "type":"object",
3532                    "properties":{
3533                        "id":{"type":"string","example":"42"}
3534                    }
3535                }
3536            }}
3537        }"##;
3538        let ir = parse_str(src).unwrap().spec.unwrap();
3539        let user = ir.types.iter().find(|t| t.id == "User").unwrap();
3540        let forge_ir::TypeDef::Object(obj) = &user.definition else {
3541            panic!("expected object");
3542        };
3543        let id_prop = obj.properties.iter().find(|p| p.name == "id").unwrap();
3544        assert_eq!(id_prop.examples.len(), 1);
3545        assert_eq!(id_prop.examples[0].0, "_default");
3546    }
3547
3548    /// `Property.deprecated` populates from JSON Schema 2020-12
3549    /// `deprecated`.
3550    #[test]
3551    fn property_deprecated_populates() {
3552        let src = r##"{
3553            "openapi":"3.2.0",
3554            "info":{"title":"t","version":"1"},
3555            "paths":{},
3556            "components":{"schemas":{
3557                "User":{
3558                    "type":"object",
3559                    "properties":{
3560                        "id":{"type":"string","deprecated":true}
3561                    }
3562                }
3563            }}
3564        }"##;
3565        let ir = parse_str(src).unwrap().spec.unwrap();
3566        let user = ir.types.iter().find(|t| t.id == "User").unwrap();
3567        let forge_ir::TypeDef::Object(obj) = &user.definition else {
3568            panic!("expected object");
3569        };
3570        let id_prop = obj.properties.iter().find(|p| p.name == "id").unwrap();
3571        assert!(id_prop.deprecated);
3572    }
3573
3574    /// `NamedType.deprecated` populates from JSON Schema 2020-12
3575    /// `deprecated` at the schema level.
3576    #[test]
3577    fn named_type_deprecated_populates() {
3578        let src = r##"{
3579            "openapi":"3.2.0",
3580            "info":{"title":"t","version":"1"},
3581            "paths":{},
3582            "components":{"schemas":{
3583                "Legacy":{"type":"string","deprecated":true}
3584            }}
3585        }"##;
3586        let ir = parse_str(src).unwrap().spec.unwrap();
3587        let legacy = ir.types.iter().find(|t| t.id == "Legacy").unwrap();
3588        assert!(legacy.deprecated);
3589    }
3590
3591    /// OAS 3.2 §4.27: SecurityScheme adds `deprecated`. Verify it
3592    /// populates.
3593    #[test]
3594    fn security_scheme_deprecated_3_2_populates() {
3595        let src = r##"{
3596            "openapi":"3.2.0",
3597            "info":{"title":"t","version":"1"},
3598            "paths":{},
3599            "components":{"securitySchemes":{
3600                "Old":{
3601                    "type":"http",
3602                    "scheme":"basic",
3603                    "description":"do not use",
3604                    "deprecated":true
3605                }
3606            }}
3607        }"##;
3608        let ir = parse_str(src).unwrap().spec.unwrap();
3609        let scheme = ir.security_schemes.iter().find(|s| s.id == "Old").unwrap();
3610        assert!(scheme.deprecated);
3611        assert_eq!(scheme.description.as_deref(), Some("do not use"));
3612    }
3613
3614    /// `NamedType.title` populates separately from `description`.
3615    /// JSON Schema treats title as a short human label.
3616    #[test]
3617    fn named_type_title_populates() {
3618        let src = r##"{
3619            "openapi":"3.2.0",
3620            "info":{"title":"t","version":"1"},
3621            "paths":{},
3622            "components":{"schemas":{
3623                "Foo":{"type":"string","title":"Foo Type","description":"long"}
3624            }}
3625        }"##;
3626        let ir = parse_str(src).unwrap().spec.unwrap();
3627        let foo = ir.types.iter().find(|t| t.id == "Foo").unwrap();
3628        assert_eq!(foo.title.as_deref(), Some("Foo Type"));
3629        assert_eq!(foo.description.as_deref(), Some("long"));
3630    }
3631
3632    #[test]
3633    fn primitive_kind_carries_only_jsonschema_type_values() {
3634        // #105: PrimitiveKind is exactly the JSON Schema `type`
3635        // keyword's leaf values. All format refinements land in
3636        // `format_extension` verbatim — including ones the IR used
3637        // to fold into rich kinds (`int32`, `date`, `email`, `byte`).
3638        use forge_ir::{PrimitiveKind, TypeDef};
3639        let src = r#"{
3640            "openapi":"3.0.3",
3641            "info":{"title":"t","version":"1"},
3642            "paths":{},
3643            "components":{"schemas":{
3644                "Plain":      {"type":"string"},
3645                "Stamp":      {"type":"string","format":"date-time"},
3646                "Mail":       {"type":"string","format":"email"},
3647                "Avatar":     {"type":"string","format":"byte"},
3648                "Tally":      {"type":"integer","format":"int32"},
3649                "Big":        {"type":"integer","format":"int64"},
3650                "Money":      {"type":"string","format":"decimal"},
3651                "Flag":       {"type":"boolean"}
3652            }}
3653        }"#;
3654        let ir = parse_str(src).unwrap().spec.unwrap();
3655        let prim = |id: &str| -> (PrimitiveKind, Option<String>) {
3656            let nt = ir.types.iter().find(|t| t.id == id).unwrap();
3657            let TypeDef::Primitive(p) = &nt.definition else {
3658                panic!("{id} not primitive");
3659            };
3660            (p.kind, p.constraints.format_extension.clone())
3661        };
3662        assert_eq!(prim("Plain"), (PrimitiveKind::String, None));
3663        assert_eq!(
3664            prim("Stamp"),
3665            (PrimitiveKind::String, Some("date-time".into()))
3666        );
3667        assert_eq!(prim("Mail"), (PrimitiveKind::String, Some("email".into())));
3668        assert_eq!(prim("Avatar"), (PrimitiveKind::String, Some("byte".into())));
3669        assert_eq!(
3670            prim("Tally"),
3671            (PrimitiveKind::Integer, Some("int32".into()))
3672        );
3673        assert_eq!(prim("Big"), (PrimitiveKind::Integer, Some("int64".into())));
3674        assert_eq!(
3675            prim("Money"),
3676            (PrimitiveKind::String, Some("decimal".into()))
3677        );
3678        assert_eq!(prim("Flag"), (PrimitiveKind::Bool, None));
3679    }
3680}