Skip to main content

oapi_codegen/
lib.rs

1//! `oapi-codegen` — generate idiomatic Rust from OpenAPI 3 specifications.
2//!
3//! The pipeline is: load a spec ([`loader`]), lower its component schemas into
4//! an intermediate representation ([`lower::schema`] → [`ir`]) and, for the server
5//! generator, its operations ([`lower::paths`] → [`ir`]), then emit formatted Rust
6//! source ([`emit`]). [`Config`] mirrors `oapi-codegen`'s YAML configuration.
7
8pub mod cli;
9pub mod config;
10mod coverage;
11pub mod deps;
12mod diagnostic;
13pub mod emit;
14pub mod error;
15pub mod filter;
16pub mod ir;
17pub mod loader;
18pub mod lower;
19pub mod naming;
20pub mod package;
21
22use std::path::Path;
23
24pub use crate::config::Config;
25pub use crate::error::Error;
26pub use crate::error::Result;
27use crate::ir::Module;
28use crate::ir::ServerUrls;
29use crate::ir::Service;
30use crate::loader::Spec;
31pub use crate::package::GeneratedFile;
32pub use crate::package::GeneratedPackage;
33pub use crate::package::PackageDrift;
34pub use crate::package::check_package;
35pub use crate::package::write_package;
36
37/// The number of warnings this process reported, for the configuration and
38/// for the spec together.
39///
40/// A warning goes to stderr and does not fail a run. The CLI reads this count
41/// for `--deny-warnings`, which turns a warning into a failed run.
42pub fn warnings_reported() -> usize {
43    return diagnostic::reported_count();
44}
45
46/// Everything one run lowers from a spec, ready for either output layout.
47enum Lowered {
48    /// The run emits models, and optionally server-URL constants, only.
49    Models {
50        /// The component models to emit.
51        module: Module,
52        /// The server-URL items, when the feature is enabled.
53        server_urls: Option<ServerUrls>,
54    },
55    /// The run emits a service: models, per-operation types, and at least one
56    /// of the server and client interfaces.
57    Service {
58        /// The component models the service references.
59        module: Module,
60        /// The lowered operations.
61        service: Service,
62        /// The server-URL items, when the feature is enabled.
63        server_urls: Option<ServerUrls>,
64        /// Which generator interfaces the configuration asked for.
65        targets: emit::Targets,
66    },
67}
68
69/// Load a spec and lower it according to `config`, stopping before emission.
70///
71/// Both output layouts run exactly the same pipeline, so this holds every step
72/// between loading and emitting: filtering, name resolution, lowering, pruning,
73/// and the validation passes that reject a spec the emitter cannot express.
74fn lower_spec(spec_path: &Path, config: &Config) -> Result<Lowered> {
75    if config.generate.embedded_spec {
76        return Err(Error::Unimplemented("embedded-spec".to_owned()));
77    }
78    let want_server = config.generate.std_http_server;
79    let mut spec = Spec::load_with(spec_path, &config.generate, &config.output_options)?;
80    spec.apply_filters(&config.output_options);
81    let want_client = config.generate.client;
82    let server_urls = if config.generate.server_urls {
83        lower::lower_server_urls(&spec)?
84    } else {
85        None
86    };
87    // A run that emits no type resolves no type name. `server-urls` on its own
88    // emits constants only, so it must not read `type-name-suffix` and must not
89    // report a collision between two schemas that it never looks at. This matches
90    // `response-type-suffix`, which only a server or client run reads.
91    if !(config.generate.models || want_server || want_client) {
92        return Ok(Lowered::Models {
93            module: Module::default(),
94            server_urls,
95        });
96    }
97    // A set but useless suffix is an error, and not silently "unset". The
98    // resolution checks it, so every caller of `type_renames` gets the check.
99    // See `lower::rename::checked_suffix`.
100    let type_name_suffix = config.output_options.type_name_suffix.as_deref();
101    // Resolve the type names one time and share them. An unresolved collision is
102    // held inside `names` and reported below, after pruning decides which models
103    // the file holds.
104    let names = lower::type_renames(&spec, type_name_suffix)?;
105    let mut module = lower::generate_models(&spec, &names)?;
106    if want_server || want_client {
107        let response_type_suffix = config
108            .output_options
109            .response_type_suffix
110            .as_deref()
111            .filter(|suffix| return !suffix.is_empty())
112            .unwrap_or(crate::config::DEFAULT_RESPONSE_SUFFIX);
113        let mut service = lower::generate_service(&spec, &config.import_mapping, response_type_suffix)?;
114        lower::rewrite_service(&mut service, names.renames());
115        // Runs before the prune pass, which drops a shape no operation reaches,
116        // and before the name checks, which then see the projected names.
117        lower::split_by_direction(&mut module, Some(&mut service));
118        if !config.output_options.skip_prune {
119            lower::prune_unused_models(&mut module, &service);
120        }
121        // The module is final here, so a collision between two pruned schemas is
122        // no longer a problem and only a surviving one is reported. With
123        // `skip-prune` the module holds every schema, so every collision reports.
124        names.check_emitted(&module)?;
125        // A hoisted inline type carries no component name, so the resolution pass
126        // above cannot see it. The final item names can still hold a duplicate.
127        lower::check_duplicate_models(&module)?;
128        // After pruning, so a cycle among dropped models is not reported.
129        lower::box_recursive_types(&mut module)?;
130        let targets = emit::Targets {
131            server: want_server,
132            client: want_client,
133        };
134        let mut reserved = emit::reserved_type_names(targets);
135        if !emit::uses_nullable(&module, Some(&service)) {
136            reserved.retain(|name| return name.name != "Nullable");
137        }
138        lower::check_type_name_collisions(&service, &module, &reserved)?;
139        lower::check_prelude_shadowing(&module, targets)?;
140        return Ok(Lowered::Service {
141            module,
142            service,
143            server_urls,
144            targets,
145        });
146    }
147    // Models-only generation prunes nothing, so the module holds every schema and
148    // every collision reports.
149    lower::split_by_direction(&mut module, None);
150    names.check_emitted(&module)?;
151    lower::check_duplicate_models(&module)?;
152    if emit::uses_nullable(&module, None) {
153        lower::check_type_name_collisions(
154            &Service::default(),
155            &module,
156            &emit::reserved_type_names(emit::Targets::default()),
157        )?;
158    }
159    lower::check_prelude_shadowing(&module, emit::Targets::default())?;
160    lower::box_recursive_types(&mut module)?;
161    return Ok(Lowered::Models { module, server_urls });
162}
163
164/// Generate Rust from a spec file according to `configuration`, returning the source.
165///
166/// Models are emitted when `generate.models` is set, or implicitly when the
167/// server or client is generated (so referenced types are in scope). The axum
168/// server interface is appended when `generate.std-http-server` is set. The
169/// blocking `reqwest` client is appended when `generate.client` is set. Models,
170/// per-operation types, and both generators are emitted flat at the crate root,
171/// so server and client can share one file.
172///
173/// Use [`generate_package`] for the layout the CLI writes, which splits the same
174/// items across a module tree.
175pub fn generate(spec_path: &Path, config: &Config) -> Result<String> {
176    return match lower_spec(spec_path, config)? {
177        Lowered::Models { module, server_urls } => emit::emit_module(&module, server_urls.as_ref()),
178        Lowered::Service {
179            module,
180            service,
181            server_urls,
182            targets,
183        } => emit::emit_flat(&module, &service, server_urls.as_ref(), targets),
184    };
185}
186
187/// Generate Rust from a spec file according to `configuration`, returning every
188/// file the run produces.
189///
190/// A run that lowers operations splits its output across a module tree: the file
191/// at `output_path` becomes a facade of re-exports, and a companion directory
192/// named after that file's stem holds one module per concern (`models`,
193/// `operations`, `server`, `client`, `server_urls`) with one file per operation
194/// underneath. Every generated name stays reachable from the root file, so a
195/// consumer that already mounts it needs no change.
196///
197/// `output_path` is read for its file stem only, which names that companion
198/// directory. Nothing is read from or written to disk here; see
199/// [`write_package`] and [`check_package`].
200///
201/// A models-only run has no operations to split, so it produces the single file
202/// it always has.
203pub fn generate_package(spec_path: &Path, config: &Config, output_path: &Path) -> Result<GeneratedPackage> {
204    return match lower_spec(spec_path, config)? {
205        Lowered::Models { module, server_urls } => Ok(GeneratedPackage::new(
206            emit::emit_module(&module, server_urls.as_ref())?,
207            Vec::new(),
208        )),
209        Lowered::Service {
210            module,
211            service,
212            server_urls,
213            targets,
214        } => {
215            let stem = package::companion_of(output_path)?
216                .file_name()
217                .map(|stem| return stem.to_string_lossy().into_owned())
218                .ok_or_else(|| {
219                    return Error::UnsplittableOutput {
220                        path: output_path.display().to_string(),
221                    };
222                })?;
223            emit::emit_package(&module, &service, server_urls.as_ref(), targets, &stem)
224        }
225    };
226}
227
228/// Generate Rust from a spec file according to `configuration` and write it to
229/// `output_path`, creating parent directories as needed.
230pub fn generate_to_file(spec_path: &Path, config: &Config, output_path: &Path) -> Result<()> {
231    let code = generate(spec_path, config)?;
232    return write_output(output_path, &code);
233}
234
235/// Generate Rust models from a spec file and return the formatted source.
236///
237/// This entry point takes no config, so two schema names that collapse onto one
238/// Rust identifier are an error. Every schema becomes an item, because no
239/// operation exists to prune against. Use [`generate`] with
240/// `output-options.type-name-suffix` to resolve such a collision by config.
241pub fn generate_models_string(spec_path: &Path) -> Result<String> {
242    let spec = Spec::load(spec_path)?;
243    let names = lower::type_renames(&spec, None)?;
244    let mut module = lower::generate_models(&spec, &names)?;
245    lower::split_by_direction(&mut module, None);
246    // Every schema becomes an item here, so every collision reaches the file.
247    names.check_emitted(&module)?;
248    lower::check_duplicate_models(&module)?;
249    lower::check_prelude_shadowing(&module, emit::Targets::default())?;
250    lower::box_recursive_types(&mut module)?;
251    let code = emit::emit_module(&module, None)?;
252    return Ok(code);
253}
254
255/// Generate Rust models from a spec file and write them to `output_path`,
256/// creating parent directories as needed.
257pub fn generate_models_to_file(spec_path: &Path, output_path: &Path) -> Result<()> {
258    let code = generate_models_string(spec_path)?;
259    return write_output(output_path, &code);
260}
261
262/// What a comparison of generated code against an output file found.
263#[derive(Debug, Clone, Copy, PartialEq, Eq)]
264pub enum Drift {
265    /// The output file holds the generated code.
266    None,
267    /// The output file does not exist. Generation creates it, so this is drift
268    /// and not a read failure.
269    Absent,
270    /// The output file exists and holds different content.
271    Differs,
272}
273
274/// Compare `code` with the content of `output_path` and report the difference.
275///
276/// This reads the file and writes nothing, so a caller can gate a build on stale
277/// generated code. The comparison is exact, because the generator formats every
278/// output through `prettyplease` and therefore produces one byte sequence for one
279/// input.
280///
281/// The comparison reads bytes and not text. Generated Rust is always UTF-8, so a
282/// file that is not gives [`Drift::Differs`]. That is what the file is, and it
283/// also keeps a hand-edited or truncated file on the drift path where the remedy
284/// applies, rather than on the error path where it does not.
285///
286/// # Errors
287///
288/// Returns [`Error::ReadOutput`] when the file exists and cannot be read, for
289/// example a directory in place of a file. An absent file gives [`Drift::Absent`]
290/// and not an error, because generation creates it.
291pub fn check_output(output_path: &Path, code: &str) -> Result<Drift> {
292    let existing = match std::fs::read(output_path) {
293        Ok(existing) => existing,
294        Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
295            return Ok(Drift::Absent);
296        }
297        Err(source) => {
298            return Err(Error::ReadOutput {
299                path: output_path.display().to_string(),
300                source,
301            });
302        }
303    };
304    if existing == code.as_bytes() {
305        return Ok(Drift::None);
306    }
307    return Ok(Drift::Differs);
308}
309
310/// Write generated source to `output_path`, creating parent directories.
311pub fn write_output(output_path: &Path, code: &str) -> Result<()> {
312    if let Some(parent) = output_path.parent()
313        && !parent.as_os_str().is_empty()
314    {
315        std::fs::create_dir_all(parent).map_err(|source| {
316            return Error::WriteOutput {
317                path: output_path.display().to_string(),
318                source,
319            };
320        })?;
321    }
322    std::fs::write(output_path, code).map_err(|source| {
323        return Error::WriteOutput {
324            path: output_path.display().to_string(),
325            source,
326        };
327    })?;
328    return Ok(());
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    /// A directory under the temp directory that goes away with the test.
336    struct TestDir {
337        path: std::path::PathBuf,
338    }
339
340    impl TestDir {
341        fn new(test_name: &str) -> Self {
342            let unique = format!(
343                "oapi-codegen-check-{test_name}-{}-{}",
344                std::process::id(),
345                std::time::SystemTime::now()
346                    .duration_since(std::time::UNIX_EPOCH)
347                    .expect("system clock should be after Unix epoch")
348                    .as_nanos(),
349            );
350            let path = std::env::temp_dir().join(unique);
351            std::fs::create_dir_all(&path).expect("create test directory");
352            return Self { path };
353        }
354
355        fn join(&self, file: &str) -> std::path::PathBuf {
356            return self.path.join(file);
357        }
358    }
359
360    impl Drop for TestDir {
361        fn drop(&mut self) {
362            let _ = std::fs::remove_dir_all(&self.path);
363        }
364    }
365
366    /// A one-member `allOf` exists to put a keyword beside a `$ref`. It must
367    /// generate what the bare `$ref` generates, with the description added. A
368    /// marked target is the case that once differed: the wrapped form lost the
369    /// mark, so the model did not split by direction. A description written
370    /// beside the `$ref` itself must give the same output as the wrapped form.
371    #[test]
372    fn a_wrapped_reference_generates_what_the_bare_reference_generates() {
373        const NOTE: &str = "the note beside the reference";
374        let spec = |property: &str| {
375            return format!(
376                "openapi: 3.0.3
377info: {{title: Demo, version: 1.0.0}}
378paths:
379  /holders:
380    post:
381      operationId: putHolder
382      requestBody:
383        required: true
384        content: {{application/json: {{schema: {{$ref: '#/components/schemas/Holder'}}}}}}
385      responses:
386        '200':
387          description: ok
388          content: {{application/json: {{schema: {{$ref: '#/components/schemas/Holder'}}}}}}
389components:
390  schemas:
391    Plain: {{type: string, maxLength: 3}}
392    Served: {{type: string, readOnly: true}}
393    Sent: {{type: string, writeOnly: true}}
394    Holder:
395      type: object
396      required: [plain, served]
397      properties:
398        plain: {}
399        served: {}
400        sent: {}
401",
402                property.replace("NAME", "Plain"),
403                property.replace("NAME", "Served"),
404                property.replace("NAME", "Sent"),
405            );
406        };
407        let dir = TestDir::new("wrapped-reference");
408        std::fs::write(
409            dir.join("config.yaml"),
410            "package: demo\ngenerate: {models: true, std-http-server: true, client: true}\n",
411        )
412        .expect("write the configuration");
413        let config = Config::load(&dir.join("config.yaml")).expect("valid configuration");
414        let generate_from = |property: &str| {
415            std::fs::write(dir.join("spec.yaml"), spec(property)).expect("write the spec");
416            return generate(&dir.join("spec.yaml"), &config).expect("the spec generates");
417        };
418
419        let bare = generate_from("{$ref: '#/components/schemas/NAME'}");
420        let wrapped = generate_from(&format!(
421            "{{allOf: [{{$ref: '#/components/schemas/NAME'}}], description: {NOTE}}}"
422        ));
423
424        assert!(bare.contains("HolderRequest"), "the marked targets split the model");
425        let without_notes: Vec<&str> = wrapped.lines().filter(|line| return !line.contains(NOTE)).collect();
426        assert_eq!(without_notes, bare.lines().collect::<Vec<&str>>());
427        assert!(wrapped.contains(NOTE), "the description reaches the output");
428
429        // A description beside the bare `$ref` is kept the same way.
430        let beside = generate_from(&format!("{{$ref: '#/components/schemas/NAME', description: {NOTE}}}"));
431        assert_eq!(beside, wrapped);
432    }
433
434    /// The two places that read a property on their own path, and not through
435    /// the field lowering: an `allOf` that merges two members which both declare
436    /// the property, and a multipart form field. A description beside the `$ref`
437    /// must change neither.
438    #[test]
439    fn a_described_reference_merges_and_uploads_like_the_bare_reference() {
440        const NOTE: &str = "the note beside the reference";
441        const TARGET_NOTE: &str = "The kinds there are.";
442        let spec = |property: &str, kind: &str| {
443            // A nullable wrapper is written in full on both sides, and the note
444            // beside the property is the only difference between the two forms.
445            let wrapper_note = if property.contains(NOTE) {
446                format!(", description: {NOTE}")
447            } else {
448                String::new()
449            };
450            return format!(
451                "openapi: 3.0.3
452info: {{title: Demo, version: 1.0.0}}
453paths:
454  /uploads:
455    post:
456      operationId: upload
457      requestBody:
458        required: true
459        content:
460          multipart/form-data:
461            schema:
462              type: object
463              required: [kind]
464              properties:
465                kind: {property}
466                file: {{type: string, format: binary}}
467      responses:
468        '200':
469          description: ok
470          content: {{application/json: {{schema: {{$ref: '#/components/schemas/Merged'}}}}}}
471        '201':
472          description: made
473          content: {{application/json: {{schema: {{$ref: '#/components/schemas/Both'}}}}}}
474        '202':
475          description: queued
476          content: {{application/json: {{schema: {{$ref: '#/components/schemas/Across'}}}}}}
477components:
478  schemas:
479    Kind: {kind}
480    SameKind: {{$ref: '#/components/schemas/Kind'}}
481    Plain: {{type: string}}
482    Base: {{type: object, properties: {{kind: {{$ref: '#/components/schemas/Kind'}}, other: {{$ref: '#/components/schemas/SameKind'}}, maybe: {{allOf: [{{$ref: '#/components/schemas/Plain'}}], nullable: true}}}}}}
483    Extra: {{type: object, properties: {{kind: {property}, other: {property}, maybe: {{allOf: [{{$ref: '#/components/schemas/Plain'}}], nullable: true{wrapper_note}}}}}}}
484    Third: {{type: object, properties: {{other: {{$ref: '#/components/schemas/Kind'}}}}}}
485    Half: {{type: object, properties: {{h: {{type: string}}}}}}
486    Pair: {{allOf: [{{$ref: '#/components/schemas/Half'}}, {{$ref: '#/components/schemas/Half'}}]}}
487    Left: {{type: object, properties: {{pair: {{$ref: '#/components/schemas/Pair'}}}}}}
488    Right: {{type: object, properties: {{pair: {{allOf: [{{$ref: '#/components/schemas/Half'}}, {{$ref: '#/components/schemas/Half'}}]{wrapper_note}}}}}}}
489    Both: {{allOf: [{{$ref: '#/components/schemas/Left'}}, {{$ref: '#/components/schemas/Right'}}]}}
490    CompA: {{allOf: [{{type: object, properties: {{p: {{$ref: '#/components/schemas/Kind'}}}}}}, {{$ref: '#/components/schemas/Half'}}]}}
491    CompB: {{allOf: [{{type: object, properties: {{p: {property}}}}}, {{$ref: '#/components/schemas/Half'}}]}}
492    Up: {{type: object, properties: {{comp: {{$ref: '#/components/schemas/CompA'}}}}}}
493    Down: {{type: object, properties: {{comp: {{$ref: '#/components/schemas/CompB'}}}}}}
494    Across: {{allOf: [{{$ref: '#/components/schemas/Up'}}, {{$ref: '#/components/schemas/Down'}}]}}
495    Merged: {{allOf: [{{$ref: '#/components/schemas/Base'}}, {{$ref: '#/components/schemas/Extra'}}, {{$ref: '#/components/schemas/Third'}}]}}
496"
497            );
498        };
499        let dir = TestDir::new("described-reference");
500        std::fs::write(
501            dir.join("config.yaml"),
502            "package: demo\ngenerate: {models: true, std-http-server: true}\n",
503        )
504        .expect("write the configuration");
505        let config = Config::load(&dir.join("config.yaml")).expect("valid configuration");
506        let generate_from = |property: &str, kind: &str| {
507            std::fs::write(dir.join("spec.yaml"), spec(property, kind)).expect("write the spec");
508            return generate(&dir.join("spec.yaml"), &config).expect("the spec generates");
509        };
510        let bare_ref = "{$ref: '#/components/schemas/Kind'}";
511        let described_ref = format!("{{$ref: '#/components/schemas/Kind', description: {NOTE}}}");
512
513        let plain_kind = "{type: string, enum: [a, b]}";
514        let bare = generate_from(bare_ref, plain_kind);
515        let described = generate_from(&described_ref, plain_kind);
516
517        assert!(bare.contains("pub struct Merged"), "the overlap merges");
518        let without_notes: Vec<&str> = described.lines().filter(|line| return !line.contains(NOTE)).collect();
519        assert_eq!(without_notes, bare.lines().collect::<Vec<&str>>());
520        // Three times on `Merged`: for the overlap on one name, for the overlap
521        // through an alias, and for the overlap of two nullable wrappers; once on
522        // the enum that the alias overlap merges into, where it survives the
523        // third member; and once on `Both`, where a documented inline composite
524        // overlaps a `$ref` to the same composite. A multipart part carries no
525        // doc comment, and `Extra` and `Right` reach no operation, so they are
526        // pruned.
527        assert_eq!(described.matches(NOTE).count(), 5, "{described}");
528        // `Across` overlaps two composites that differ only in a note beside a
529        // nested `$ref`, and it keeps the first composite's name.
530        assert!(described.contains("pub comp: Option<CompA>"), "{described}");
531
532        // When `Kind` has a description of its own, the note beside the
533        // property wins on the field, as it does on a plain field.
534        let described_kind = format!("{{type: string, enum: [a, b], description: {TARGET_NOTE}}}");
535        let documented = generate_from(&described_ref, &described_kind);
536        assert!(
537            documented.contains(&format!(
538                "    /// {NOTE}\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub other"
539            )),
540            "{documented}"
541        );
542        assert!(
543            !documented.contains(&format!(
544                "    /// {TARGET_NOTE}\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub other"
545            )),
546            "{documented}"
547        );
548    }
549
550    /// A wrapper around a multipart field's `$ref` keeps its own marks. A
551    /// required field that a `readOnly` wrapper marks travels in no request, so
552    /// the server reads no part for it.
553    #[test]
554    fn a_read_only_wrapper_keeps_a_multipart_field_out_of_the_request() {
555        let dir = TestDir::new("multipart-wrapper-mark");
556        std::fs::write(
557            dir.join("spec.yaml"),
558            "openapi: 3.0.3
559info: {title: Demo, version: 1.0.0}
560paths:
561  /uploads:
562    post:
563      operationId: upload
564      requestBody:
565        required: true
566        content:
567          multipart/form-data:
568            schema:
569              type: object
570              required: [served, sent, elsewhere]
571              properties:
572                served: {allOf: [{$ref: '#/components/schemas/Plain'}], readOnly: true}
573                sent: {allOf: [{$ref: '#/components/schemas/Plain'}], description: a note}
574                elsewhere: {allOf: [{$ref: 'other.yaml#/components/schemas/Plain'}], readOnly: true}
575      responses:
576        '204': {description: ok}
577components:
578  schemas:
579    Plain: {type: string}
580",
581        )
582        .expect("write the spec");
583        // A part that is never sent is settled by its own mark, so the target
584        // in another file, which no multipart field could read, is never reached.
585        std::fs::write(
586            dir.join("other.yaml"),
587            "openapi: 3.0.3\ninfo: {title: Other, version: 1.0.0}\npaths: {}\ncomponents:\n  schemas:\n    Plain: {type: string}\n",
588        )
589        .expect("write the other document");
590        std::fs::write(
591            dir.join("config.yaml"),
592            "package: demo\ngenerate: {models: true, std-http-server: true}\n",
593        )
594        .expect("write the configuration");
595        let config = Config::load(&dir.join("config.yaml")).expect("valid configuration");
596
597        let code = generate(&dir.join("spec.yaml"), &config).expect("the spec generates");
598
599        assert!(code.contains("\"sent\""), "the plain field is a part: {code}");
600        assert!(!code.contains("\"served\""), "the read-only field is no part: {code}");
601        assert!(
602            !code.contains("\"elsewhere\""),
603            "the read-only cross-file field is no part: {code}"
604        );
605
606        // A wrapper mark and a target mark that leave no direction are an error
607        // here, as they are on any field, whichever side carries which.
608        for (wrapper, target) in [("readOnly", "writeOnly"), ("writeOnly", "readOnly")] {
609            std::fs::write(
610                dir.join("spec.yaml"),
611                format!(
612                    "openapi: 3.0.3
613info: {{title: Demo, version: 1.0.0}}
614paths:
615  /uploads:
616    post:
617      operationId: upload
618      requestBody:
619        required: true
620        content:
621          multipart/form-data:
622            schema:
623              type: object
624              properties:
625                torn: {{allOf: [{{$ref: '#/components/schemas/Marked'}}], {wrapper}: true}}
626      responses:
627        '204': {{description: ok}}
628components:
629  schemas:
630    Marked: {{type: string, {target}: true}}
631"
632                ),
633            )
634            .expect("write the spec");
635            let error = generate(&dir.join("spec.yaml"), &config).expect_err("both marks leave no direction");
636            assert!(error.to_string().contains("readOnly"), "{wrapper} on {target}: {error}");
637        }
638    }
639
640    #[test]
641    fn check_output_reports_an_absent_file_as_drift() {
642        let dir = TestDir::new("absent");
643        // Generation creates the file, so absence is drift and not a read failure.
644        let drift = check_output(&dir.join("out.rs"), "pub struct Widget;\n").expect("check an absent file");
645        assert_eq!(drift, Drift::Absent);
646    }
647
648    #[test]
649    fn check_output_reports_equal_content_as_no_drift() {
650        let dir = TestDir::new("equal");
651        let path = dir.join("out.rs");
652        let code = "pub struct Widget;\n";
653        std::fs::write(&path, code).expect("write the output file");
654        let drift = check_output(&path, code).expect("check an equal file");
655        assert_eq!(drift, Drift::None);
656    }
657
658    #[test]
659    fn check_output_reports_different_content_as_drift() {
660        let dir = TestDir::new("differs");
661        let path = dir.join("out.rs");
662        std::fs::write(&path, "pub struct Widget;\n").expect("write the output file");
663        let drift = check_output(&path, "pub struct Gadget;\n").expect("check a stale file");
664        assert_eq!(drift, Drift::Differs);
665    }
666
667    #[test]
668    fn check_output_compares_exactly() {
669        let dir = TestDir::new("exact");
670        let path = dir.join("out.rs");
671        // The generator formats every output, so one input gives one byte
672        // sequence. A trailing newline is therefore a real difference and not
673        // noise to normalise away.
674        std::fs::write(&path, "pub struct Widget;").expect("write the output file");
675        let drift = check_output(&path, "pub struct Widget;\n").expect("check a file with no trailing newline");
676        assert_eq!(drift, Drift::Differs);
677    }
678
679    #[test]
680    fn check_output_reports_content_that_is_not_utf8_as_drift() {
681        let dir = TestDir::new("not-utf8");
682        let path = dir.join("out.rs");
683        // Generated Rust is always UTF-8, so such a file is a differing file and
684        // not an unreadable one. The remedy for drift applies, and the remedy for
685        // a read failure does not.
686        std::fs::write(&path, [0xFF_u8, 0xFE_u8]).expect("write the output file");
687        let drift = check_output(&path, "pub struct Widget;\n").expect("check a file that is not UTF-8");
688        assert_eq!(drift, Drift::Differs);
689    }
690
691    #[test]
692    fn check_output_writes_nothing() {
693        let dir = TestDir::new("readonly");
694        let path = dir.join("out.rs");
695        let existing = "pub struct Widget;\n";
696        std::fs::write(&path, existing).expect("write the output file");
697        let drift = check_output(&path, "pub struct Gadget;\n").expect("check a stale file");
698        assert_eq!(drift, Drift::Differs);
699        let after = std::fs::read_to_string(&path).expect("read the output file back");
700        assert_eq!(after, existing, "`check_output` must not change the file");
701    }
702
703    #[test]
704    fn check_output_fails_on_a_path_it_cannot_read() {
705        let dir = TestDir::new("unreadable");
706        let path = dir.join("out.rs");
707        // A directory exists but holds no string content, so this is a read
708        // failure and not drift.
709        std::fs::create_dir(&path).expect("create a directory where a file belongs");
710        let error = check_output(&path, "pub struct Widget;\n").expect_err("a directory is not readable as a file");
711        assert!(
712            matches!(error, Error::ReadOutput { .. }),
713            "expected `ReadOutput`, got {error:?}"
714        );
715    }
716}