feat(cli): Add --stats to generate; log to stderr.

This commit is contained in:
Lina Butler
2026-06-12 02:34:56 -07:00
parent 968a582dd1
commit ab423f6eaf
14 changed files with 236 additions and 66 deletions
Generated
+2
View File
@@ -1128,6 +1128,8 @@ dependencies = [
"ploidy-codegen-rust",
"ploidy-core",
"semver",
"serde",
"serde_json",
"tempfile",
]
+2
View File
@@ -27,6 +27,8 @@ ploidy-pointer-derive = { path = "ploidy-pointer-derive", version = "0.14.1" }
pretty_assertions = "1"
ref-cast = "1"
rustc-hash = "2"
serde = "1"
serde_json = "1"
toml_edit = { version = "0.25", features = ["serde"] }
[workspace.lints.rust]
+1 -1
View File
@@ -11,7 +11,7 @@ rust-version.workspace = true
[dependencies]
itertools = "0.14"
serde = { version = "1", features = ["derive"] }
serde = { workspace = true, features = ["derive"] }
miette = "7"
ploidy-core = { workspace = true, features = ["proc-macro2"] }
prettyplease = "0.2"
+25 -9
View File
@@ -4,7 +4,7 @@ use itertools::Itertools;
use proc_macro2::TokenStream;
use quote::quote;
use ploidy_core::codegen::{IntoCode, write_to_disk};
use ploidy_core::codegen::{IntoCode, WrittenFile, write_to_disk};
mod cargo;
mod cfg;
@@ -45,18 +45,26 @@ pub use schema::*;
pub use statics::*;
pub use types::*;
pub fn write_types_to_disk(output: &Path, graph: &CodegenGraph<'_>) -> miette::Result<()> {
pub fn write_types_to_disk(
output: &Path,
graph: &CodegenGraph<'_>,
) -> miette::Result<Vec<WrittenFile>> {
let mut written = Vec::new();
for schema in graph.schemas() {
let code = CodegenSchemaType::new(graph, &schema).into_code();
write_to_disk(output, code)?;
written.push(write_to_disk(output, code)?);
}
write_to_disk(output, CodegenTypesModule::new(graph))?;
written.push(write_to_disk(output, CodegenTypesModule::new(graph))?);
Ok(())
Ok(written)
}
pub fn write_client_to_disk(output: &Path, graph: &CodegenGraph<'_>) -> miette::Result<()> {
pub fn write_client_to_disk(
output: &Path,
graph: &CodegenGraph<'_>,
) -> miette::Result<Vec<WrittenFile>> {
// Group operations by resource name.
let ops_by_resource: BTreeMap<_, Vec<_>> =
graph.operations().fold(BTreeMap::default(), |mut map, op| {
@@ -65,16 +73,24 @@ pub fn write_client_to_disk(output: &Path, graph: &CodegenGraph<'_>) -> miette::
map
});
let mut written = Vec::new();
// Write a module per resource.
for (ident, ops) in &ops_by_resource {
write_to_disk(output, CodegenResource::new(graph, *ident, ops))?;
written.push(write_to_disk(
output,
CodegenResource::new(graph, *ident, ops),
)?);
}
// Write the top-level client module.
let idents = ops_by_resource.keys().copied().collect_vec();
write_to_disk(output, CodegenClientModule::new(graph, &idents))?;
written.push(write_to_disk(
output,
CodegenClientModule::new(graph, &idents),
)?);
Ok(())
Ok(written)
}
/// Generates one or more `#[doc]` attributes for a schema description,
+2 -2
View File
@@ -18,8 +18,8 @@ form_urlencoded = "1"
indexmap = { version = "2", features = ["serde"] }
itertools = "0.14"
miette = "7"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true, features = ["preserve_order"] }
serde-saphyr = "0.0.27"
percent-encoding = "2.3"
petgraph = "0.8"
+20 -6
View File
@@ -27,19 +27,33 @@ pub mod unique;
pub use unique::{AsKebabCase, AsPascalCase, AsSnakeCase, NamePart, UniqueName, UniqueNames};
pub fn write_to_disk(output: &Path, code: impl IntoCode) -> miette::Result<()> {
/// A record of a file that [`write_to_disk`] wrote.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WrittenFile {
/// The path to the file, relative to the output directory.
pub path: String,
/// The size of the written contents in bytes.
pub size: usize,
}
pub fn write_to_disk(output: &Path, code: impl IntoCode) -> miette::Result<WrittenFile> {
let code = code.into_code();
let path = output.join(code.path());
let relative = code.path().to_owned();
let absolute = output.join(&relative);
let string = code.into_string()?;
if let Some(parent) = path.parent() {
if let Some(parent) = absolute.parent() {
std::fs::create_dir_all(parent)
.into_diagnostic()
.with_context(|| format!("Failed to create directory `{}`", parent.display()))?;
}
std::fs::write(&path, string)
let size = string.len();
std::fs::write(&absolute, string)
.into_diagnostic()
.with_context(|| format!("Failed to write `{}`", path.display()))?;
Ok(())
.with_context(|| format!("Failed to write `{}`", absolute.display()))?;
Ok(WrittenFile {
path: relative,
size,
})
}
pub trait Code {
+2 -2
View File
@@ -4,7 +4,7 @@ use indexmap::IndexMap;
use itertools::Itertools;
use ploidy_pointer::{JsonPointee, JsonPointer, JsonPointerBuf, JsonPointerTarget};
use serde::{
Deserialize, Deserializer,
Deserialize, Deserializer, Serialize,
de::{DeserializeOwned, Error as DeserializeError},
};
use serde_json::Value as JsonValue;
@@ -61,7 +61,7 @@ impl Info {
}
/// The title and optional version from an [`Info`] section.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize)]
pub struct Label<'a> {
pub title: &'a str,
pub version: Option<&'a str>,
+2 -2
View File
@@ -13,9 +13,9 @@ repository.workspace = true
indexmap = { version = "2", optional = true }
ploidy-pointer-derive = { workspace = true, optional = true }
ref-cast = { workspace = true }
serde = { version = "1", optional = true }
serde = { workspace = true, optional = true }
serde_bytes = { version = "0.11", optional = true }
serde_json = { version = "1", optional = true }
serde_json = { workspace = true, optional = true }
strsim = { version = "0.11", optional = true }
thiserror = "2"
+2 -2
View File
@@ -32,9 +32,9 @@ reqwest = { version = "0.13", default-features = false, features = [
"query",
"rustls",
] }
serde = { version = "1", features = ["derive"] }
serde = { workspace = true, features = ["derive"] }
serde_bytes = "0.11"
serde_json = "1"
serde_json = { workspace = true }
serde_path_to_error = "0.1"
thiserror = "2"
url = { version = "2", features = ["serde"] }
+2
View File
@@ -17,6 +17,8 @@ mimalloc = { version = "0.1", optional = true }
ploidy-codegen-rust = { workspace = true }
ploidy-core = { workspace = true }
semver = "1"
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
[features]
mimalloc = ["dep:mimalloc"]
+4
View File
@@ -24,6 +24,10 @@ pub struct RawGenerateArgs<T: clap::Args> {
#[arg(short, long)]
pub output: Option<PathBuf>,
/// Print statistics to standard output.
#[arg(long)]
pub stats: bool,
#[command(flatten)]
pub language: T,
}
+5
View File
@@ -66,6 +66,7 @@ impl Generate {
Ok(Self::Rust(GenerateArgs {
input,
output,
stats: args.stats,
language,
}))
}
@@ -77,6 +78,7 @@ impl Generate {
pub struct GenerateArgs<T> {
pub input: PathBuf,
pub output: PathBuf,
pub stats: bool,
pub language: T,
}
@@ -186,6 +188,7 @@ mod tests {
let args = RawGenerate::Rust(RawGenerateArgs {
input: PathBuf::from("specs/petstore.yaml"),
output: None,
stats: false,
language: RawGenerateRustArgs::default(),
});
let Generate::Rust(result) = Generate::try_new(args).unwrap();
@@ -197,6 +200,7 @@ mod tests {
let args = RawGenerate::Rust(RawGenerateArgs {
input: PathBuf::from("specs/petstore.yaml"),
output: Some(PathBuf::from("my-output")),
stats: false,
language: RawGenerateRustArgs::default(),
});
let Generate::Rust(result) = Generate::try_new(args).unwrap();
@@ -208,6 +212,7 @@ mod tests {
let args = RawGenerate::Rust(RawGenerateArgs {
input: PathBuf::from("/"),
output: None,
stats: false,
language: RawGenerateRustArgs::default(),
});
let err = Generate::try_new(args).unwrap_err();
+112 -42
View File
@@ -1,6 +1,9 @@
use itertools::Itertools;
use miette::{Context, IntoDiagnostic, Result};
use ploidy_codegen_rust::{CodegenCargoManifest, CodegenErrorModule, CodegenGraph, CodegenLibrary};
use ploidy_codegen_rust::{
CodegenCargoManifest, CodegenErrorModule, CodegenGraph, CodegenIdentUsage, CodegenLibrary,
ResourceGroup,
};
use ploidy_core::{
arena::Arena,
codegen::write_to_disk,
@@ -10,8 +13,12 @@ use ploidy_core::{
mod args;
mod cmd;
mod stats;
use self::cmd::{Generate, GenerateArgs, Main};
use self::{
cmd::{Generate, GenerateArgs, Main},
stats::{GenerateStats, OutputStats, Timings, timed},
};
#[cfg(feature = "mimalloc")]
#[global_allocator]
@@ -23,29 +30,51 @@ fn main() -> Result<()> {
Main::Generate(Generate::Rust(GenerateArgs {
input,
output,
stats,
language,
})) => {
let mut timings = Timings::default();
let source = std::fs::read_to_string(&input)
.into_diagnostic()
.with_context(|| format!("Failed to read `{}`", input.display()))?;
let doc = Document::from_yaml(&source)
.into_diagnostic()
.context("Failed to parse OpenAPI document")?;
let doc = {
let timing = timed(|| {
Document::from_yaml(&source)
.into_diagnostic()
.context("Failed to parse OpenAPI document")
});
timings.parse = timing.as_secs_f64();
timing.into_inner()
}?;
if let Some(label) = doc.info.label() {
let label = doc.info.label();
if let Some(label) = label {
match label.version {
Some(version) => println!("OpenAPI: {} (version {version})", label.title),
None => println!("OpenAPI: {}", label.title),
Some(version) => eprintln!("OpenAPI: {} (version {version})", label.title),
None => eprintln!("OpenAPI: {}", label.title),
}
}
let arena = Arena::new();
let spec = Spec::from_doc(&arena, &doc).into_diagnostic()?;
let mut raw = RawGraph::new(&arena, &spec);
raw.collapse_trivial_inlines();
raw.inline_tagged_variants();
raw.inline_untagged_variants();
let spec = {
let timing = timed(|| Spec::from_doc(&arena, &doc).into_diagnostic());
timings.ir = timing.as_secs_f64();
timing.into_inner()
}?;
let raw = {
let timing = timed(|| {
let mut raw = RawGraph::new(&arena, &spec);
raw.collapse_trivial_inlines();
raw.inline_tagged_variants();
raw.inline_untagged_variants();
raw
});
timings.ir += timing.as_secs_f64();
timing.into_inner()
};
let config = language
.manifest
@@ -54,45 +83,86 @@ fn main() -> Result<()> {
.transpose()?
.flatten();
let graph = {
let graph = raw.cook();
match config.as_ref() {
Some(config) => CodegenGraph::with_config(graph, config),
None => CodegenGraph::new(graph),
}
let timing = timed(|| {
let graph = raw.cook();
match config.as_ref() {
Some(config) => CodegenGraph::with_config(graph, config),
None => CodegenGraph::new(graph),
}
});
timings.cook = timing.as_secs_f64();
timing.into_inner()
};
println!("Writing generated code to `{}`...", output.display());
println!("Generating `Cargo.toml`...");
write_to_disk(
&output,
CodegenCargoManifest::new(&graph, &language.manifest),
)?;
println!("Generating `lib.rs`...");
write_to_disk(&output, CodegenLibrary)?;
println!("Generating `error.rs`...");
write_to_disk(&output, CodegenErrorModule)?;
println!("Generating {} types...", graph.schemas().count());
ploidy_codegen_rust::write_types_to_disk(&output, &graph)?;
eprintln!("Writing generated code to `{}`...", output.display());
let schemas = graph.schemas().count();
let counts = graph
.operations()
.into_grouping_map_by(|op| graph.resource_for(op))
.fold(0, |count, _, _| count + 1);
println!(
"Generating {} client methods across {} resources...",
counts.values().copied().sum::<usize>(),
counts.len(),
);
ploidy_codegen_rust::write_client_to_disk(&output, &graph)?;
println!("Generation complete");
let written = {
let timing = timed(|| -> Result<_> {
let mut written = Vec::new();
eprintln!("Generating `Cargo.toml`...");
written.push(write_to_disk(
&output,
CodegenCargoManifest::new(&graph, &language.manifest),
)?);
eprintln!("Generating `lib.rs`...");
written.push(write_to_disk(&output, CodegenLibrary)?);
eprintln!("Generating `error.rs`...");
written.push(write_to_disk(&output, CodegenErrorModule)?);
eprintln!("Generating {schemas} types...");
written.extend(ploidy_codegen_rust::write_types_to_disk(&output, &graph)?);
eprintln!(
"Generating {} client methods across {} resources...",
counts.values().copied().sum::<usize>(),
counts.len(),
);
written.extend(ploidy_codegen_rust::write_client_to_disk(&output, &graph)?);
Ok(written)
});
timings.codegen = timing.as_secs_f64();
timing.into_inner()
}?;
eprintln!("Generation complete");
if stats {
let stats = GenerateStats {
spec: label,
schemas,
operations: counts
.iter()
.map(|(&resource, &count)| {
let key = match resource {
ResourceGroup::Named(name) => {
CodegenIdentUsage::Module(name).display().to_string()
}
ResourceGroup::Default => "default".to_owned(),
};
(key, count)
})
.collect(),
timings,
output: OutputStats {
files: written.len(),
size: written.iter().map(|file| file.size).sum(),
},
};
println!("{}", serde_json::to_string(&stats).into_diagnostic()?);
}
if language.check {
println!("Running `cargo check`...");
eprintln!("Running `cargo check`...");
let status = std::process::Command::new("cargo")
.arg("check")
.arg("--all-targets")
+55
View File
@@ -0,0 +1,55 @@
use std::{
collections::BTreeMap,
time::{Duration, Instant},
};
use ploidy_core::parse::Label;
use serde::Serialize;
/// Statistics for a single generation run.
#[derive(Debug, Serialize)]
pub struct GenerateStats<'a> {
pub spec: Option<Label<'a>>,
pub schemas: usize,
pub operations: BTreeMap<String, usize>,
pub timings: Timings,
pub output: OutputStats,
}
/// Wall-clock durations, in seconds, for each generation phase.
#[derive(Debug, Default, Serialize)]
pub struct Timings {
pub parse: f64,
pub ir: f64,
pub cook: f64,
pub codegen: f64,
}
/// The number of files written, and their combined size in bytes.
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct OutputStats {
pub files: usize,
pub size: usize,
}
#[inline]
pub fn timed<T>(f: impl FnOnce() -> T) -> TimedResult<T> {
let start = Instant::now();
let result = f();
TimedResult(result, start.elapsed())
}
#[derive(Debug)]
pub struct TimedResult<T>(T, Duration);
impl<T> TimedResult<T> {
#[inline]
pub fn as_secs_f64(&self) -> f64 {
self.1.as_secs_f64()
}
#[inline]
pub fn into_inner(self) -> T {
self.0
}
}