From 7905534d18b6035553343607e23f0ab5e37fa257 Mon Sep 17 00:00:00 2001 From: Harnoor Lal Date: Sat, 18 Apr 2026 19:51:47 -0700 Subject: [PATCH] feat(cli): add --example flag to build and profile example targets Support cargo example targets via --example flag, mutually exclusive with --bin. Generalize find_bin_target to find_target accepting a target kind parameter. Update build_instrumented to accept CargoTarget enum (Bin or Example). Extend find_latest_binary to scan the examples subdirectory. Add unit test for example target discovery. --- src/build.rs | 93 ++++++++++++++++++++++++++++++++++++++++------------ src/main.rs | 92 ++++++++++++++++++++++++++++++--------------------- 2 files changed, 126 insertions(+), 59 deletions(-) diff --git a/src/build.rs b/src/build.rs index 26999f4..66fa096 100644 --- a/src/build.rs +++ b/src/build.rs @@ -8,6 +8,12 @@ use toml_edit::DocumentMut; use crate::error::{Error, io_context}; +/// Which cargo target to build. +pub enum CargoTarget<'a> { + Bin(&'a str), + Example(&'a str), +} + // --- Cargo metadata types --- #[derive(Debug, Deserialize)] @@ -55,19 +61,20 @@ pub fn cargo_metadata(project_dir: &Path) -> Result { .map_err(|e| Error::BuildFailed(format!("failed to parse cargo metadata: {e}"))) } -/// Find a binary target in the metadata for the given package. +/// Find a target of the given kind in the metadata for the given package. /// -/// When `bin_name` is `Some`, looks for that specific binary target. -/// When `None`, returns the first binary target found. +/// `kind` is the cargo target kind: "bin" or "example". +/// When `name` is `Some`, looks for that specific target. +/// When `None`, returns the first target of that kind found. /// /// Returns `(package_name, src_path)` where src_path is the absolute path -/// to the binary's entry point. -pub fn find_bin_target( +/// to the target's entry point. +pub fn find_target( metadata: &CargoMetadata, package_name: Option<&str>, - bin_name: Option<&str>, + name: Option<&str>, + kind: &str, ) -> Result<(String, PathBuf), Error> { - // Filter packages: if package_name given, match it; otherwise use all. let candidates: Vec<&MetadataPackage> = if let Some(pkg) = package_name { metadata.packages.iter().filter(|p| p.name == pkg).collect() } else { @@ -75,18 +82,18 @@ pub fn find_bin_target( }; if candidates.is_empty() { - let name = package_name.unwrap_or(""); + let pkg = package_name.unwrap_or(""); return Err(Error::BuildFailed(format!( - "no package '{name}' found in cargo metadata" + "no package '{pkg}' found in cargo metadata" ))); } for pkg in &candidates { for target in &pkg.targets { - if !target.kind.iter().any(|k| k == "bin") { + if !target.kind.iter().any(|k| k == kind) { continue; } - if let Some(wanted) = bin_name { + if let Some(wanted) = name { if target.name != wanted { continue; } @@ -95,10 +102,10 @@ pub fn find_bin_target( } } - let bin_desc = bin_name.unwrap_or("default"); + let target_desc = name.unwrap_or("default"); let pkg_desc = package_name.unwrap_or(""); Err(Error::BuildFailed(format!( - "no binary target '{bin_desc}' found in package '{pkg_desc}'" + "no {kind} target '{target_desc}' found in package '{pkg_desc}'" ))) } @@ -732,7 +739,7 @@ pub fn build_instrumented( project_dir: &Path, target_dir: &Path, package: Option<&str>, - bin: Option<&str>, + target: Option>, config_path: &Path, modified_files: &std::collections::HashSet, ) -> Result { @@ -756,8 +763,14 @@ pub fn build_instrumented( if let Some(pkg) = package { cmd.arg("-p").arg(pkg); } - if let Some(bin_name) = bin { - cmd.arg("--bin").arg(bin_name); + match target { + Some(CargoTarget::Bin(name)) => { + cmd.arg("--bin").arg(name); + } + Some(CargoTarget::Example(name)) => { + cmd.arg("--example").arg(name); + } + None => {} } let output = cmd.output()?; @@ -936,7 +949,7 @@ edition = "2021" }], }; - let (name, path) = find_bin_target(&metadata, None, None).unwrap(); + let (name, path) = find_target(&metadata, None, None, "bin").unwrap(); assert_eq!(name, "demo"); assert_eq!(path, PathBuf::from("/project/src/main.rs")); } @@ -963,7 +976,7 @@ edition = "2021" }], }; - let (name, path) = find_bin_target(&metadata, None, Some("worker")).unwrap(); + let (name, path) = find_target(&metadata, None, Some("worker"), "bin").unwrap(); assert_eq!(name, "demo"); assert_eq!(path, PathBuf::from("/project/src/worker.rs")); } @@ -990,7 +1003,7 @@ edition = "2021" }], }; - let (_, path) = find_bin_target(&metadata, None, None).unwrap(); + let (_, path) = find_target(&metadata, None, None, "bin").unwrap(); assert_eq!(path, PathBuf::from("/project/src/main.rs")); } @@ -1009,7 +1022,7 @@ edition = "2021" }], }; - let result = find_bin_target(&metadata, None, Some("nonexistent")); + let result = find_target(&metadata, None, Some("nonexistent"), "bin"); assert!(result.is_err()); let msg = result.unwrap_err().to_string(); assert!( @@ -1044,11 +1057,49 @@ edition = "2021" ], }; - let (name, path) = find_bin_target(&metadata, Some("core"), None).unwrap(); + let (name, path) = find_target(&metadata, Some("core"), None, "bin").unwrap(); assert_eq!(name, "core"); assert_eq!(path, PathBuf::from("/ws/crates/core/src/main.rs")); } + #[test] + fn find_target_finds_example() { + let metadata = CargoMetadata { + workspace_root: PathBuf::from("/project"), + packages: vec![MetadataPackage { + name: "demo".to_string(), + manifest_path: PathBuf::from("/project/Cargo.toml"), + targets: vec![ + MetadataTarget { + name: "demo".to_string(), + kind: vec!["lib".to_string()], + src_path: PathBuf::from("/project/src/lib.rs"), + }, + MetadataTarget { + name: "demo".to_string(), + kind: vec!["bin".to_string()], + src_path: PathBuf::from("/project/src/main.rs"), + }, + MetadataTarget { + name: "bench".to_string(), + kind: vec!["example".to_string()], + src_path: PathBuf::from("/project/examples/bench.rs"), + }, + ], + }], + }; + + let (name, path) = find_target(&metadata, None, Some("bench"), "example").unwrap(); + assert_eq!(name, "demo"); + assert_eq!(path, PathBuf::from("/project/examples/bench.rs")); + + let result = find_target(&metadata, None, None, "example"); + assert!(result.is_ok()); + + let result = find_target(&metadata, None, None, "bin"); + assert_eq!(result.unwrap().1, PathBuf::from("/project/src/main.rs")); + } + #[test] fn find_current_package_matches_dir() { let tmp = TempDir::new().unwrap(); diff --git a/src/main.rs b/src/main.rs index a356846..faaa123 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,8 +8,8 @@ use std::time::Duration; use clap::{Parser, Subcommand}; use piano::build::{ - build_instrumented, cargo_metadata, clean_stale_piano_files, find_bin_target, - find_current_package, find_project_root, prebuild_runtime, prebuild_runtime_from_path, + CargoTarget, build_instrumented, cargo_metadata, clean_stale_piano_files, find_current_package, + find_project_root, find_target, prebuild_runtime, prebuild_runtime_from_path, }; use piano::error::{Error, io_context}; use piano::report::{ @@ -74,9 +74,13 @@ struct BuildOpts { /// Build and profile a specific binary target (for projects with multiple /// [[bin]] entries). Matches cargo's --bin flag. - #[arg(long)] + #[arg(long, conflicts_with = "example")] bin: Option, + /// Build and profile an example target. Matches cargo's --example flag. + #[arg(long, conflicts_with = "bin")] + example: Option, + /// Capture per-thread CPU time alongside wall time (Unix only). #[arg(long)] cpu_time: bool, @@ -424,6 +428,7 @@ fn build_project( project, runtime_path, bin, + example, cpu_time, output_dir, list_skipped, @@ -471,25 +476,25 @@ fn build_project( )) })?; - // Find the target package and binary. - // If the user specified --bin, look for it. Otherwise find the current - // package from the project directory. - let (package_name, bin_src_path) = if bin.is_some() || metadata.packages.len() == 1 { + // Find the target package and binary/example. + let target_kind = if example.is_some() { "example" } else { "bin" }; + let target_name = example.as_deref().or(bin.as_deref()); + let (package_name, bin_src_path) = if target_name.is_some() || metadata.packages.len() == 1 { let pkg_filter = if metadata.packages.len() == 1 { None } else { find_current_package(&metadata, &project).map(|p| p.name.as_str()) }; - find_bin_target(&metadata, pkg_filter, bin.as_deref())? + find_target(&metadata, pkg_filter, target_name, target_kind)? } else { - // Multiple packages, no --bin: find the package matching project dir. + // Multiple packages, no --bin/--example: find the package matching project dir. let pkg = find_current_package(&metadata, &project).ok_or_else(|| { Error::BuildFailed(format!( "could not determine which package to build in workspace at {}", workspace_root.display() )) })?; - find_bin_target(&metadata, Some(&pkg.name), None)? + find_target(&metadata, Some(&pkg.name), None, "bin")? }; // Derive source directory from the binary's src_path. @@ -752,11 +757,16 @@ fn build_project( } else { None }; + let cargo_target = if let Some(ref ex) = example { + Some(CargoTarget::Example(ex.as_str())) + } else { + bin.as_deref().map(CargoTarget::Bin) + }; let binary = build_instrumented( &workspace_root, &target_dir, pkg_arg, - bin.as_deref(), + cargo_target, &config_path, &modified_files, )?; @@ -789,39 +799,45 @@ fn is_binary_extension(ext: &std::ffi::OsStr) -> bool { fn find_latest_binary(project_root: &Option) -> Result { let project = project_root.as_ref().ok_or(Error::NoBinary)?; - let dir = project.join("target/piano/release"); - if !dir.is_dir() { + let release_dir = project.join("target/piano/release"); + if !release_dir.is_dir() { return Err(Error::NoBinary); } + let mut dirs = vec![release_dir.clone()]; + let examples_dir = release_dir.join("examples"); + if examples_dir.is_dir() { + dirs.push(examples_dir); + } let mut best: Option<(PathBuf, std::time::SystemTime)> = None; - for entry in std::fs::read_dir(&dir).map_err(io_context("read directory", &dir))? { - let entry = entry.map_err(io_context("read directory entry", &dir))?; - let path = entry.path(); - if !path.is_file() { - continue; - } - // Skip non-binary files by extension. On Unix, binaries have no extension. - // On Windows, binaries have .exe extension -- allow those through. - if let Some(ext) = path.extension() { - if !is_binary_extension(ext) { + for dir in &dirs { + let entries = std::fs::read_dir(dir).map_err(io_context("read directory", dir))?; + for entry in entries { + let entry = entry.map_err(io_context("read directory entry", dir))?; + let path = entry.path(); + if !path.is_file() { continue; } - } - let meta = entry - .metadata() - .map_err(io_context("read metadata", &path))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if meta.permissions().mode() & 0o111 == 0 { - continue; // not executable + if let Some(ext) = path.extension() { + if !is_binary_extension(ext) { + continue; + } + } + let meta = entry + .metadata() + .map_err(io_context("read metadata", &path))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if meta.permissions().mode() & 0o111 == 0 { + continue; + } + } + let mtime = meta + .modified() + .map_err(io_context("read modified time", &path))?; + if best.as_ref().is_none_or(|(_, t)| mtime > *t) { + best = Some((path, mtime)); } - } - let mtime = meta - .modified() - .map_err(io_context("read modified time", &path))?; - if best.as_ref().is_none_or(|(_, t)| mtime > *t) { - best = Some((path, mtime)); } } best.map(|(p, _)| p).ok_or(Error::NoBinary)