Skip to main content

oapi_codegen/
loader.rs

1//! Loading OpenAPI documents and resolving `$ref`s.
2
3use std::cell::RefCell;
4use std::collections::HashMap;
5use std::path::Path;
6use std::path::PathBuf;
7use std::rc::Rc;
8
9use indexmap::IndexMap;
10use openapiv3::OpenAPI;
11use openapiv3::Parameter;
12use openapiv3::ReferenceOr;
13use openapiv3::RequestBody;
14use openapiv3::Response;
15use openapiv3::Schema;
16
17use crate::error::Error;
18use crate::error::Result;
19use crate::lower::direction::REQUEST_SUFFIX;
20use crate::lower::direction::RESPONSE_SUFFIX;
21
22/// Maximum `$ref` chain length before bailing out (cycle guard).
23const MAX_REF_DEPTH: usize = 32;
24
25/// The OpenAPI minor versions the generator reads. Every parsed document must
26/// declare a patch release of one of them.
27///
28/// This is a list so that adding a version is one entry here. The gate and its
29/// message both read it, so neither states a version of its own.
30const SUPPORTED_SPEC_VERSIONS: [&str; 1] = ["3.0"];
31
32/// Top-level keys that carry operations the generator cannot emit. A document
33/// that declares one is rejected, because ignoring it emits no handler for any
34/// operation inside it and reads as a document that declares none.
35///
36/// `webhooks` is a 3.1 key, so today the version gate rejects such a document
37/// first and this list only reaches a 3.0 document that declares the key anyway.
38/// Such a document is still ambiguous, and the generator does not guess. The check
39/// stands on its own once a version that defines the key is supported.
40const UNSUPPORTED_TOP_LEVEL_KEYS: [&str; 1] = ["webhooks"];
41
42/// Shared empty schema map returned when a document has no components.
43static EMPTY_SCHEMAS: std::sync::OnceLock<IndexMap<String, ReferenceOr<Schema>>> = std::sync::OnceLock::new();
44
45/// Shared empty security-scheme map returned when a document has no components.
46static EMPTY_SECURITY_SCHEMES: std::sync::OnceLock<IndexMap<String, ReferenceOr<openapiv3::SecurityScheme>>> =
47    std::sync::OnceLock::new();
48
49/// A resolved structural object plus the referenced file it came from.
50#[derive(Debug, Clone)]
51pub struct Resolved<T> {
52    /// The concrete, owned object.
53    pub value: T,
54    /// The referenced file the object was ultimately resolved from, written
55    /// exactly as it appears in the `$ref` (the `import-mapping` key), or `None`
56    /// for a same-document / inline object.
57    pub origin: Option<String>,
58}
59
60/// A loaded OpenAPI document plus its source path (for diagnostics).
61#[derive(Debug)]
62pub struct Spec {
63    inner: OpenAPI,
64    source: PathBuf,
65    docs: RefCell<HashMap<PathBuf, Rc<OpenAPI>>>,
66    /// What the run generates and which operations it keeps. The inspection of
67    /// each document reads this to decide which constraint notes apply: only a
68    /// server checks a query parameter, and only for an operation it keeps.
69    run: crate::coverage::Run,
70}
71
72impl Spec {
73    /// Load and parse an OpenAPI document from a YAML or JSON file, for a run
74    /// that generates no server and filters nothing.
75    pub fn load(path: &Path) -> Result<Self> {
76        return Self::load_for(path, false, &crate::config::OutputOptions::default());
77    }
78
79    /// Load and parse an OpenAPI document from a YAML or JSON file.
80    ///
81    /// `server` states whether the run generates the server, and `options`
82    /// holds the filters the run applies. A server checks a query parameter's
83    /// constraints in the generated query struct, so for an operation the run
84    /// keeps the inspection reports no unchecked constraint there.
85    pub fn load_for(path: &Path, server: bool, options: &crate::config::OutputOptions) -> Result<Self> {
86        let run = crate::coverage::Run {
87            server,
88            filters: options.clone(),
89        };
90        let text = std::fs::read_to_string(path).map_err(|source| {
91            return Error::ReadSpec {
92                path: path.display().to_string(),
93                source,
94            };
95        })?;
96        let document = path.display().to_string();
97        // Parse to a `Value` first, then into `OpenAPI` from that one tree. The
98        // typed form drops every key it does not know, so a key such as
99        // `webhooks:` is only visible here. This costs no second parse of the
100        // text.
101        let value: serde_yaml::Value = serde_yaml::from_str(&text).map_err(|source| {
102            return Error::ParseSpec {
103                path: document.clone(),
104                source,
105            };
106        })?;
107        // Both checks read the untyped tree, and both run before the typed parse.
108        // The version gate must, because a 3.1-only construct fails that parse
109        // with a message that names a YAML shape and not a version.
110        check_spec_version(&document, &value)?;
111        check_top_level_keys(&value)?;
112        let described = crate::coverage::check(&document, &value, &run)?;
113        let mut value = value;
114        crate::coverage::wrap_described_refs(&mut value, &described);
115        let inner: OpenAPI = serde_yaml::from_value(value).map_err(|source| {
116            return Error::ParseSpec {
117                path: document.clone(),
118                source,
119            };
120        })?;
121        return Ok(Spec {
122            inner,
123            source: path.to_path_buf(),
124            docs: RefCell::new(HashMap::new()),
125            run,
126        });
127    }
128
129    /// Construct a spec directly from an already-parsed document (test helper).
130    pub fn from_parts(inner: OpenAPI, source: PathBuf) -> Self {
131        return Spec {
132            inner,
133            source,
134            docs: RefCell::new(HashMap::new()),
135            run: crate::coverage::Run::default(),
136        };
137    }
138
139    /// Parse (once, cached) the file referenced by a cross-file `$ref`,
140    /// resolving `file` relative to the directory containing the main spec.
141    fn document_for(&self, file: &str) -> Result<Rc<OpenAPI>> {
142        let base = self.source.parent().unwrap_or_else(|| {
143            return Path::new(".");
144        });
145        let path = base.join(file);
146        if let Some(doc) = self.docs.borrow().get(&path) {
147            return Ok(Rc::clone(doc));
148        }
149        let text = std::fs::read_to_string(&path).map_err(|source| {
150            return Error::ReadRefFile {
151                file: file.to_owned(),
152                source,
153            };
154        })?;
155        let value: serde_yaml::Value = serde_yaml::from_str(&text).map_err(|source| {
156            return Error::ParseRefFile {
157                file: file.to_owned(),
158                source,
159            };
160        })?;
161        // A referenced file is a document of its own and declares its own
162        // version. A 3.1 fragment pulled into a 3.0 document is the same
163        // ambiguity as a 3.1 root, so the same gate applies, and it applies
164        // before the typed parse for the same reason.
165        check_spec_version(file, &value)?;
166        check_top_level_keys(&value)?;
167        let described = crate::coverage::check(file, &value, &self.run)?;
168        let mut value = value;
169        crate::coverage::wrap_described_refs(&mut value, &described);
170        let parsed: OpenAPI = serde_yaml::from_value(value).map_err(|source| {
171            return Error::ParseRefFile {
172                file: file.to_owned(),
173                source,
174            };
175        })?;
176        let doc = Rc::new(parsed);
177        self.docs.borrow_mut().insert(path, Rc::clone(&doc));
178        return Ok(doc);
179    }
180
181    /// The source path the spec was loaded from.
182    pub fn source(&self) -> &Path {
183        return &self.source;
184    }
185
186    /// Apply the configured operation and schema filters, mutating the spec in
187    /// place before lowering (see [`crate::filter`]).
188    pub fn apply_filters(&mut self, opts: &crate::config::OutputOptions) {
189        crate::filter::apply(&mut self.inner, opts);
190    }
191
192    /// The component schemas declared in the document, in document order.
193    pub fn schemas(&self) -> &IndexMap<String, ReferenceOr<Schema>> {
194        let empty = EMPTY_SCHEMAS.get_or_init(IndexMap::new);
195        let schemas = self
196            .inner
197            .components
198            .as_ref()
199            .map(|c| {
200                return &c.schemas;
201            })
202            .unwrap_or(empty);
203        return schemas;
204    }
205
206    /// The paths (operations) declared in the document, in document order.
207    pub fn paths(&self) -> &openapiv3::Paths {
208        return &self.inner.paths;
209    }
210
211    /// The top-level `servers` declared in the document, in document order.
212    pub fn servers(&self) -> &[openapiv3::Server] {
213        return &self.inner.servers;
214    }
215
216    /// The document's global `security` requirements, if declared. An operation
217    /// with no `security` of its own inherits these.
218    pub fn global_security(&self) -> Option<&[openapiv3::SecurityRequirement]> {
219        return self.inner.security.as_deref();
220    }
221
222    /// The security schemes declared under `components.securitySchemes`, in
223    /// document order.
224    pub fn security_schemes(&self) -> &IndexMap<String, ReferenceOr<openapiv3::SecurityScheme>> {
225        let empty = EMPTY_SECURITY_SCHEMES.get_or_init(IndexMap::new);
226        let schemes = self
227            .inner
228            .components
229            .as_ref()
230            .map(|c| {
231                return &c.security_schemes;
232            })
233            .unwrap_or(empty);
234        return schemes;
235    }
236
237    /// Resolve a `#/components/responses/<name>` (possibly cross-file)
238    /// reference to an owned component response plus the file it came from,
239    /// following reference chains within and across documents.
240    pub fn resolve_response(&self, reference: &str) -> Result<Resolved<Response>> {
241        let mut current = reference.to_owned();
242        let mut origin: Option<String> = None;
243        for _ in 0..MAX_REF_DEPTH {
244            if let Some(file) = ref_file_part(&current) {
245                origin = Some(file.to_owned());
246            }
247            let name = ref_component_name(&current, "responses").ok_or_else(|| {
248                return Error::UnsupportedRef {
249                    reference: current.clone(),
250                    reason: "only `#/components/responses/<name>` references are supported".to_owned(),
251                };
252            })?;
253            let entry = self.component_response(origin.as_deref(), &current, name)?;
254            match entry {
255                ReferenceOr::Item(response) => {
256                    return Ok(Resolved {
257                        value: response,
258                        origin,
259                    });
260                }
261                ReferenceOr::Reference { reference } => {
262                    current = reference;
263                }
264            }
265        }
266        return Err(Error::UnresolvedRef(reference.to_owned()));
267    }
268
269    /// Resolve a `#/components/parameters/<name>` (possibly cross-file)
270    /// reference to an owned component parameter plus the file it came from,
271    /// following reference chains within and across documents.
272    pub fn resolve_parameter(&self, reference: &str) -> Result<Resolved<Parameter>> {
273        let mut current = reference.to_owned();
274        let mut origin: Option<String> = None;
275        for _ in 0..MAX_REF_DEPTH {
276            if let Some(file) = ref_file_part(&current) {
277                origin = Some(file.to_owned());
278            }
279            let name = ref_component_name(&current, "parameters").ok_or_else(|| {
280                return Error::UnsupportedRef {
281                    reference: current.clone(),
282                    reason: "only `#/components/parameters/<name>` references are supported".to_owned(),
283                };
284            })?;
285            let entry = self.component_parameter(origin.as_deref(), &current, name)?;
286            match entry {
287                ReferenceOr::Item(parameter) => {
288                    return Ok(Resolved {
289                        value: parameter,
290                        origin,
291                    });
292                }
293                ReferenceOr::Reference { reference } => {
294                    current = reference;
295                }
296            }
297        }
298        return Err(Error::UnresolvedRef(reference.to_owned()));
299    }
300
301    /// Resolve a `#/components/requestBodies/<name>` (possibly cross-file)
302    /// reference to an owned component request body plus the file it came from,
303    /// following reference chains within and across documents.
304    pub fn resolve_request_body(&self, reference: &str) -> Result<Resolved<RequestBody>> {
305        let mut current = reference.to_owned();
306        let mut origin: Option<String> = None;
307        for _ in 0..MAX_REF_DEPTH {
308            if let Some(file) = ref_file_part(&current) {
309                origin = Some(file.to_owned());
310            }
311            let name = ref_component_name(&current, "requestBodies").ok_or_else(|| {
312                return Error::UnsupportedRef {
313                    reference: current.clone(),
314                    reason: "only `#/components/requestBodies/<name>` references are supported".to_owned(),
315                };
316            })?;
317            let entry = self.component_request_body(origin.as_deref(), &current, name)?;
318            match entry {
319                ReferenceOr::Item(body) => {
320                    return Ok(Resolved { value: body, origin });
321                }
322                ReferenceOr::Reference { reference } => {
323                    current = reference;
324                }
325            }
326        }
327        return Err(Error::UnresolvedRef(reference.to_owned()));
328    }
329
330    /// Resolve a `$ref` string to the concrete schema it names, following
331    /// chains of references within this document.
332    pub fn resolve(&self, reference: &str) -> Result<&Schema> {
333        let mut current = reference.to_owned();
334        for _ in 0..MAX_REF_DEPTH {
335            let name = ref_target_name(&current).ok_or_else(|| {
336                return Error::UnsupportedRef {
337                    reference: current.clone(),
338                    reason: "only `#/components/schemas/<name>` references are supported".to_owned(),
339                };
340            })?;
341            let entry = self
342                .schemas()
343                .get(name)
344                .ok_or_else(|| return Error::UnresolvedRef(current.clone()))?;
345            match entry {
346                ReferenceOr::Item(schema) => {
347                    return Ok(schema);
348                }
349                ReferenceOr::Reference { reference } => {
350                    current = reference.clone();
351                }
352            }
353        }
354        return Err(Error::UnresolvedRef(reference.to_owned()));
355    }
356
357    /// Resolve a same-document schema `$ref` to an owned schema, against the
358    /// main document when `origin` is `None` or a referenced document otherwise.
359    ///
360    /// A cross-file (`file#/...`) inner schema ref is intentionally rejected: a
361    /// schema *type* reference is emitted as a named external type through the
362    /// `import-mapping` (see the lowering pass's `schema_ref_type`), never read
363    /// and inlined. This method only resolves refs that stay within one
364    /// document's own `#/components/schemas`.
365    pub fn resolve_schema(&self, origin: Option<&str>, reference: &str) -> Result<Schema> {
366        let mut current = reference.to_owned();
367        for _ in 0..MAX_REF_DEPTH {
368            if ref_file_part(&current).is_some() {
369                return Err(Error::UnsupportedRef {
370                    reference: current.clone(),
371                    reason: "cross-file schema `$ref`s are not supported here".to_owned(),
372                });
373            }
374            let name = ref_component_name(&current, "schemas").ok_or_else(|| {
375                return Error::UnsupportedRef {
376                    reference: current.clone(),
377                    reason: "only `#/components/schemas/<name>` references are supported".to_owned(),
378                };
379            })?;
380            let entry = self.component_schema(origin, &current, name)?;
381            match entry {
382                ReferenceOr::Item(schema) => {
383                    return Ok(schema);
384                }
385                ReferenceOr::Reference { reference } => {
386                    current = reference;
387                }
388            }
389        }
390        return Err(Error::UnresolvedRef(reference.to_owned()));
391    }
392
393    /// Look up a named component in the main document (`origin` is `None`) or a
394    /// referenced document, returning an owned copy. `select` extracts the
395    /// specific component map's entry from a document. `reference` is the full
396    /// `$ref` fragment currently being resolved, reported verbatim in the
397    /// unresolved-reference error so a miss points at the exact ref (kind,
398    /// component, and file). The origin dispatch and error are shared across
399    /// component kinds.
400    fn component_lookup<T>(
401        &self,
402        origin: Option<&str>,
403        reference: &str,
404        select: impl Fn(&OpenAPI) -> Option<ReferenceOr<T>>,
405    ) -> Result<ReferenceOr<T>> {
406        let entry = match origin {
407            None => select(&self.inner),
408            Some(file) => {
409                let doc = self.document_for(file)?;
410                select(doc.as_ref())
411            }
412        };
413        return entry.ok_or_else(|| return Error::UnresolvedRef(reference.to_owned()));
414    }
415
416    /// Look up a component response (see [`Self::component_lookup`]).
417    fn component_response(&self, origin: Option<&str>, reference: &str, name: &str) -> Result<ReferenceOr<Response>> {
418        return self.component_lookup(origin, reference, |doc| {
419            return doc.components.as_ref().and_then(|components| {
420                return components.responses.get(name).cloned();
421            });
422        });
423    }
424
425    /// Look up a component parameter (see [`Self::component_lookup`]).
426    fn component_parameter(&self, origin: Option<&str>, reference: &str, name: &str) -> Result<ReferenceOr<Parameter>> {
427        return self.component_lookup(origin, reference, |doc| {
428            return doc.components.as_ref().and_then(|components| {
429                return components.parameters.get(name).cloned();
430            });
431        });
432    }
433
434    /// Look up a component request body (see [`Self::component_lookup`]).
435    fn component_request_body(
436        &self,
437        origin: Option<&str>,
438        reference: &str,
439        name: &str,
440    ) -> Result<ReferenceOr<RequestBody>> {
441        return self.component_lookup(origin, reference, |doc| {
442            return doc.components.as_ref().and_then(|components| {
443                return components.request_bodies.get(name).cloned();
444            });
445        });
446    }
447
448    /// Look up a component schema (see [`Self::component_lookup`]).
449    fn component_schema(&self, origin: Option<&str>, reference: &str, name: &str) -> Result<ReferenceOr<Schema>> {
450        return self.component_lookup(origin, reference, |doc| {
451            return doc.components.as_ref().and_then(|components| {
452                return components.schemas.get(name).cloned();
453            });
454        });
455    }
456
457    /// The name a schema in a referenced file takes in the crate the
458    /// `import-mapping` points at.
459    ///
460    /// The two runs read the same document, so an `x-rust-name` there reaches
461    /// both. Without this the run that generates the models emits the name the
462    /// key gives, the run that generates the operations emits the schema name,
463    /// and the composed crate does not build.
464    ///
465    /// A miss is an error. The name would otherwise reach the output and name a
466    /// type the other crate never declares.
467    ///
468    /// Two names in the referenced file that give one Rust name are an error
469    /// too. The run that writes the models separates them with a
470    /// `type-name-suffix` it takes from its own config. This run cannot see that
471    /// config, so it emits the plain name and reaches whichever of the two
472    /// schemas kept it. That builds, and it carries the wrong type.
473    ///
474    /// A schema the direction pass splits is an error for the same reason. The
475    /// other run emits two names there, and this one cannot tell which of the
476    /// two an `import-mapping` reference means.
477    pub fn external_schema_name(&self, file: &str, name: &str, reference: &str) -> Result<String> {
478        let entry = self.component_schema(Some(file), reference, name)?;
479        let chosen = external_name_of(&entry, name)?;
480        let doc = self.document_for(file)?;
481        let schemas = doc.components.as_ref().map(|components| return &components.schemas);
482        let ident = crate::naming::to_ident(&chosen, crate::naming::Case::Pascal);
483        if direction_split_schemas(&doc).contains(name) {
484            let shape = ident.logical();
485            return Err(Error::UnsupportedRef {
486                reference: reference.to_owned(),
487                reason: format!(
488                    "`{name}` in `{file}` marks a property `readOnly` or `writeOnly`, so the run that \
489                     generates that file emits it as `{shape}{REQUEST_SUFFIX}` and `{shape}{RESPONSE_SUFFIX}`. \
490                     This run cannot tell which of the two an `import-mapping` reference means. Declare \
491                     the schema in this document instead, or drop the mark"
492                ),
493            });
494        }
495        for (other, other_entry) in schemas.into_iter().flatten() {
496            if other == name {
497                continue;
498            }
499            let taken = external_name_of(other_entry, other)?;
500            if crate::naming::to_ident(&taken, crate::naming::Case::Pascal).logical() == ident.logical() {
501                return Err(Error::UnsupportedRef {
502                    reference: reference.to_owned(),
503                    reason: format!(
504                        "`{file}` gives `{name}` and `{other}` the one Rust name `{}`",
505                        ident.logical()
506                    ),
507                });
508            }
509        }
510        return Ok(chosen);
511    }
512}
513
514/// The schemas in `doc` that the direction pass splits into a request shape and
515/// a response shape.
516///
517/// A schema qualifies when a property inside it carries a direction mark, or
518/// when it reaches such a schema through a same-document `$ref`.
519///
520/// A mark on a schema itself does not qualify that schema. The direction pass
521/// splits a model only when one of its properties is directional, so a marked
522/// primitive keeps its one name and only its referrers split.
523///
524/// This reads the document alone, because both runs that compose a crate must
525/// reach the same verdict. Only one of the two holds the operations.
526fn direction_split_schemas(doc: &OpenAPI) -> std::collections::BTreeSet<String> {
527    let mut marked = std::collections::BTreeSet::new();
528    let Some(components) = doc.components.as_ref() else {
529        return marked;
530    };
531
532    // A component that `resolve` follows to a marked schema carries the mark,
533    // whether it is that schema or an alias chain that ends at it. The chain is
534    // followed the way `resolve` follows it, with the same depth, so the two
535    // agree on a chain at the limit.
536    let directional: std::collections::BTreeSet<String> = components
537        .schemas
538        .keys()
539        .filter(|name| {
540            return resolved_component(&components.schemas, name)
541                .is_some_and(|schema| return schema.schema_data.read_only || schema.schema_data.write_only);
542        })
543        .cloned()
544        .collect();
545
546    let mut referrers: HashMap<String, Vec<String>> = HashMap::new();
547    for (name, entry) in &components.schemas {
548        match entry {
549            ReferenceOr::Item(schema) => {
550                if schema_marks_a_direction(schema, &directional) {
551                    marked.insert(name.clone());
552                }
553                let mut targets = Vec::new();
554                schema_local_refs(schema, &mut targets);
555                for target in targets {
556                    referrers.entry(target).or_default().push(name.clone());
557                }
558            }
559            // A component declared as a `$ref` is an alias. An alias to a split
560            // model splits as well, so it has to reach the closure below.
561            ReferenceOr::Reference { reference } => {
562                if let Some(target) = ref_target_name(reference) {
563                    referrers.entry(target.to_owned()).or_default().push(name.clone());
564                }
565            }
566        }
567    }
568
569    let mut stack: Vec<String> = marked.iter().cloned().collect();
570    while let Some(name) = stack.pop() {
571        let Some(parents) = referrers.get(&name) else {
572            continue;
573        };
574        for parent in parents {
575            if marked.insert(parent.clone()) {
576                stack.push(parent.clone());
577            }
578        }
579    }
580    return marked;
581}
582
583/// Whether a property of `schema`, or of a schema written inside it, carries a
584/// direction mark.
585///
586/// `directional` holds the component schemas that set `readOnly` or `writeOnly`
587/// on themselves. A property that names one of those carries the mark too.
588fn schema_marks_a_direction(schema: &Schema, directional: &std::collections::BTreeSet<String>) -> bool {
589    let mut found = declares_a_directional_property(schema, directional);
590    walk_inline_schemas(schema, &mut |inner| {
591        found = found || declares_a_directional_property(inner, directional);
592    });
593    return found;
594}
595
596/// Whether one schema declares a property that only one direction carries.
597fn declares_a_directional_property(schema: &Schema, directional: &std::collections::BTreeSet<String>) -> bool {
598    let names_a_marked_schema = |reference: &str| {
599        return ref_file_part(reference).is_none()
600            && ref_component_name(reference, "schemas").is_some_and(|name| return directional.contains(name));
601    };
602    return object_properties(schema).iter().any(|entry| {
603        return match entry {
604            // A one-member `allOf` adds keywords to a `$ref` and changes nothing
605            // else, so the mark of the schema it names still applies.
606            ReferenceOr::Item(inner) => {
607                inner.schema_data.read_only
608                    || inner.schema_data.write_only
609                    || single_ref_member(inner).is_some_and(names_a_marked_schema)
610            }
611            ReferenceOr::Reference { reference } => names_a_marked_schema(reference),
612        };
613    });
614}
615
616/// The schema a component name resolves to, through same-document aliases.
617///
618/// This mirrors [`Spec::resolve`]: each name costs one step, the schema itself
619/// included, and a chain that needs more than `MAX_REF_DEPTH` steps resolves to
620/// nothing.
621fn resolved_component<'a>(schemas: &'a IndexMap<String, ReferenceOr<Schema>>, name: &str) -> Option<&'a Schema> {
622    let mut current = name;
623    for _ in 0..MAX_REF_DEPTH {
624        match schemas.get(current)? {
625            ReferenceOr::Item(schema) => return Some(schema),
626            ReferenceOr::Reference { reference } => {
627                if ref_file_part(reference).is_some() {
628                    return None;
629                }
630                current = ref_component_name(reference, "schemas")?;
631            }
632        }
633    }
634    return None;
635}
636
637/// The reference a one-member `allOf` wraps, when that member is a `$ref`.
638///
639/// OpenAPI 3.0 ignores a keyword beside a `$ref`, so a document wraps the
640/// `$ref` in a one-member `allOf` to give it a `description` or `nullable`.
641pub(crate) fn single_ref_member(schema: &Schema) -> Option<&str> {
642    let openapiv3::SchemaKind::AllOf { all_of } = &schema.schema_kind else {
643        return None;
644    };
645    let [ReferenceOr::Reference { reference }] = all_of.as_slice() else {
646        return None;
647    };
648    return Some(reference);
649}
650
651/// The schemas one schema declares as properties.
652fn object_properties(schema: &Schema) -> Vec<ReferenceOr<Schema>> {
653    let unbox = |entry: &ReferenceOr<Box<Schema>>| {
654        return match entry {
655            ReferenceOr::Item(inner) => ReferenceOr::Item((**inner).clone()),
656            ReferenceOr::Reference { reference } => ReferenceOr::Reference {
657                reference: reference.clone(),
658            },
659        };
660    };
661    return match &schema.schema_kind {
662        openapiv3::SchemaKind::Type(openapiv3::Type::Object(object)) => {
663            return object.properties.values().map(&unbox).collect();
664        }
665        openapiv3::SchemaKind::Any(any) => return any.properties.values().map(&unbox).collect(),
666        openapiv3::SchemaKind::Type(_)
667        | openapiv3::SchemaKind::OneOf { .. }
668        | openapiv3::SchemaKind::AllOf { .. }
669        | openapiv3::SchemaKind::AnyOf { .. }
670        | openapiv3::SchemaKind::Not { .. } => Vec::new(),
671    };
672}
673
674/// The same-document component schemas a schema names, at any depth.
675fn schema_local_refs(schema: &Schema, out: &mut Vec<String>) {
676    let mut collect = |entry: &ReferenceOr<Schema>| {
677        if let ReferenceOr::Reference { reference } = entry
678            && ref_file_part(reference).is_none()
679            && let Some(name) = ref_component_name(reference, "schemas")
680        {
681            out.push(name.to_owned());
682        }
683    };
684    for entry in inline_members(schema) {
685        collect(&entry);
686    }
687    walk_inline_schemas(schema, &mut |inner| {
688        for entry in inline_members(inner) {
689            collect(&entry);
690        }
691    });
692}
693
694/// Apply `visit` to every schema written inline within `schema`, at any depth.
695fn walk_inline_schemas(schema: &Schema, visit: &mut impl FnMut(&Schema)) {
696    for entry in inline_members(schema) {
697        if let ReferenceOr::Item(inner) = entry {
698            visit(&inner);
699            walk_inline_schemas(&inner, visit);
700        }
701    }
702}
703
704/// The schemas one schema holds directly: its properties, its element type, its
705/// `additionalProperties`, and its composition members.
706fn inline_members(schema: &Schema) -> Vec<ReferenceOr<Schema>> {
707    let unbox = |entry: &ReferenceOr<Box<Schema>>| {
708        return match entry {
709            ReferenceOr::Item(inner) => ReferenceOr::Item((**inner).clone()),
710            ReferenceOr::Reference { reference } => ReferenceOr::Reference {
711                reference: reference.clone(),
712            },
713        };
714    };
715    let additional = |entry: &Option<openapiv3::AdditionalProperties>| {
716        return match entry {
717            Some(openapiv3::AdditionalProperties::Schema(inner)) => vec![(**inner).clone()],
718            Some(openapiv3::AdditionalProperties::Any(_)) | None => Vec::new(),
719        };
720    };
721    let mut members = Vec::new();
722    match &schema.schema_kind {
723        openapiv3::SchemaKind::Type(openapiv3::Type::Object(object)) => {
724            members.extend(object.properties.values().map(&unbox));
725            members.extend(additional(&object.additional_properties));
726        }
727        openapiv3::SchemaKind::Type(openapiv3::Type::Array(array)) => {
728            members.extend(array.items.as_ref().map(unbox));
729        }
730        openapiv3::SchemaKind::Type(_) => {}
731        openapiv3::SchemaKind::OneOf { one_of } => members.extend(one_of.iter().cloned()),
732        openapiv3::SchemaKind::AllOf { all_of } => members.extend(all_of.iter().cloned()),
733        openapiv3::SchemaKind::AnyOf { any_of } => members.extend(any_of.iter().cloned()),
734        openapiv3::SchemaKind::Not { not } => members.push((**not).clone()),
735        openapiv3::SchemaKind::Any(any) => {
736            members.extend(any.properties.values().map(&unbox));
737            members.extend(additional(&any.additional_properties));
738            members.extend(any.items.as_ref().map(unbox));
739            members.extend(any.one_of.iter().cloned());
740            members.extend(any.all_of.iter().cloned());
741            members.extend(any.any_of.iter().cloned());
742            members.extend(any.not.as_ref().map(|not| return (**not).clone()));
743        }
744    }
745    return members;
746}
747
748/// The name a schema in a referenced document declares for itself, honouring
749/// `x-rust-name`.
750fn external_name_of(entry: &ReferenceOr<Schema>, name: &str) -> Result<String> {
751    let renamed = match entry {
752        ReferenceOr::Item(schema) => {
753            crate::lower::extension::str_value(&schema.schema_data.extensions, crate::naming::X_RUST_NAME, name)?
754        }
755        ReferenceOr::Reference { .. } => None,
756    };
757    return Ok(renamed.unwrap_or(name).to_owned());
758}
759
760/// Reject a document whose `openapi:` value is not a patch release of a minor
761/// version in [`SUPPORTED_SPEC_VERSIONS`].
762///
763/// The parser behind the generator ignores the `openapi:` value, so without this
764/// check it reads whatever subset of an unsupported document happens to match a
765/// supported dialect, and reports nothing. A document that generates in part and
766/// fails in part is worse than one that fails at once, because the part that
767/// generates looks correct.
768///
769/// The check runs on the untyped tree, before the typed parse. A construct that
770/// only a newer version defines fails that parse with a message about a YAML
771/// shape and not about a version.
772///
773/// A document that declares no `openapi:` key, or declares it as a non-string,
774/// passes here. The typed parse reports that with a message that points at a line.
775fn check_spec_version(document: &str, value: &serde_yaml::Value) -> Result<()> {
776    let Some(version) = value.get("openapi").and_then(serde_yaml::Value::as_str) else {
777        return Ok(());
778    };
779    let version = version.trim();
780    // A patch part is optional in practice, so `3.0` and `3.0.3` both pass. The
781    // dot guards against a future `3.00` reading as `3.0`.
782    let supported = SUPPORTED_SPEC_VERSIONS.iter().any(|minor| {
783        return version == *minor || version.starts_with(&format!("{minor}."));
784    });
785    if supported {
786        return Ok(());
787    }
788    let reads = SUPPORTED_SPEC_VERSIONS
789        .iter()
790        .map(|minor| {
791            return format!("{minor}.x");
792        })
793        .collect::<Vec<_>>()
794        .join(", ");
795    return Err(Error::UnsupportedSpecVersion {
796        document: document.to_owned(),
797        version: version.to_owned(),
798        hint: format!("The generator reads OpenAPI {reads} only."),
799    });
800}
801
802/// Reject a document that declares a top-level key holding operations the
803/// generator cannot emit.
804///
805/// This reads the untyped tree, because the typed `OpenAPI` form drops every key
806/// it does not know and a dropped key cannot be reported. A document that is not
807/// a mapping passes here. The typed parse that follows reports that shape with
808/// its own message, which points at the line.
809fn check_top_level_keys(value: &serde_yaml::Value) -> Result<()> {
810    let Some(mapping) = value.as_mapping() else {
811        return Ok(());
812    };
813    for key in UNSUPPORTED_TOP_LEVEL_KEYS {
814        if mapping.contains_key(serde_yaml::Value::String(key.to_owned())) {
815            return Err(Error::UnsupportedSpecKey {
816                key: key.to_owned(),
817                reason: "declares operations the generator cannot emit".to_owned(),
818                hint: format!(
819                    "Remove `{key}:`, or move its operations under `paths:`. Silently ignoring it emits no handler for any operation it holds."
820                ),
821            });
822        }
823    }
824    return Ok(());
825}
826
827/// Extract the trailing schema name from a *same-document* `$ref`
828/// (`#/components/schemas/Foo` -> `Foo`).
829///
830/// Cross-file references (for example `schemas/x.yaml#/components/schemas/Foo`) yield
831/// `None`: the models pipeline and the same-document `$ref` resolver only handle
832/// in-document schemas, so accepting a cross-file name here will risk emitting a
833/// local `Named` type for what is actually external. The server generator reads
834/// cross-file names via [`ref_component_name`] paired with [`ref_file_part`].
835pub fn ref_target_name(reference: &str) -> Option<&str> {
836    if ref_file_part(reference).is_some() {
837        return None;
838    }
839    return ref_component_name(reference, "schemas");
840}
841
842/// The reason a schema `$ref` at `site` gives no name.
843///
844/// [`ref_target_name`] answers `None` for two different faults, and the remedy
845/// differs. A cross-file ref names a schema and still fails, so a message that
846/// asks the author to reference a schema misleads. Name the fault instead.
847///
848/// `site` reads into the sentence, for example `a property`.
849pub fn schema_ref_reason(reference: &str, site: &str) -> String {
850    if ref_file_part(reference).is_some() {
851        return format!("a cross-file ref does not resolve at {site}");
852    }
853    return format!("{site} must reference `#/components/schemas/<name>`");
854}
855
856/// Extract the trailing component name of the given `kind` (`schemas`,
857/// `responses`, `parameters`, or `requestBodies`) from a (possibly cross-file)
858/// `$ref`.
859pub fn ref_component_name<'a>(reference: &'a str, kind: &str) -> Option<&'a str> {
860    let fragment = reference.split('#').nth(1).unwrap_or(reference);
861    let prefix = match kind {
862        "schemas" => "/components/schemas/",
863        "responses" => "/components/responses/",
864        "parameters" => "/components/parameters/",
865        "requestBodies" => "/components/requestBodies/",
866        _ => return None,
867    };
868    let name = fragment.strip_prefix(prefix)?;
869    if name.is_empty() || name.contains('/') {
870        return None;
871    }
872    return Some(name);
873}
874
875/// The file part of a cross-file `$ref` (the text before `#`), or `None` for a
876/// same-document reference such as `#/components/schemas/Foo`.
877pub fn ref_file_part(reference: &str) -> Option<&str> {
878    return match reference.split_once('#') {
879        Some((file, _fragment)) if !file.is_empty() => Some(file),
880        _ => None,
881    };
882}
883
884#[cfg(test)]
885mod tests {
886    use super::*;
887
888    struct TestDir {
889        path: PathBuf,
890    }
891
892    impl TestDir {
893        fn new(test_name: &str) -> Self {
894            let unique = format!(
895                "oapi-codegen-loader-{test_name}-{}-{}",
896                std::process::id(),
897                std::time::SystemTime::now()
898                    .duration_since(std::time::UNIX_EPOCH)
899                    .expect("system clock should be after Unix epoch")
900                    .as_nanos(),
901            );
902            let path = std::env::temp_dir().join(unique);
903            std::fs::create_dir_all(&path).expect("create test directory");
904            return Self { path };
905        }
906
907        fn write(&self, file: &str, contents: &str) -> PathBuf {
908            let path = self.path.join(file);
909            if let Some(parent) = path.parent() {
910                std::fs::create_dir_all(parent).expect("create parent directory");
911            }
912            std::fs::write(&path, contents).expect("write test file");
913            return path;
914        }
915    }
916
917    impl Drop for TestDir {
918        fn drop(&mut self) {
919            let _ = std::fs::remove_dir_all(&self.path);
920        }
921    }
922
923    fn parse_openapi(yaml: &str) -> OpenAPI {
924        return serde_yaml::from_str(yaml).expect("parse OpenAPI document");
925    }
926
927    fn assert_query_parameter_name(parameter: &Parameter, expected: &str) {
928        match parameter {
929            Parameter::Query { parameter_data, .. } => {
930                assert_eq!(parameter_data.name, expected);
931            }
932            _ => panic!("expected a query parameter"),
933        }
934    }
935
936    fn minimal_doc() -> &'static str {
937        return "openapi: 3.0.3\ninfo:\n  title: t\n  version: '1'\npaths: {}\n";
938    }
939
940    fn shared_parameter_doc(name: &str) -> String {
941        return format!(
942            "openapi: 3.0.3\ninfo:\n  title: shared\n  version: '1'\npaths: {{}}\ncomponents:\n  parameters:\n    PageSize:\n      name: {name}\n      in: query\n      schema:\n        type: integer\n",
943        );
944    }
945
946    #[test]
947    fn ref_target_name_is_same_document_schemas_only() {
948        assert_eq!(ref_target_name("#/components/schemas/Foo"), Some("Foo"));
949        // Cross-file schema refs are rejected here. The server path resolves
950        // them via `ref_component_name` + `ref_file_part` instead.
951        assert_eq!(
952            ref_target_name("schemas/common.yaml#/components/schemas/ErrorResponse"),
953            None
954        );
955        assert_eq!(ref_target_name("#/components/responses/Bar"), None);
956    }
957
958    /// The two faults behind a `None` from [`ref_target_name`] need different
959    /// remedies, so the reason must tell them apart.
960    #[test]
961    fn a_schema_ref_reason_names_the_fault() {
962        assert_eq!(
963            schema_ref_reason("common.yaml#/components/schemas/X", "a property"),
964            "a cross-file ref does not resolve at a property"
965        );
966        assert_eq!(
967            schema_ref_reason("#/components/responses/Bar", "a property"),
968            "a property must reference `#/components/schemas/<name>`"
969        );
970    }
971
972    #[test]
973    fn extracts_component_responses_and_file_parts() {
974        assert_eq!(
975            ref_component_name("#/components/responses/Bar", "responses"),
976            Some("Bar")
977        );
978        assert_eq!(ref_component_name("#/components/schemas/Foo", "responses"), None);
979        assert_eq!(ref_file_part("#/components/schemas/Foo"), None);
980        assert_eq!(
981            ref_file_part("schemas/common.yaml#/components/schemas/ErrorResponse"),
982            Some("schemas/common.yaml"),
983        );
984    }
985
986    #[test]
987    fn ref_component_name_recognizes_parameters_and_request_bodies() {
988        assert_eq!(
989            ref_component_name("#/components/parameters/PageSize", "parameters"),
990            Some("PageSize")
991        );
992        assert_eq!(
993            ref_component_name("#/components/requestBodies/CreateWidget", "requestBodies"),
994            Some("CreateWidget")
995        );
996        assert_eq!(ref_component_name("#/components/schemas/Foo", "parameters"), None);
997    }
998
999    #[test]
1000    fn resolves_same_document_component_parameter() {
1001        let yaml = "openapi: 3.0.3\ninfo:\n  title: t\n  version: '1'\npaths: {}\ncomponents:\n  parameters:\n    PageSize:\n      name: pageSize\n      in: query\n      schema:\n        type: integer\n";
1002        let doc = parse_openapi(yaml);
1003        let spec = Spec::from_parts(doc, std::path::PathBuf::from("inline.yaml"));
1004        let param = spec
1005            .resolve_parameter("#/components/parameters/PageSize")
1006            .expect("resolve");
1007        assert!(param.origin.is_none());
1008        assert_query_parameter_name(&param.value, "pageSize");
1009    }
1010
1011    #[test]
1012    fn errors_on_cross_file_ref_to_missing_file() {
1013        let source = std::env::temp_dir().join(format!(
1014            "oapi-codegen-loader-missing-{}-{}.yaml",
1015            std::process::id(),
1016            std::time::SystemTime::now()
1017                .duration_since(std::time::UNIX_EPOCH)
1018                .expect("system clock should be after Unix epoch")
1019                .as_nanos(),
1020        ));
1021        let spec = Spec::from_parts(parse_openapi(minimal_doc()), source);
1022        let result = spec.resolve_parameter("common.yaml#/components/parameters/PageSize");
1023        assert!(matches!(result, Err(Error::ReadRefFile { .. })));
1024    }
1025
1026    #[test]
1027    fn unresolved_component_error_preserves_the_full_reference() {
1028        // A same-document miss reports the full `$ref` fragment (kind + name),
1029        // not the bare component name.
1030        let yaml = "openapi: 3.0.3\ninfo:\n  title: t\n  version: '1'\npaths: {}\ncomponents:\n  parameters: {}\n";
1031        let spec = Spec::from_parts(parse_openapi(yaml), std::path::PathBuf::from("inline.yaml"));
1032        let result = spec.resolve_parameter("#/components/parameters/Missing");
1033        match result {
1034            Err(Error::UnresolvedRef(reference)) => {
1035                assert_eq!(reference, "#/components/parameters/Missing");
1036            }
1037            other => panic!("expected UnresolvedRef, got {other:?}"),
1038        }
1039
1040        // A cross-file miss reports the file plus the full fragment.
1041        let dir = TestDir::new("unresolved-cross-file");
1042        let main = dir.write("main.yaml", minimal_doc());
1043        dir.write("shared.yaml", minimal_doc());
1044        let spec = Spec::load(&main).expect("load main spec");
1045        let result = spec.resolve_parameter("shared.yaml#/components/parameters/Missing");
1046        match result {
1047            Err(Error::UnresolvedRef(reference)) => {
1048                assert_eq!(reference, "shared.yaml#/components/parameters/Missing");
1049            }
1050            other => panic!("expected UnresolvedRef, got {other:?}"),
1051        }
1052    }
1053
1054    #[test]
1055    fn document_for_reads_sibling_and_caches() {
1056        let dir = TestDir::new("document-for-caches");
1057        let main = dir.write("main.yaml", minimal_doc());
1058        dir.write("shared.yaml", &shared_parameter_doc("pageSize"));
1059
1060        let spec = Spec::load(&main).expect("load main spec");
1061        let param = spec
1062            .resolve_parameter("shared.yaml#/components/parameters/PageSize")
1063            .expect("resolve cross-file parameter");
1064        assert_eq!(param.origin.as_deref(), Some("shared.yaml"));
1065        assert_query_parameter_name(&param.value, "pageSize");
1066        assert_eq!(spec.docs.borrow().len(), 1);
1067
1068        let second = spec
1069            .resolve_parameter("shared.yaml#/components/parameters/PageSize")
1070            .expect("resolve cached cross-file parameter");
1071        assert_eq!(second.origin.as_deref(), Some("shared.yaml"));
1072        assert_query_parameter_name(&second.value, "pageSize");
1073        assert_eq!(spec.docs.borrow().len(), 1);
1074    }
1075
1076    #[test]
1077    fn cross_file_chain_across_two_files_resolves() {
1078        let dir = TestDir::new("cross-file-chain");
1079        let main = dir.write("main.yaml", minimal_doc());
1080        dir.write(
1081            "a.yaml",
1082            "openapi: 3.0.3\ninfo:\n  title: a\n  version: '1'\npaths: {}\ncomponents:\n  parameters:\n    X:\n      $ref: \"b.yaml#/components/parameters/Y\"\n",
1083        );
1084        dir.write(
1085            "b.yaml",
1086            "openapi: 3.0.3\ninfo:\n  title: b\n  version: '1'\npaths: {}\ncomponents:\n  parameters:\n    Y:\n      name: cursor\n      in: query\n      schema:\n        type: string\n",
1087        );
1088
1089        let spec = Spec::load(&main).expect("load main spec");
1090        let param = spec
1091            .resolve_parameter("a.yaml#/components/parameters/X")
1092            .expect("resolve cross-file chain");
1093        assert_eq!(param.origin.as_deref(), Some("b.yaml"));
1094        assert_query_parameter_name(&param.value, "cursor");
1095    }
1096
1097    #[test]
1098    fn cross_file_cycle_terminates() {
1099        let dir = TestDir::new("cross-file-cycle");
1100        let main = dir.write("main.yaml", minimal_doc());
1101        dir.write(
1102            "a.yaml",
1103            "openapi: 3.0.3\ninfo:\n  title: a\n  version: '1'\npaths: {}\ncomponents:\n  parameters:\n    X:\n      $ref: \"b.yaml#/components/parameters/Y\"\n",
1104        );
1105        dir.write(
1106            "b.yaml",
1107            "openapi: 3.0.3\ninfo:\n  title: b\n  version: '1'\npaths: {}\ncomponents:\n  parameters:\n    Y:\n      $ref: \"a.yaml#/components/parameters/X\"\n",
1108        );
1109
1110        let spec = Spec::load(&main).expect("load main spec");
1111        let result = spec.resolve_parameter("a.yaml#/components/parameters/X");
1112        assert!(matches!(result, Err(Error::UnresolvedRef(_))));
1113    }
1114
1115    #[test]
1116    fn resolve_schema_against_origin() {
1117        let dir = TestDir::new("resolve-schema-origin");
1118        let main = dir.write("main.yaml", minimal_doc());
1119        dir.write(
1120            "shared.yaml",
1121            "openapi: 3.0.3\ninfo:\n  title: shared\n  version: '1'\npaths: {}\ncomponents:\n  schemas:\n    PageInfo:\n      type: integer\n      format: int32\n",
1122        );
1123
1124        let spec = Spec::load(&main).expect("load main spec");
1125        let schema = spec
1126            .resolve_schema(Some("shared.yaml"), "#/components/schemas/PageInfo")
1127            .expect("resolve schema from origin");
1128        assert!(matches!(
1129            schema.schema_kind,
1130            openapiv3::SchemaKind::Type(openapiv3::Type::Integer(_))
1131        ));
1132    }
1133
1134    /// A mark on a schema itself splits the schemas that name it, not the schema.
1135    ///
1136    /// The direction pass splits a model only when one of its properties is
1137    /// directional, so a marked primitive stays one type alias. Reporting it as
1138    /// split rejects an `import-mapping` reference that resolves.
1139    #[test]
1140    fn a_schema_level_mark_splits_only_the_referrers() {
1141        let doc: OpenAPI = serde_yaml::from_str(
1142            "openapi: 3.0.3\ninfo:\n  title: t\n  version: '1'\npaths: {}\ncomponents:\n  schemas:\n    Marked:\n      type: string\n      readOnly: true\n    Holder:\n      type: object\n      properties:\n        a:\n          $ref: '#/components/schemas/Marked'\n    Bystander:\n      type: object\n      properties:\n        b:\n          type: string\n",
1143        )
1144        .expect("parse doc");
1145
1146        let split = direction_split_schemas(&doc);
1147        assert!(split.contains("Holder"), "a property that names a marked schema splits");
1148        assert!(!split.contains("Marked"), "a marked primitive keeps its one name");
1149        assert!(!split.contains("Bystander"), "an unrelated schema keeps its one name");
1150    }
1151
1152    /// A component declared as a `$ref` is an alias, and an alias to a split
1153    /// model splits as well.
1154    ///
1155    /// Missing one emits a reference to a name the models run never writes, so
1156    /// the composed crate does not build.
1157    #[test]
1158    fn an_alias_to_a_split_model_splits_as_well() {
1159        let doc: OpenAPI = serde_yaml::from_str(
1160            "openapi: 3.0.3\ninfo:\n  title: t\n  version: '1'\npaths: {}\ncomponents:\n  schemas:\n    Marked:\n      type: object\n      properties:\n        id:\n          type: string\n          readOnly: true\n    Alias:\n      $ref: '#/components/schemas/Marked'\n    Plain:\n      type: object\n      properties:\n        a:\n          type: string\n    PlainAlias:\n      $ref: '#/components/schemas/Plain'\n",
1161        )
1162        .expect("parse doc");
1163
1164        let split = direction_split_schemas(&doc);
1165        assert!(split.contains("Marked"), "the model that declares the mark splits");
1166        assert!(split.contains("Alias"), "an alias to a split model splits");
1167        assert!(!split.contains("Plain"), "an unmarked model keeps its one name");
1168        assert!(
1169            !split.contains("PlainAlias"),
1170            "an alias to an unmarked model keeps its one name"
1171        );
1172    }
1173
1174    /// `resolve` follows an alias to the schema behind it, so a property that
1175    /// names the alias takes the mark of that schema. The split set must agree,
1176    /// or an `import-mapping` run would emit a name the models run never writes.
1177    #[test]
1178    fn an_alias_chain_to_a_marked_schema_splits_the_referrer() {
1179        let doc: OpenAPI = serde_yaml::from_str(
1180            "openapi: 3.0.3\ninfo:\n  title: t\n  version: '1'\npaths: {}\ncomponents:\n  schemas:\n    Marked:\n      type: string\n      readOnly: true\n    Alias:\n      $ref: '#/components/schemas/Marked'\n    Twice:\n      $ref: '#/components/schemas/Alias'\n    Holder:\n      type: object\n      properties:\n        a:\n          $ref: '#/components/schemas/Alias'\n    Wrapped:\n      type: object\n      properties:\n        a:\n          allOf:\n            - $ref: '#/components/schemas/Twice'\n",
1181        )
1182        .expect("parse doc");
1183
1184        let split = direction_split_schemas(&doc);
1185        assert!(split.contains("Holder"), "a name of an alias to a marked schema splits");
1186        assert!(split.contains("Wrapped"), "a wrapped name of a two-step alias splits");
1187        assert!(
1188            !split.contains("Alias") && !split.contains("Twice"),
1189            "an alias to a primitive keeps its one name"
1190        );
1191    }
1192
1193    /// `resolve` follows at most `MAX_REF_DEPTH` names, the schema itself
1194    /// included. A chain one link longer resolves to nothing in the lowering,
1195    /// which then reads no mark, so the split set must read none either.
1196    #[test]
1197    fn an_alias_chain_at_the_depth_limit_splits_and_one_link_longer_does_not() {
1198        let chain = |links: usize, reversed: bool| {
1199            let mut schemas: Vec<String> = vec!["    Marked:\n      type: string\n      readOnly: true\n".to_owned()];
1200            for index in 1..=links {
1201                let target = if index == links {
1202                    "Marked".to_owned()
1203                } else {
1204                    format!("Alias{}", index + 1)
1205                };
1206                schemas.push(format!(
1207                    "    Alias{index}:\n      $ref: '#/components/schemas/{target}'\n"
1208                ));
1209            }
1210            if reversed {
1211                schemas.reverse();
1212            }
1213            return format!(
1214                "openapi: 3.0.3\ninfo:\n  title: t\n  version: '1'\npaths: {{}}\ncomponents:\n  schemas:\n{}    Holder:\n      type: object\n      properties:\n        a:\n          $ref: '#/components/schemas/Alias1'\n",
1215                schemas.concat()
1216            );
1217        };
1218        for (links, splits) in [(MAX_REF_DEPTH - 1, true), (MAX_REF_DEPTH, false)] {
1219            for reversed in [false, true] {
1220                let doc: OpenAPI = serde_yaml::from_str(&chain(links, reversed)).expect("parse doc");
1221                let split = direction_split_schemas(&doc);
1222                assert_eq!(split.contains("Holder"), splits, "{links} links, reversed: {reversed}");
1223            }
1224        }
1225    }
1226
1227    /// A one-member `allOf` around a `$ref` names the same schema as the bare
1228    /// `$ref`, so it splits the same models.
1229    #[test]
1230    fn a_wrapped_reference_to_a_marked_schema_splits_the_referrer() {
1231        let doc: OpenAPI = serde_yaml::from_str(
1232            "openapi: 3.0.3\ninfo:\n  title: t\n  version: '1'\npaths: {}\ncomponents:\n  schemas:\n    Marked:\n      type: string\n      readOnly: true\n    Plain:\n      type: string\n    Holder:\n      type: object\n      properties:\n        a:\n          allOf:\n            - $ref: '#/components/schemas/Marked'\n          description: wrapped\n    Bystander:\n      type: object\n      properties:\n        b:\n          allOf:\n            - $ref: '#/components/schemas/Plain'\n",
1233        )
1234        .expect("parse doc");
1235
1236        let split = direction_split_schemas(&doc);
1237        assert!(split.contains("Holder"), "a wrapped name of a marked schema splits");
1238        assert!(
1239            !split.contains("Bystander"),
1240            "a wrapped name of a plain schema does not"
1241        );
1242    }
1243
1244    /// A mark on a property splits the model that declares it, and every model
1245    /// that reaches it.
1246    #[test]
1247    fn a_property_mark_splits_the_declaring_model_and_its_referrers() {
1248        let doc: OpenAPI = serde_yaml::from_str(
1249            "openapi: 3.0.3\ninfo:\n  title: t\n  version: '1'\npaths: {}\ncomponents:\n  schemas:\n    Leaf:\n      type: object\n      properties:\n        id:\n          type: string\n          readOnly: true\n    Parent:\n      type: object\n      properties:\n        leaf:\n          $ref: '#/components/schemas/Leaf'\n",
1250        )
1251        .expect("parse doc");
1252
1253        let split = direction_split_schemas(&doc);
1254        assert!(split.contains("Leaf"), "the model that declares the mark splits");
1255        assert!(split.contains("Parent"), "a model that reaches the mark splits");
1256    }
1257}