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}