Skip to main content

typed_openapi/
generate.rs

1//! The bless step: a vendor's OpenAPI document and an adopter's Overlay in,
2//! one corrected document and the Rust an adopter compiles against out.
3//!
4//! An adoption runs this once per vendor revision and commits everything it
5//! writes. That is what makes a vendor change reviewable: the diff after a
6//! bless run is the answer to "what did the vendor do?", in Rust rather than in
7//! YAML.
8//!
9//! # What it writes
10//!
11//! Four artefacts under one directory, all derived from a single Overlay
12//! application so that none of them can describe a different API:
13//!
14//! - `spec/<name>.overlaid.yaml` — the corrected document, and the reviewable
15//!   record of what everything below it came from.
16//! - `src/types.rs` — `components.schemas` as Rust types, from typify, with the
17//!   adopter's own types substituted wherever [`Settings::replace`] says.
18//! - `src/ops.rs` — one typed wrapper per operation, the closed `OperationId`
19//!   set, and the `(operationId, method, path)` inventory a hand-written
20//!   operation asserts against.
21//! - `src/model.postcard` — that same document already reduced to the facts a
22//!   command line needs, so a shipped binary parses no YAML and enables no
23//!   feature that could.
24//!
25//! # Using it
26//!
27//! ```no_run
28//! # fn main() -> Result<(), typed_openapi::generate::GenerateError> {
29//! typed_openapi::generate::Settings::new("spec/vendor.yaml")
30//!     .overlay("spec/corrections.yaml")
31//!     .overlay("spec/cli.yaml")
32//!     .replace("money", "money::Money")
33//!     .write_to("api-generated")?;
34//! # Ok(())
35//! # }
36//! ```
37//!
38//! `examples/toy/xtask` is that call in a binary, written to be copied.
39//!
40//! # Corrections come in layers
41//!
42//! [`Settings::overlay`] may be called more than once, and the order of the
43//! calls is the order the Overlays are applied: each one corrects the document
44//! the ones before it produced. What an adoption puts in which layer is its
45//! own affair — this crate reads an ordered list of standard Overlay documents
46//! and nothing more. `docs/overlay.md` recommends a split, and the example
47//! keeps it.
48//!
49//! # Every Overlay is applied strictly
50//!
51//! [`overlay::apply`] uses `ErrorOnZeroMatch`, so a correction whose target the
52//! vendor has renamed or retyped fails here rather than lapsing quietly. A
53//! correction that stops applying is the loudest thing a vendor revision can
54//! do, and this is where it is heard. The failure names the layer it is in.
55
56mod names;
57mod ops;
58mod types;
59
60use std::io;
61use std::path::{Path, PathBuf};
62
63use syn::visit_mut::VisitMut;
64use thiserror::Error;
65
66use crate::model::DocumentError;
67use crate::overlay::OverlayError;
68use crate::{Document, LoadError, overlay};
69
70/// The command a generated file tells its reader to run.
71///
72/// `cargo run -p xtask -- bless` is the convention this crate documents; an
73/// adoption that spells it differently says so with
74/// [`Settings::regenerated_by`], because the line is a promise to whoever opens
75/// the file next.
76const DEFAULT_COMMAND: &str = "cargo run -p xtask -- bless";
77
78/// The one exemption generated source gets from an adopter's lints.
79///
80/// It is an inner attribute rather than a wrapping module so that the exemption
81/// travels with the file it exempts, and it is the same block in every
82/// generated file so that an adopter can grep for it.
83const ALLOW: &str = "\
84#![allow(
85    clippy::all,
86    clippy::pedantic,
87    clippy::restriction,
88    missing_debug_implementations,
89    unreachable_pub,
90    unused,
91    rustdoc::all,
92    reason = \"generated source is not graded on style; the allow covers this \\
93              module and nothing else\"
94)]
95";
96
97/// What a bless step generates, and the two things only the adopter can say.
98///
99/// The vendor's document and the adopter's Overlays are the input and a
100/// directory is the output; everything between them is derived. The two
101/// settings are the two facts the documents do not carry: which Rust types the
102/// adopter already owns for which vendor formats, and what command regenerates
103/// the result.
104#[derive(Debug, Clone)]
105pub struct Settings {
106    document: PathBuf,
107    overlays: Vec<PathBuf>,
108    replacements: Vec<(String, String)>,
109    command: String,
110}
111
112impl Settings {
113    /// Generate from the vendor's document, uncorrected.
114    ///
115    /// A path rather than contents: the generated files name every document
116    /// they came from so that a reader can find them, and the corrected
117    /// document is written under the vendor document's own name.
118    ///
119    /// Corrections are layers over it — [`Settings::overlay`], once per layer.
120    #[must_use]
121    pub fn new(document: impl Into<PathBuf>) -> Self {
122        Self {
123            document: document.into(),
124            overlays: Vec::new(),
125            replacements: Vec::new(),
126            command: DEFAULT_COMMAND.to_owned(),
127        }
128    }
129
130    /// Lay one Overlay over the document, after every Overlay already named.
131    ///
132    /// Call it once per layer. The order of the calls is the order the layers
133    /// are applied, because a later layer corrects the document the earlier
134    /// ones produced — so two layers that touch the same node are not
135    /// interchangeable, and the last one wins.
136    ///
137    /// A layer that fails names itself, which is the practical reason to have
138    /// more than one: a tripwire that stops the bless says which file to open.
139    #[must_use]
140    pub fn overlay(mut self, overlay: impl Into<PathBuf>) -> Self {
141        self.overlays.push(overlay.into());
142        self
143    }
144
145    /// Emit `rust_type` wherever the document declares `format`.
146    ///
147    /// The adopter owns a Rust type for a vendor format — an amount of money, a
148    /// customer number, a posting key — and wants it in the generated structs
149    /// rather than the `String` the document would otherwise produce.
150    ///
151    /// Keying on the format rather than on a schema name is what keeps the
152    /// substitution honest: the shape being replaced is read out of the
153    /// document, so the rule the CLI validates against and the rule the Rust
154    /// type stands for are the same bytes. `rust_type` is written into the
155    /// generated source verbatim, so it is a path the generated crate can name.
156    ///
157    /// A named schema carrying the format becomes a newtype *over* `rust_type`
158    /// whenever the schema's name is not what `rust_type` ends in, and that
159    /// wrapper's impls are written in terms of it: `Display` forwards to it,
160    /// `FromStr` parses into it and names `<rust_type as FromStr>::Err` as its
161    /// own error. Where one is written, the generated types assert both traits
162    /// against `rust_type`, so a missing one is a single named error rather
163    /// than the wrapper's own impls failing. Everywhere else the type stands
164    /// alone and needs only the `Serialize`, `Deserialize`, `Clone`, `Debug`
165    /// and `PartialEq` every generated type has. `docs/generating.md` says
166    /// which case is which, and why the document's `pattern` and the type's
167    /// own reading are two rules that a test has to hold together.
168    #[must_use]
169    pub fn replace(mut self, format: impl Into<String>, rust_type: impl Into<String>) -> Self {
170        self.replacements.push((format.into(), rust_type.into()));
171        self
172    }
173
174    /// Name the command that regenerates, for the header of every written file.
175    ///
176    /// The default is `cargo run -p xtask -- bless`.
177    #[must_use]
178    pub fn regenerated_by(mut self, command: impl Into<String>) -> Self {
179        self.command = command.into();
180        self
181    }
182
183    /// Write the four artefacts under `crate_dir`, and answer with their paths.
184    ///
185    /// The sink is a directory rather than four values the caller places,
186    /// because the layout is not the caller's to choose: the generated crate
187    /// embeds the corrected document and the reduced model by relative path,
188    /// and the header of each Rust file states where the others are. One
189    /// argument buys all four files in the arrangement they have to be in.
190    ///
191    /// Every Rust file is handed to `rustfmt` after it is written, so what
192    /// lands in the tree is what `cargo fmt --check` expects and the bless step
193    /// stays one command.
194    pub fn write_to(&self, crate_dir: impl AsRef<Path>) -> Result<Vec<PathBuf>, GenerateError> {
195        let dir = crate_dir.as_ref();
196        let corrected = self.correct()?;
197
198        let spec = dir.join("spec").join(self.corrected_name());
199        let types = dir.join("src/types.rs");
200        let ops = dir.join("src/ops.rs");
201        let model = dir.join("src/model.postcard");
202
203        let header = self.rust_header(&spec);
204        let document = format!("{}{}", self.document_header(), corrected.yaml);
205
206        // The types are emitted first because the wrappers name them, and
207        // `names` is how they are named: `ops` looks a schema up in what
208        // `types` wrote rather than deriving a spelling of its own.
209        let (source, names) = types::emit(&corrected.api, &header, &self.replacements)?;
210
211        write_bytes(&spec, document.as_bytes())?;
212        write_rust(&types, &source)?;
213        write_rust(
214            &ops,
215            &ops::emit(&corrected.api, &corrected.model, &header, &names)?,
216        )?;
217        write_model(&model, &corrected.model)?;
218
219        Ok(vec![spec, types, ops, model])
220    }
221
222    /// Every layer, laid over the document in order, in the three views the
223    /// artefacts need.
224    fn correct(&self) -> Result<Corrected, GenerateError> {
225        let fault = |path: &Path| {
226            let path = path.to_path_buf();
227            move |source| GenerateError::Overlay { path, source }
228        };
229
230        let mut overlaid = overlay::parse(&read(&self.document)?).map_err(fault(&self.document))?;
231        for layer in &self.overlays {
232            overlaid = overlay::apply(overlaid, &read(layer)?).map_err(fault(layer))?;
233        }
234        let yaml = serde_yaml_ng::to_string(&overlaid).map_err(GenerateError::Yaml)?;
235
236        // Refuse to emit against a document this crate cannot build a CLI from.
237        // Everything below trusts that this succeeded.
238        let model = Document::load(&yaml, &[]).map_err(GenerateError::Unusable)?;
239        let api = serde_json::from_value(overlaid).map_err(GenerateError::NotOpenApi)?;
240
241        Ok(Corrected { yaml, model, api })
242    }
243
244    /// The corrected document's file name: the vendor's, with `overlaid` in it.
245    fn corrected_name(&self) -> String {
246        let stem = self
247            .document
248            .file_stem()
249            .unwrap_or(self.document.as_os_str())
250            .to_string_lossy();
251        format!("{stem}.overlaid.yaml")
252    }
253
254    /// What the corrected document says above its first line: the command
255    /// that rewrites it, and every document it was built from in the order
256    /// they were applied — which is what a reader needs to reproduce it.
257    fn document_header(&self) -> String {
258        format!(
259            "# Generated by `{}` from {}.\n\
260             # Do not edit: every correction belongs in an Overlay.\n",
261            self.command,
262            listed(&self.inputs(file_name)),
263        )
264    }
265
266    /// What every generated Rust file says above its first line: how to rewrite
267    /// it, where a correction belongs instead, and the one exemption generated
268    /// source gets from an adopter's lints.
269    fn rust_header(&self, corrected: &Path) -> String {
270        let belongs = match self.overlays.as_slice() {
271            [] => format!("is generated from `{}`", locator(&self.document)),
272            layers => {
273                let named: Vec<String> = layers
274                    .iter()
275                    .map(|path| format!("`{}`", locator(path)))
276                    .collect();
277                format!("belongs in {}", listed(&named))
278            }
279        };
280        format!(
281            "//! Generated by `{}` from `{}`.\n\
282             //! Do not edit: every correction {belongs}.\n\
283             {ALLOW}",
284            self.command,
285            locator(corrected),
286        )
287    }
288
289    /// The vendor's document and every layer over it, in the order they are
290    /// applied, named the way `name` names a path.
291    fn inputs(&self, name: impl Fn(&Path) -> String) -> Vec<String> {
292        std::iter::once(&self.document)
293            .chain(&self.overlays)
294            .map(|path| name(path))
295            .collect()
296    }
297}
298
299/// A list as prose: `a`, then `a and b`, then `a, b and c`.
300fn listed(items: &[String]) -> String {
301    match items.split_last() {
302        None => String::new(),
303        Some((last, [])) => last.clone(),
304        Some((last, rest)) => format!("{} and {last}", rest.join(", ")),
305    }
306}
307
308/// One Overlay application, in the three views the four artefacts are emitted
309/// from.
310///
311/// Producing all three from one application is the whole point: the YAML that
312/// is committed, the reduction a binary reads and the object model the Rust is
313/// emitted from are the same correction, so no two artefacts can describe a
314/// different API.
315struct Corrected {
316    /// The corrected document, as it is committed.
317    yaml: String,
318    /// That document reduced to the facts a command line needs.
319    model: Document,
320    /// That document as the OpenAPI object model the emitters read.
321    api: openapiv3::OpenAPI,
322}
323
324/// The shortest form of a path a reader can act on: the directory holding it
325/// and its name.
326///
327/// A bare file name is ambiguous once an adoption has more than one `spec`
328/// directory, and an absolute path is true only on the machine that generated.
329fn locator(path: &Path) -> String {
330    match path.parent().and_then(Path::file_name) {
331        Some(parent) => format!("{}/{}", parent.to_string_lossy(), file_name(path)),
332        None => file_name(path),
333    }
334}
335
336fn file_name(path: &Path) -> String {
337    path.file_name()
338        .unwrap_or(path.as_os_str())
339        .to_string_lossy()
340        .into_owned()
341}
342
343fn read(path: &Path) -> Result<String, GenerateError> {
344    std::fs::read_to_string(path).map_err(|source| GenerateError::Read {
345        path: path.to_path_buf(),
346        source,
347    })
348}
349
350/// A vendor's description, kept where rustdoc will not run it.
351///
352/// Every description in the document becomes a doc comment, and rustdoc
353/// compiles and executes the code blocks in a doc comment. Markdown makes a
354/// code block out of three ordinary shapes of prose: a run of lines indented
355/// four spaces or more, a list item whose content sits five columns from its
356/// marker, and a fence that names no language. A vendor writing a nested list,
357/// a column of items lined up under the widest marker, or a hanging example
358/// writes all three without meaning any of them, and what the adopter gets is
359/// `cargo test --doc` failing on the vendor's sentences — which no lint
360/// allowance reaches, because a doctest is executed rather than linted.
361///
362/// So each line is made unrunnable where it would otherwise be run, and left
363/// alone everywhere else. The generator is what put the prose where rustdoc
364/// would execute it, so the generator is where it is made safe; an adopter
365/// switching doctests off for the whole crate would be hiding this and taking
366/// their own hand-written files with it.
367///
368/// # What may be edited, and what may not
369///
370/// **The generated page has to render what the vendor wrote.** Every edit here
371/// is chosen against that: it changes bytes the vendor has no stake in, so that
372/// the rendering is the one they meant. Capping an indentation keeps a nested
373/// list nested instead of letting it become a code block; pulling a hanging
374/// item's content back keeps it an item; writing a bullet as `-` keeps the
375/// bullet, which rustc would otherwise eat. What none of them may do is change
376/// what the page says — the inside of a fence is the vendor's sample and is
377/// passed through untouched, and a fence's language is renamed rather than
378/// dropped, because the word is what a reader's highlighting goes by.
379struct Prose;
380
381impl VisitMut for Prose {
382    fn visit_attribute_mut(&mut self, attr: &mut syn::Attribute) {
383        let syn::Meta::NameValue(pair) = &mut attr.meta else {
384            return;
385        };
386        if !pair.path.is_ident("doc") {
387            return;
388        }
389        let syn::Expr::Lit(syn::ExprLit {
390            lit: syn::Lit::Str(text),
391            ..
392        }) = &mut pair.value
393        else {
394            return;
395        };
396        *text = syn::LitStr::new(&unrunnable(&text.value()), text.span());
397    }
398}
399
400/// How wide Markdown counts a tab when it measures indentation.
401const TAB: usize = 4;
402
403/// The deepest indentation that cannot open a code block.
404///
405/// Four opens one, so three is what is left. It is enough to keep a nested list
406/// nested — a list marker needs only to reach its parent's content column — so
407/// what capping costs is the depth of an unusually deep one, and what it buys
408/// is a crate whose tests run.
409const KEEP: usize = 3;
410
411/// The widest gap between a list marker and its content that cannot open a
412/// code block.
413///
414/// Markdown measures a list item's content from the end of its marker rather
415/// than from the start of the line, so this is one wider than [`KEEP`]: five
416/// columns after a marker open a code block inside the item, four do not.
417/// Measured on `-`, `+`, `*` and an ordered marker alike.
418///
419/// Four is safe only where the marker survives to hold the gap off the margin,
420/// which is [`dashed`]'s doing; [`unhung`] says what happens where it does
421/// not.
422const HANG: usize = 4;
423
424/// The bullet a list marker is written with.
425///
426/// `-` rather than `*`, and [`dashed`] says why.
427const BULLET: char = '-';
428
429/// The most digits Markdown reads as one ordered list marker.
430const ORDERED: usize = 9;
431
432/// `prose` with nothing in it that rustdoc would compile.
433fn unrunnable(prose: &str) -> String {
434    let capped = capped_lines(prose);
435    if !capped.contains('\n') {
436        // One line is one `///`, where nothing is indented and there is
437        // nothing to strip.
438        return capped;
439    }
440    // More than one line is a `/* */` comment, and `rustfmt` indents its body
441    // to the item it sits on — every line but the first, which stays flush
442    // against the opening `/*`. rustc strips the indentation a comment's lines
443    // share, and a flush first line holds that strip down to almost nothing, so
444    // the rest of the body arrives still carrying the column the item sits at:
445    // a paragraph the vendor indented three spaces lands six in, which is a
446    // code block. Opening on a blank line puts the first line inside the
447    // indented body with the others, where the strip reaches it, and that same
448    // paragraph lands back on its three.
449    format!("\n{capped}\n")
450}
451
452/// `prose` with no line indented deeply enough to open a code block, no list
453/// item hanging its content far enough to open one, no bullet rustc will eat,
454/// and no fence rustdoc would read as Rust.
455///
456/// A line is capped, then unhung, then dashed, then read as a fence, and the
457/// first step's place in that order is the rule: Markdown reads a fence at
458/// three columns or fewer, so an indented one is not a fence at all but the
459/// start of an indented code block — capping it first is what turns it into the
460/// fence the vendor meant, rather than leaving a block whose first line happens
461/// to be three backticks. The two list steps cannot disturb that, because a run
462/// of backticks is not a list marker.
463fn capped_lines(prose: &str) -> String {
464    let mut fenced = false;
465    let lines: Vec<String> = prose
466        .split('\n')
467        .map(|line| {
468            // Inside a fence the content is the vendor's sample, kept as they
469            // wrote it — the fence above it already says nobody will run it.
470            if fenced && fence(line).is_none() {
471                return line.to_owned();
472            }
473            let tamed = dashed(&unhung(&capped(line)));
474            let Some((marker, language)) = fence(&tamed) else {
475                return tamed;
476            };
477            if fenced {
478                fenced = false;
479                return tamed;
480            }
481            fenced = true;
482            if compiled(language) {
483                let indent: String = tamed.chars().take_while(|c| c.is_whitespace()).collect();
484                format!("{indent}{marker}{INERT}")
485            } else {
486                tamed
487            }
488        })
489        .collect();
490    lines.join("\n")
491}
492
493/// The language named on a fence rustdoc will not compile.
494///
495/// Any word it does not recognise does, and this one says what the block is.
496const INERT: &str = "text";
497
498/// The words rustdoc reads above a code block as *attributes of Rust* rather
499/// than as the name of a language.
500///
501/// A fence carrying one of them — or carrying nothing — is a block rustdoc
502/// compiles and runs, so a vendor who wrote `rust` over a line of pseudocode
503/// has written a doctest without meaning to. Every other word names a language
504/// rustdoc leaves alone, and the vendor's own is worth keeping: it is what a
505/// reader's syntax highlighting goes by.
506const COMPILED: [&str; 7] = [
507    "compile_fail",
508    "ignore",
509    "no_run",
510    "rust",
511    "should_panic",
512    "standalone_crate",
513    "test_harness",
514];
515
516/// Would rustdoc compile a block a fence naming `language` opens?
517fn compiled(language: &str) -> bool {
518    let word = language.split([',', ' ', '\t']).next().unwrap_or(language);
519    word.is_empty() || word.starts_with("edition") || COMPILED.contains(&word)
520}
521
522/// The marker this line fences with and the language it names, if it is a
523/// fence.
524///
525/// A fence is three or more backticks or tildes. Whether it is indented too
526/// far to be one is settled before this is asked, by capping the line.
527fn fence(line: &str) -> Option<(&str, &str)> {
528    let body = line.trim_start();
529    let mark = ['`', '~']
530        .into_iter()
531        .find(|mark| body.chars().take(3).filter(|c| c == mark).count() == 3)?;
532    let run = body.len() - body.trim_start_matches(mark).len();
533    let (marker, language) = body.split_at(run);
534    Some((marker, language.trim()))
535}
536
537/// `line` with its indentation capped at [`KEEP`].
538fn capped(line: &str) -> String {
539    let content = line.trim_start();
540    if content.is_empty() || columns(line) <= KEEP {
541        return line.to_owned();
542    }
543    format!("{}{content}", " ".repeat(KEEP))
544}
545
546/// How many columns the whitespace at the start of `text` is worth.
547///
548/// Markdown counts a tab as [`TAB`], and both caps are stated in columns, so
549/// this is the one place that reading is made.
550fn columns(text: &str) -> usize {
551    text.chars()
552        .take_while(|c| c.is_whitespace())
553        .map(|c| if c == '\t' { TAB } else { 1 })
554        .sum()
555}
556
557/// `line` with the gap between a list marker and its content capped at
558/// [`HANG`].
559///
560/// This is the code block [`capped`] cannot see. A vendor lining the text of
561/// several items up under the widest marker writes five spaces after a short
562/// one, and Markdown reads content that far from a marker as an indented code
563/// block *inside* the item — while the line's own indentation, which is all
564/// [`capped`] measures, is nothing at all.
565///
566/// The cap holds at four only because [`dashed`] has taken `*` off the front of
567/// every bullet. Where a `*` survives, rustc eats it out of a `/* */` comment
568/// and leaves the gap standing alone as ordinary indentation, and four columns
569/// of that under a blank line opens a block of its own. The two rules are one
570/// rule, and `PROSE`'s list set off by a blank line is what holds them
571/// together.
572///
573/// The line is left alone when the marker carries no content: a marker on its
574/// own opens nothing, and there is no gap to measure.
575fn unhung(line: &str) -> String {
576    let content = line.trim_start();
577    let Some(width) = list_marker(content) else {
578        return line.to_owned();
579    };
580    let (indent, rest) = line.split_at(line.len() - content.len());
581    let (marker, after) = rest.split_at(width);
582    let text = after.trim_start();
583    if text.is_empty() || columns(after) <= HANG {
584        return line.to_owned();
585    }
586    format!("{indent}{marker}{}{text}", " ".repeat(HANG))
587}
588
589/// `line` with a `*` bullet written as [`BULLET`].
590///
591/// rustc rebuilds a `/* */` comment by stripping a leading `*` off every line
592/// whenever all of the lines it weighs carry one at the same column, which is
593/// the shape a vendor's bullet list takes exactly. What reaches the reader is
594/// then a run of sentences where the vendor wrote a list, and nothing on the
595/// page says a marker went missing.
596///
597/// Markdown draws the same bullet for `-` and `*`, so this is one character the
598/// vendor has no stake in and the rendering is the one they wrote. `+` and the
599/// ordered markers are left alone: rustc eats none of them.
600fn dashed(line: &str) -> String {
601    let content = line.trim_start();
602    if !content.starts_with('*') || list_marker(content).is_none() {
603        return line.to_owned();
604    }
605    let (indent, rest) = line.split_at(line.len() - content.len());
606    let (_, after) = rest.split_at(1);
607    format!("{indent}{BULLET}{after}")
608}
609
610/// How wide the list marker at the start of `content` is, if it is one.
611///
612/// Markdown's markers are `-`, `+` or `*`, and up to [`ORDERED`] digits
613/// followed by `.` or `)`. Whitespace after it is what makes it a marker at
614/// all, which is also what keeps `*emphasis*` at the start of a line out of
615/// this.
616fn list_marker(content: &str) -> Option<usize> {
617    let width = if content.starts_with(['-', '+', '*']) {
618        1
619    } else {
620        let digits = content.chars().take_while(char::is_ascii_digit).count();
621        if digits == 0 || digits > ORDERED {
622            return None;
623        }
624        if !content.get(digits..)?.starts_with(['.', ')']) {
625            return None;
626        }
627        digits + 1
628    };
629    content
630        .get(width..)?
631        .starts_with([' ', '\t'])
632        .then_some(width)
633}
634
635/// Write a file, creating the directory it goes in if it is missing.
636fn write_bytes(path: &Path, contents: &[u8]) -> Result<(), GenerateError> {
637    if let Some(parent) = path.parent() {
638        std::fs::create_dir_all(parent).map_err(|source| GenerateError::Write {
639            path: parent.to_path_buf(),
640            source,
641        })?;
642    }
643    std::fs::write(path, contents).map_err(|source| GenerateError::Write {
644        path: path.to_path_buf(),
645        source,
646    })
647}
648
649/// Write the reduction a binary loads, after reading it straight back.
650///
651/// `model` is the one [`Document::load`] a bless step makes — the same value
652/// the generated types and wrappers were emitted from — so encoding it is the
653/// only way the blob and the document can come apart. The read-back proves they
654/// have not: what a binary will deserialise is what this step reduced.
655fn write_model(path: &Path, model: &Document) -> Result<(), GenerateError> {
656    let blob = model.to_blob().map_err(GenerateError::Blob)?;
657    let read_back = Document::from_blob(&blob).map_err(GenerateError::Blob)?;
658    if &read_back != model {
659        return Err(GenerateError::RoundTrip);
660    }
661    write_bytes(path, &blob)
662}
663
664fn write_rust(path: &Path, source: &str) -> Result<(), GenerateError> {
665    write_bytes(path, source.as_bytes())?;
666    rustfmt(path)
667}
668
669/// Hand a written file to `rustfmt`.
670///
671/// Formatting the file on disk rather than piping the source through keeps the
672/// result identical to what an adopter's own `cargo fmt` would produce, which
673/// is the only reason a generated file can sit under `cargo fmt --check` at
674/// all.
675fn rustfmt(path: &Path) -> Result<(), GenerateError> {
676    let status = std::process::Command::new("rustfmt")
677        .arg("--edition")
678        .arg("2024")
679        .arg(path)
680        .status()
681        .map_err(|source| {
682            if source.kind() == io::ErrorKind::NotFound {
683                GenerateError::RustfmtMissing
684            } else {
685                GenerateError::RustfmtSpawn {
686                    path: path.to_path_buf(),
687                    source,
688                }
689            }
690        })?;
691    if status.success() {
692        Ok(())
693    } else {
694        Err(GenerateError::RustfmtFailed {
695            path: path.to_path_buf(),
696        })
697    }
698}
699
700/// Why a bless step stopped.
701///
702/// Most variants are the document saying something this generator has no Rust
703/// spelling for; the rest are the two documents, the filesystem, or `rustfmt`.
704/// A bless step reports and exits, so nothing here is meant to be branched on —
705/// it is meant to name the thing to go and fix.
706#[derive(Debug, Error)]
707#[non_exhaustive]
708pub enum GenerateError {
709    #[error("reading {path}: {source}")]
710    Read {
711        path: PathBuf,
712        #[source]
713        source: io::Error,
714    },
715    #[error("writing {path}: {source}")]
716    Write {
717        path: PathBuf,
718        #[source]
719        source: io::Error,
720    },
721    /// A document or a layer over it that does not read, or does not apply.
722    /// `path` is the file to go and open: with corrections split across
723    /// layers, which one stopped the bless is the first thing to know.
724    #[error("{path}: {source}")]
725    Overlay {
726        path: PathBuf,
727        #[source]
728        source: OverlayError,
729    },
730    #[error("the overlaid document is not representable as YAML: {0}")]
731    Yaml(#[source] serde_yaml_ng::Error),
732    #[error("the overlaid document does not describe a usable CLI: {0}")]
733    Unusable(#[source] LoadError),
734    #[error("the overlaid document is not an OpenAPI 3 document: {0}")]
735    NotOpenApi(#[source] serde_json::Error),
736    #[error("schema `{name}` is not representable as JSON: {source}")]
737    Schema {
738        name: String,
739        #[source]
740        source: serde_json::Error,
741    },
742    #[error("typify cannot build Rust types from the document's schemas: {0}")]
743    Typify(#[source] typify::Error),
744    /// A wrapper would have to name the type of a schema the document's
745    /// `components.schemas` never declared. Emitting an identifier for it
746    /// anyway is how an adopter ends up bisecting a generated file, so the
747    /// reference is named here instead.
748    #[error(
749        "`#/components/schemas/{schema}` is referenced but not declared, so no \
750         wrapper can name the type it would be"
751    )]
752    NoType { schema: String },
753    /// Two of the document's schemas reduce to one Rust type. typify writes a
754    /// definition per schema and uniquifies nothing, so emitting them is a file
755    /// that defines the same type twice; renaming one here would be a generator
756    /// choosing a public name nobody asked for.
757    #[error(
758        "the document's schemas `{first}` and `{second}` are both `{rust}` in Rust; \
759         rename one of them in an Overlay"
760    )]
761    OneType {
762        first: String,
763        second: String,
764        rust: String,
765    },
766    #[error("{0}")]
767    Unsupported(String),
768    /// A failure while emitting one operation's wrapper, named by the
769    /// operation it came from. Everything else this crate refuses says which
770    /// operation it is about, and a generated file is too large to bisect by
771    /// hand for one that does not.
772    #[error("{op}: {source}")]
773    Operation {
774        op: String,
775        #[source]
776        source: Box<GenerateError>,
777    },
778    #[error("the generated {file} is not valid Rust: {source}")]
779    NotRust {
780        file: &'static str,
781        #[source]
782        source: syn::Error,
783    },
784    #[error("the reduced model does not survive a round trip: {0}")]
785    Blob(#[source] DocumentError),
786    #[error("the reduced model is not the document's reduction after a round trip")]
787    RoundTrip,
788    #[error("rustfmt is not on PATH, and a bless step formats every Rust file it writes")]
789    RustfmtMissing,
790    #[error("running rustfmt on {path}: {source}")]
791    RustfmtSpawn {
792        path: PathBuf,
793        #[source]
794        source: io::Error,
795    },
796    #[error("rustfmt rejected the generated {path}")]
797    RustfmtFailed { path: PathBuf },
798}