mod ops;
mod types;
use std::io;
use std::path::{Path, PathBuf};
use thiserror::Error;
use crate::model::DocumentError;
use crate::overlay::OverlayError;
use crate::{Document, LoadError, overlay};
const DEFAULT_COMMAND: &str = "cargo run -p xtask -- bless";
const ALLOW: &str = "\
#![allow(
clippy::all,
clippy::pedantic,
clippy::restriction,
missing_debug_implementations,
unreachable_pub,
unused,
rustdoc::all,
reason = \"generated source is not graded on style; the allow covers this \\
module and nothing else\"
)]
";
#[derive(Debug, Clone)]
pub struct Settings {
document: PathBuf,
overlays: Vec<PathBuf>,
replacements: Vec<(String, String)>,
command: String,
}
impl Settings {
#[must_use]
pub fn new(document: impl Into<PathBuf>) -> Self {
Self {
document: document.into(),
overlays: Vec::new(),
replacements: Vec::new(),
command: DEFAULT_COMMAND.to_owned(),
}
}
#[must_use]
pub fn overlay(mut self, overlay: impl Into<PathBuf>) -> Self {
self.overlays.push(overlay.into());
self
}
#[must_use]
pub fn replace(mut self, format: impl Into<String>, rust_type: impl Into<String>) -> Self {
self.replacements.push((format.into(), rust_type.into()));
self
}
#[must_use]
pub fn regenerated_by(mut self, command: impl Into<String>) -> Self {
self.command = command.into();
self
}
pub fn write_to(&self, crate_dir: impl AsRef<Path>) -> Result<Vec<PathBuf>, GenerateError> {
let dir = crate_dir.as_ref();
let corrected = self.correct()?;
let spec = dir.join("spec").join(self.corrected_name());
let types = dir.join("src/types.rs");
let ops = dir.join("src/ops.rs");
let model = dir.join("src/model.postcard");
let header = self.rust_header(&spec);
let document = format!("{}{}", self.document_header(), corrected.yaml);
write_bytes(&spec, document.as_bytes())?;
write_rust(
&types,
&types::emit(&corrected.api, &header, &self.replacements)?,
)?;
write_rust(&ops, &ops::emit(&corrected.api, &corrected.model, &header)?)?;
write_model(&model, &corrected.model)?;
Ok(vec![spec, types, ops, model])
}
fn correct(&self) -> Result<Corrected, GenerateError> {
let fault = |path: &Path| {
let path = path.to_path_buf();
move |source| GenerateError::Overlay { path, source }
};
let mut overlaid = overlay::parse(&read(&self.document)?).map_err(fault(&self.document))?;
for layer in &self.overlays {
overlaid = overlay::apply(overlaid, &read(layer)?).map_err(fault(layer))?;
}
let yaml = serde_yaml_ng::to_string(&overlaid).map_err(GenerateError::Yaml)?;
let model = Document::load(&yaml, &[]).map_err(GenerateError::Unusable)?;
let api = serde_json::from_value(overlaid).map_err(GenerateError::NotOpenApi)?;
Ok(Corrected { yaml, model, api })
}
fn corrected_name(&self) -> String {
let stem = self
.document
.file_stem()
.unwrap_or(self.document.as_os_str())
.to_string_lossy();
format!("{stem}.overlaid.yaml")
}
fn document_header(&self) -> String {
format!(
"# Generated by `{}` from {}.\n\
# Do not edit: every correction belongs in an Overlay.\n",
self.command,
listed(&self.inputs(file_name)),
)
}
fn rust_header(&self, corrected: &Path) -> String {
let belongs = match self.overlays.as_slice() {
[] => format!("is generated from `{}`", locator(&self.document)),
layers => {
let named: Vec<String> = layers
.iter()
.map(|path| format!("`{}`", locator(path)))
.collect();
format!("belongs in {}", listed(&named))
}
};
format!(
"//! Generated by `{}` from `{}`.\n\
//! Do not edit: every correction {belongs}.\n\
{ALLOW}",
self.command,
locator(corrected),
)
}
fn inputs(&self, name: impl Fn(&Path) -> String) -> Vec<String> {
std::iter::once(&self.document)
.chain(&self.overlays)
.map(|path| name(path))
.collect()
}
}
fn listed(items: &[String]) -> String {
match items.split_last() {
None => String::new(),
Some((last, [])) => last.clone(),
Some((last, rest)) => format!("{} and {last}", rest.join(", ")),
}
}
struct Corrected {
yaml: String,
model: Document,
api: openapiv3::OpenAPI,
}
fn locator(path: &Path) -> String {
match path.parent().and_then(Path::file_name) {
Some(parent) => format!("{}/{}", parent.to_string_lossy(), file_name(path)),
None => file_name(path),
}
}
fn file_name(path: &Path) -> String {
path.file_name()
.unwrap_or(path.as_os_str())
.to_string_lossy()
.into_owned()
}
fn read(path: &Path) -> Result<String, GenerateError> {
std::fs::read_to_string(path).map_err(|source| GenerateError::Read {
path: path.to_path_buf(),
source,
})
}
fn write_bytes(path: &Path, contents: &[u8]) -> Result<(), GenerateError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|source| GenerateError::Write {
path: parent.to_path_buf(),
source,
})?;
}
std::fs::write(path, contents).map_err(|source| GenerateError::Write {
path: path.to_path_buf(),
source,
})
}
fn write_model(path: &Path, model: &Document) -> Result<(), GenerateError> {
let blob = model.to_blob().map_err(GenerateError::Blob)?;
let read_back = Document::from_blob(&blob).map_err(GenerateError::Blob)?;
if &read_back != model {
return Err(GenerateError::RoundTrip);
}
write_bytes(path, &blob)
}
fn write_rust(path: &Path, source: &str) -> Result<(), GenerateError> {
write_bytes(path, source.as_bytes())?;
rustfmt(path)
}
fn rustfmt(path: &Path) -> Result<(), GenerateError> {
let status = std::process::Command::new("rustfmt")
.arg("--edition")
.arg("2024")
.arg(path)
.status()
.map_err(|source| {
if source.kind() == io::ErrorKind::NotFound {
GenerateError::RustfmtMissing
} else {
GenerateError::RustfmtSpawn {
path: path.to_path_buf(),
source,
}
}
})?;
if status.success() {
Ok(())
} else {
Err(GenerateError::RustfmtFailed {
path: path.to_path_buf(),
})
}
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum GenerateError {
#[error("reading {path}: {source}")]
Read {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("writing {path}: {source}")]
Write {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("{path}: {source}")]
Overlay {
path: PathBuf,
#[source]
source: OverlayError,
},
#[error("the overlaid document is not representable as YAML: {0}")]
Yaml(#[source] serde_yaml_ng::Error),
#[error("the overlaid document does not describe a usable CLI: {0}")]
Unusable(#[source] LoadError),
#[error("the overlaid document is not an OpenAPI 3 document: {0}")]
NotOpenApi(#[source] serde_json::Error),
#[error("schema `{name}` is not representable as JSON: {source}")]
Schema {
name: String,
#[source]
source: serde_json::Error,
},
#[error("typify cannot build Rust types from the document's schemas: {0}")]
Typify(#[source] typify::Error),
#[error("{0}")]
Unsupported(String),
#[error("the generated {file} is not valid Rust: {source}")]
NotRust {
file: &'static str,
#[source]
source: syn::Error,
},
#[error("the reduced model does not survive a round trip: {0}")]
Blob(#[source] DocumentError),
#[error("the reduced model is not the document's reduction after a round trip")]
RoundTrip,
#[error("rustfmt is not on PATH, and a bless step formats every Rust file it writes")]
RustfmtMissing,
#[error("running rustfmt on {path}: {source}")]
RustfmtSpawn {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("rustfmt rejected the generated {path}")]
RustfmtFailed { path: PathBuf },
}