From ab423f6eaf1c920c2b60780c362abea6fab888fb Mon Sep 17 00:00:00 2001 From: Lina Butler Date: Fri, 12 Jun 2026 02:34:56 -0700 Subject: [PATCH] feat(cli): Add `--stats` to `generate`; log to stderr. --- Cargo.lock | 2 + Cargo.toml | 2 + ploidy-codegen-rust/Cargo.toml | 2 +- ploidy-codegen-rust/src/lib.rs | 34 ++++++-- ploidy-core/Cargo.toml | 4 +- ploidy-core/src/codegen/mod.rs | 26 ++++-- ploidy-core/src/parse/types.rs | 4 +- ploidy-pointer/Cargo.toml | 4 +- ploidy-util/Cargo.toml | 4 +- ploidy/Cargo.toml | 2 + ploidy/src/args.rs | 4 + ploidy/src/cmd.rs | 5 ++ ploidy/src/main.rs | 154 ++++++++++++++++++++++++--------- ploidy/src/stats.rs | 55 ++++++++++++ 14 files changed, 236 insertions(+), 66 deletions(-) create mode 100644 ploidy/src/stats.rs diff --git a/Cargo.lock b/Cargo.lock index 0e33b9f..7f41a84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1128,6 +1128,8 @@ dependencies = [ "ploidy-codegen-rust", "ploidy-core", "semver", + "serde", + "serde_json", "tempfile", ] diff --git a/Cargo.toml b/Cargo.toml index e45d22d..af2f824 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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] diff --git a/ploidy-codegen-rust/Cargo.toml b/ploidy-codegen-rust/Cargo.toml index eaa13bb..2253267 100644 --- a/ploidy-codegen-rust/Cargo.toml +++ b/ploidy-codegen-rust/Cargo.toml @@ -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" diff --git a/ploidy-codegen-rust/src/lib.rs b/ploidy-codegen-rust/src/lib.rs index cdefe36..0039d87 100644 --- a/ploidy-codegen-rust/src/lib.rs +++ b/ploidy-codegen-rust/src/lib.rs @@ -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> { + 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> { // 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, diff --git a/ploidy-core/Cargo.toml b/ploidy-core/Cargo.toml index 2553fea..3a4a6d7 100644 --- a/ploidy-core/Cargo.toml +++ b/ploidy-core/Cargo.toml @@ -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" diff --git a/ploidy-core/src/codegen/mod.rs b/ploidy-core/src/codegen/mod.rs index c3d6ac9..1767954 100644 --- a/ploidy-core/src/codegen/mod.rs +++ b/ploidy-core/src/codegen/mod.rs @@ -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 { 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 { diff --git a/ploidy-core/src/parse/types.rs b/ploidy-core/src/parse/types.rs index f663a0c..a70be1e 100644 --- a/ploidy-core/src/parse/types.rs +++ b/ploidy-core/src/parse/types.rs @@ -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>, diff --git a/ploidy-pointer/Cargo.toml b/ploidy-pointer/Cargo.toml index 631f93b..e52a797 100644 --- a/ploidy-pointer/Cargo.toml +++ b/ploidy-pointer/Cargo.toml @@ -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" diff --git a/ploidy-util/Cargo.toml b/ploidy-util/Cargo.toml index dbdfe9c..819b550 100644 --- a/ploidy-util/Cargo.toml +++ b/ploidy-util/Cargo.toml @@ -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"] } diff --git a/ploidy/Cargo.toml b/ploidy/Cargo.toml index e81e428..d77225d 100644 --- a/ploidy/Cargo.toml +++ b/ploidy/Cargo.toml @@ -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"] diff --git a/ploidy/src/args.rs b/ploidy/src/args.rs index dca01ad..49fc8e3 100644 --- a/ploidy/src/args.rs +++ b/ploidy/src/args.rs @@ -24,6 +24,10 @@ pub struct RawGenerateArgs { #[arg(short, long)] pub output: Option, + /// Print statistics to standard output. + #[arg(long)] + pub stats: bool, + #[command(flatten)] pub language: T, } diff --git a/ploidy/src/cmd.rs b/ploidy/src/cmd.rs index 704bad5..eaac9d4 100644 --- a/ploidy/src/cmd.rs +++ b/ploidy/src/cmd.rs @@ -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 { 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(); diff --git a/ploidy/src/main.rs b/ploidy/src/main.rs index 95bd9e5..fb58bbe 100644 --- a/ploidy/src/main.rs +++ b/ploidy/src/main.rs @@ -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::(), - 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::(), + 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") diff --git a/ploidy/src/stats.rs b/ploidy/src/stats.rs new file mode 100644 index 0000000..356be1c --- /dev/null +++ b/ploidy/src/stats.rs @@ -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>, + pub schemas: usize, + pub operations: BTreeMap, + 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(f: impl FnOnce() -> T) -> TimedResult { + let start = Instant::now(); + let result = f(); + TimedResult(result, start.elapsed()) +} + +#[derive(Debug)] +pub struct TimedResult(T, Duration); + +impl TimedResult { + #[inline] + pub fn as_secs_f64(&self) -> f64 { + self.1.as_secs_f64() + } + + #[inline] + pub fn into_inner(self) -> T { + self.0 + } +}