use std::num::NonZeroUsize;
use std::path::PathBuf;
use cbh_command::{
AnalyzeOptions, BackfillOptions, BlessOptions, CacheSelection, CollectOptions, Command,
ExamineOptions, InstallOptions, ListOptions, ListSubject, LocalStorageSelection,
MachineKeyOptions, PruneOptions, UnblessOptions,
};
use cbh_model::BenchmarkIdPrefix;
use clap::{ArgGroup, Args, Parser, Subcommand as ClapSubcommand, ValueEnum};
const HEADING_ENV: &str = "Environment and execution";
const HEADING_OUTPUT: &str = "Output";
const HEADING_DISCRIMINANT: &str = "Discriminant selection";
const HEADING_COMMIT: &str = "Commit selection";
const HEADING_FILTER: &str = "Data filtering";
const HEADING_SCOPE: &str = "Benchmark scope";
const HEADING_FEATURES: &str = "Feature selection";
const HEADING_ANALYSIS: &str = "Analysis";
#[derive(Debug, Parser)]
#[command(
name = "cargo-bench-history",
about = "Maintain a history of benchmark results over time and analyze it for trends.",
disable_help_subcommand = true,
disable_version_flag = true
)]
pub struct Cli {
#[command(subcommand)]
command: Subcommand,
}
#[derive(Debug)]
pub struct EarlyExit {
pub output: String,
pub status: Result<(), ()>,
}
impl EarlyExit {
fn from_clap(error: &clap::Error) -> Self {
use clap::error::ErrorKind;
let success = matches!(
error.kind(),
ErrorKind::DisplayHelp
| ErrorKind::DisplayVersion
| ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
);
Self {
output: error.to_string(),
status: if success { Ok(()) } else { Err(()) },
}
}
}
impl Cli {
pub fn from_args(command_name: &[&str], args: &[&str]) -> Result<Self, EarlyExit> {
let argv: Vec<&str> = command_name.iter().chain(args).copied().collect();
Self::try_parse_from(argv).map_err(|error| EarlyExit::from_clap(&error))
}
#[must_use]
pub fn into_command(self) -> Command {
match self.command {
Subcommand::Analyze(command) => Command::Analyze(command.into_options()),
Subcommand::Backfill(command) => Command::Backfill(command.into_options()),
Subcommand::Bless(command) => Command::Bless(command.into_options()),
Subcommand::Collect(command) => Command::Collect(command.into_options()),
Subcommand::Examine(command) => Command::Examine(command.into_options()),
Subcommand::Install(command) => Command::Install(command.into_options()),
Subcommand::List(command) => Command::List(command.into_options()),
Subcommand::MachineKey(command) => Command::MachineKey(command.into_options()),
Subcommand::Prune(command) => Command::Prune(command.into_options()),
Subcommand::Unbless(command) => Command::Unbless(command.into_options()),
}
}
#[must_use]
pub fn help(program_name: &str) -> String {
Self::from_args(&[program_name], &["--help"])
.err()
.map(|early_exit| early_exit.output)
.unwrap_or_default()
}
}
#[derive(ClapSubcommand, Debug)]
enum Subcommand {
Analyze(AnalyzeCommand),
Backfill(BackfillCommand),
Bless(BlessCommand),
Collect(CollectCommand),
Install(InstallCommand),
List(ListCommand),
MachineKey(MachineKeyCommand),
Examine(ExamineCommand),
Prune(PruneCommand),
Unbless(UnblessCommand),
}
#[derive(Args, Debug)]
#[command(next_help_heading = HEADING_ENV)]
struct EnvArgs {
#[arg(long, value_name = "PATH")]
config: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
repo: Option<PathBuf>,
#[arg(long, value_name = "PATH", num_args = 0..=1, require_equals = true)]
#[expect(
clippy::option_option,
reason = "clap's representation of a three-state optional-value flag: absent (None), bare \
--local (Some(None) -> read the env var), or --local=<path> (Some(Some(path)))"
)]
local: Option<Option<PathBuf>>,
#[arg(long)]
verbose: bool,
}
#[expect(
clippy::option_option,
reason = "mirrors the clap field's three-state optional-value representation, mapped here to \
the typed LocalStorageSelection"
)]
fn local_selection(local: Option<Option<PathBuf>>) -> Option<LocalStorageSelection> {
match local {
None => None,
Some(Some(path)) => Some(LocalStorageSelection::Path(path)),
Some(None) => Some(LocalStorageSelection::FromEnv),
}
}
#[expect(
clippy::option_option,
reason = "mirrors the clap field's three-state optional-value representation, mapped here to \
the typed CacheSelection"
)]
fn cache_selection(cache: Option<Option<PathBuf>>) -> Option<CacheSelection> {
match cache {
None => None,
Some(Some(path)) => Some(CacheSelection::Path(path)),
Some(None) => Some(CacheSelection::FromEnv),
}
}
#[derive(Args, Debug)]
#[command(next_help_heading = HEADING_ENV)]
struct CacheArg {
#[arg(
long,
value_name = "PATH",
num_args = 0..=1,
require_equals = true,
conflicts_with = "local"
)]
#[expect(
clippy::option_option,
reason = "clap's representation of a three-state optional-value flag: absent (None), bare \
--cache (Some(None) -> read the env var), or --cache=<path> (Some(Some(path)))"
)]
cache: Option<Option<PathBuf>>,
}
#[derive(Args, Debug)]
#[command(next_help_heading = HEADING_DISCRIMINANT)]
struct QueryFacetArgs {
#[arg(long, value_name = "NAME")]
engine: Vec<String>,
#[arg(long, value_name = "TRIPLE")]
target_triple: Vec<String>,
#[arg(long, value_name = "KEY")]
machine_key: Vec<String>,
}
#[derive(Args, Debug)]
#[command(next_help_heading = HEADING_COMMIT)]
struct TimelineArgs {
#[arg(long, value_name = "REF")]
context: Option<String>,
#[arg(long, value_name = "REF")]
base: Option<String>,
#[arg(long, value_name = "WHEN")]
since: Option<String>,
}
#[derive(Args, Debug)]
#[command(next_help_heading = HEADING_OUTPUT)]
struct OutputArgs {
#[arg(long)]
no_text: bool,
#[arg(long, value_name = "PATH")]
markdown: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
json: Option<PathBuf>,
}
#[derive(Args, Debug)]
struct CollectCommand {
#[command(flatten)]
env: EnvArgs,
#[arg(long, value_name = "KEY", help_heading = HEADING_DISCRIMINANT)]
machine_key: Option<String>,
#[arg(long, help_heading = HEADING_SCOPE, conflicts_with = "package")]
workspace: bool,
#[arg(long = "package", short = 'p', value_name = "NAME", help_heading = HEADING_SCOPE)]
package: Vec<String>,
#[arg(long, value_name = "NAME", help_heading = HEADING_SCOPE, conflicts_with = "package")]
exclude: Vec<String>,
#[arg(long, value_name = "NAME", help_heading = HEADING_SCOPE)]
bench: Vec<String>,
#[arg(long, value_name = "FEATURES", help_heading = HEADING_FEATURES)]
features: Vec<String>,
#[arg(long, help_heading = HEADING_FEATURES)]
all_features: bool,
#[arg(long, help_heading = HEADING_FEATURES)]
no_default_features: bool,
#[arg(long, help_heading = HEADING_ENV)]
no_store: bool,
#[arg(long, help_heading = HEADING_ENV)]
overwrite: bool,
#[arg(long, help_heading = HEADING_ENV, conflicts_with = "overwrite")]
skip_existing: bool,
#[arg(long = "best-of", value_name = "N", default_value_t = NonZeroUsize::MIN, help_heading = HEADING_ENV)]
best_of: NonZeroUsize,
#[arg(last = true, value_name = "ARGS")]
passthrough: Vec<String>,
}
impl CollectCommand {
fn into_options(self) -> CollectOptions {
CollectOptions {
config_path: self.env.config,
repo: self.env.repo,
local: local_selection(self.env.local),
packages: resolve_packages(self.workspace, self.package),
excludes: self.exclude,
benches: self.bench,
features: self.features,
all_features: self.all_features,
no_default_features: self.no_default_features,
machine_key: self.machine_key,
no_store: self.no_store,
overwrite: self.overwrite,
skip_existing: self.skip_existing,
passthrough: self.passthrough,
verbose: self.env.verbose,
best_of: self.best_of,
}
}
}
#[derive(Args, Debug)]
struct InstallCommand {
#[arg(long, value_name = "PATH", help_heading = HEADING_ENV)]
config: Option<PathBuf>,
#[arg(long, help_heading = HEADING_ENV)]
verbose: bool,
}
impl InstallCommand {
fn into_options(self) -> InstallOptions {
InstallOptions {
config_path: self.config,
verbose: self.verbose,
}
}
}
#[derive(Args, Debug)]
struct MachineKeyCommand {
#[arg(long, help_heading = HEADING_ENV)]
verbose: bool,
}
impl MachineKeyCommand {
fn into_options(self) -> MachineKeyOptions {
MachineKeyOptions {
verbose: self.verbose,
}
}
}
#[derive(Args, Debug)]
struct AnalyzeCommand {
#[arg(value_name = "PREFIX")]
prefixes: Vec<BenchmarkIdPrefix>,
#[command(flatten)]
env: EnvArgs,
#[command(flatten)]
cache: CacheArg,
#[command(flatten)]
output: OutputArgs,
#[command(flatten)]
facets: QueryFacetArgs,
#[command(flatten)]
timeline: TimelineArgs,
#[arg(long, help_heading = HEADING_FILTER)]
no_dirty: bool,
#[arg(long, help_heading = HEADING_ANALYSIS)]
include_improvements: bool,
#[arg(long, help_heading = HEADING_ANALYSIS)]
include_inactive: bool,
#[arg(long, help_heading = HEADING_ANALYSIS)]
include_ghosts: bool,
#[arg(long, value_name = "PATH", help_heading = HEADING_OUTPUT)]
markdown_summary: Option<PathBuf>,
}
impl AnalyzeCommand {
fn into_options(self) -> AnalyzeOptions {
AnalyzeOptions {
config_path: self.env.config,
repo: self.env.repo,
local: local_selection(self.env.local),
cache: cache_selection(self.cache.cache),
context: self.timeline.context,
base: self.timeline.base,
no_dirty: self.no_dirty,
since: self.timeline.since,
engine: self.facets.engine,
target_triple: self.facets.target_triple,
machine_key: self.facets.machine_key,
prefixes: self.prefixes,
no_text: self.output.no_text,
markdown: self.output.markdown,
json: self.output.json,
markdown_summary: self.markdown_summary,
include_improvements: self.include_improvements,
include_inactive: self.include_inactive,
include_ghosts: self.include_ghosts,
verbose: self.env.verbose,
timing: false,
}
}
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum ListSubjectArg {
Runs,
Discriminants,
Blessings,
}
impl From<ListSubjectArg> for ListSubject {
fn from(subject: ListSubjectArg) -> Self {
match subject {
ListSubjectArg::Runs => Self::Runs,
ListSubjectArg::Discriminants => Self::Discriminants,
ListSubjectArg::Blessings => Self::Blessings,
}
}
}
#[derive(Args, Debug)]
struct ListCommand {
#[arg(value_name = "runs|discriminants|blessings")]
subject: ListSubjectArg,
#[command(flatten)]
env: EnvArgs,
#[command(flatten)]
cache: CacheArg,
#[command(flatten)]
output: OutputArgs,
#[command(flatten)]
facets: QueryFacetArgs,
#[command(flatten)]
timeline: TimelineArgs,
#[arg(long, help_heading = HEADING_FILTER)]
no_dirty: bool,
#[arg(long, help_heading = HEADING_FILTER)]
all: bool,
}
impl ListCommand {
fn into_options(self) -> ListOptions {
ListOptions {
subject: self.subject.into(),
config_path: self.env.config,
repo: self.env.repo,
local: local_selection(self.env.local),
cache: cache_selection(self.cache.cache),
context: self.timeline.context,
base: self.timeline.base,
no_dirty: self.no_dirty,
since: self.timeline.since,
engine: self.facets.engine,
target_triple: self.facets.target_triple,
machine_key: self.facets.machine_key,
no_text: self.output.no_text,
markdown: self.output.markdown,
json: self.output.json,
all: self.all,
verbose: self.env.verbose,
}
}
}
#[derive(Args, Debug)]
struct ExamineCommand {
#[command(flatten)]
env: EnvArgs,
#[command(flatten)]
cache: CacheArg,
#[command(flatten)]
output: OutputArgs,
#[command(flatten)]
facets: QueryFacetArgs,
#[command(flatten)]
timeline: TimelineArgs,
#[arg(long, value_name = "ID", help_heading = HEADING_SCOPE)]
benchmark: String,
#[arg(long, value_name = "NAME", help_heading = HEADING_SCOPE)]
metric: String,
#[arg(long, help_heading = HEADING_FILTER)]
no_dirty: bool,
}
impl ExamineCommand {
fn into_options(self) -> ExamineOptions {
ExamineOptions {
config_path: self.env.config,
repo: self.env.repo,
local: local_selection(self.env.local),
cache: cache_selection(self.cache.cache),
context: self.timeline.context,
base: self.timeline.base,
no_dirty: self.no_dirty,
since: self.timeline.since,
engine: self.facets.engine,
target_triple: self.facets.target_triple,
machine_key: self.facets.machine_key,
benchmark: self.benchmark,
metric: self.metric,
no_text: self.output.no_text,
markdown: self.output.markdown,
json: self.output.json,
verbose: self.env.verbose,
}
}
}
#[derive(Args, Debug)]
#[command(group(
ArgGroup::new("prune-kind")
.args(["clean", "dirty", "all"])
.required(true)
))]
struct PruneCommand {
#[arg(value_name = "COMMIT")]
commit: Vec<String>,
#[command(flatten)]
env: EnvArgs,
#[command(flatten)]
cache: CacheArg,
#[arg(long, help_heading = HEADING_ENV)]
dry_run: bool,
#[arg(long, help_heading = HEADING_ENV)]
prune_base: bool,
#[command(flatten)]
output: OutputArgs,
#[command(flatten)]
facets: QueryFacetArgs,
#[command(flatten)]
commit_selection: PruneCommitArgs,
#[arg(long, help_heading = HEADING_FILTER)]
clean: bool,
#[arg(long, help_heading = HEADING_FILTER)]
dirty: bool,
#[arg(long, help_heading = HEADING_FILTER)]
all: bool,
}
#[derive(Args, Debug)]
#[command(next_help_heading = HEADING_COMMIT)]
struct PruneCommitArgs {
#[arg(long, value_name = "REF")]
context: Option<String>,
#[arg(long, value_name = "REF")]
base: Option<String>,
#[arg(long, value_name = "WHEN")]
since: Option<String>,
}
impl PruneCommand {
fn into_options(self) -> PruneOptions {
let clean = self.clean || self.all;
let dirty = self.dirty || self.all;
PruneOptions {
config_path: self.env.config,
repo: self.env.repo,
local: local_selection(self.env.local),
cache: cache_selection(self.cache.cache),
context: self.commit_selection.context,
base: self.commit_selection.base,
commit: self.commit,
since: self.commit_selection.since,
engine: self.facets.engine,
target_triple: self.facets.target_triple,
machine_key: self.facets.machine_key,
clean,
dirty,
prune_base: self.prune_base,
dry_run: self.dry_run,
no_text: self.output.no_text,
markdown: self.output.markdown,
json: self.output.json,
verbose: self.env.verbose,
}
}
}
#[derive(Args, Debug)]
struct BackfillCommand {
#[arg(value_name = "FROM")]
from: String,
#[arg(value_name = "TO")]
to: String,
#[command(flatten)]
env: EnvArgs,
#[arg(long, value_name = "KEY", help_heading = HEADING_DISCRIMINANT)]
machine_key: Option<String>,
#[arg(long, help_heading = HEADING_SCOPE, conflicts_with = "package")]
workspace: bool,
#[arg(long = "package", short = 'p', value_name = "NAME", help_heading = HEADING_SCOPE)]
package: Vec<String>,
#[arg(long, value_name = "NAME", help_heading = HEADING_SCOPE, conflicts_with = "package")]
exclude: Vec<String>,
#[arg(long, value_name = "NAME", help_heading = HEADING_SCOPE)]
bench: Vec<String>,
#[arg(long, value_name = "FEATURES", help_heading = HEADING_FEATURES)]
features: Vec<String>,
#[arg(long, help_heading = HEADING_FEATURES)]
all_features: bool,
#[arg(long, help_heading = HEADING_FEATURES)]
no_default_features: bool,
#[arg(long, help_heading = HEADING_ENV)]
overwrite: bool,
#[arg(long, help_heading = HEADING_ENV)]
ignore_errors: bool,
#[arg(long = "best-of", value_name = "N", default_value_t = NonZeroUsize::MIN, help_heading = HEADING_ENV)]
best_of: NonZeroUsize,
#[arg(last = true, value_name = "ARGS")]
passthrough: Vec<String>,
}
impl BackfillCommand {
fn into_options(self) -> BackfillOptions {
BackfillOptions {
config_path: self.env.config,
repo: self.env.repo,
local: local_selection(self.env.local),
from: self.from,
to: self.to,
packages: resolve_packages(self.workspace, self.package),
excludes: self.exclude,
benches: self.bench,
features: self.features,
all_features: self.all_features,
no_default_features: self.no_default_features,
machine_key: self.machine_key,
overwrite: self.overwrite,
ignore_errors: self.ignore_errors,
passthrough: self.passthrough,
verbose: self.env.verbose,
best_of: self.best_of,
}
}
}
#[derive(Args, Debug)]
struct BlessCommand {
#[arg(value_name = "PREFIX")]
prefixes: Vec<BenchmarkIdPrefix>,
#[command(flatten)]
env: EnvArgs,
#[arg(long, conflicts_with = "prefixes")]
all: bool,
#[arg(long, value_name = "REF", help_heading = HEADING_COMMIT)]
context: Option<String>,
#[arg(long, value_name = "REF", help_heading = HEADING_COMMIT)]
base: Option<String>,
#[command(flatten)]
facets: QueryFacetArgs,
}
impl BlessCommand {
fn into_options(self) -> BlessOptions {
BlessOptions {
config_path: self.env.config,
repo: self.env.repo,
local: local_selection(self.env.local),
context: self.context,
base: self.base,
engine: self.facets.engine,
target_triple: self.facets.target_triple,
machine_key: self.facets.machine_key,
prefixes: self.prefixes,
all: self.all,
verbose: self.env.verbose,
}
}
}
#[derive(Args, Debug)]
struct UnblessCommand {
#[command(flatten)]
env: EnvArgs,
#[arg(long, value_name = "REF", help_heading = HEADING_COMMIT)]
context: Option<String>,
#[arg(long, value_name = "REF", help_heading = HEADING_COMMIT)]
base: Option<String>,
#[command(flatten)]
facets: QueryFacetArgs,
}
impl UnblessCommand {
fn into_options(self) -> UnblessOptions {
UnblessOptions {
config_path: self.env.config,
repo: self.env.repo,
local: local_selection(self.env.local),
context: self.context,
base: self.base,
engine: self.facets.engine,
target_triple: self.facets.target_triple,
machine_key: self.facets.machine_key,
verbose: self.env.verbose,
}
}
}
fn resolve_packages(workspace: bool, package: Vec<String>) -> Vec<String> {
if workspace { Vec::new() } else { package }
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
fn parse(args: &[&str]) -> Command {
Cli::from_args(&["cargo-bench-history"], args)
.unwrap()
.into_command()
}
#[test]
fn cli_is_debug_formatted() {
let cli = Cli::from_args(&["cargo-bench-history"], &["collect"]).unwrap();
assert!(format!("{cli:?}").contains("Collect"), "{cli:?}");
}
#[test]
fn help_lists_every_command() {
let help = Cli::help("cargo-bench-history");
assert!(!help.is_empty(), "help text is non-empty");
for command in [
"analyze", "backfill", "bless", "collect", "examine", "install", "list", "prune",
"unbless",
] {
assert!(help.contains(command), "help lists {command}: {help}");
}
}
#[test]
fn collect_parses_scope_and_passthrough() {
let command = parse(&[
"collect",
"--package",
"nm",
"-p",
"many_cpus",
"--bench",
"nm_observe",
"--",
"--noplot",
]);
let Command::Collect(options) = command else {
panic!("expected collect command");
};
assert_eq!(
options.packages,
vec!["nm".to_owned(), "many_cpus".to_owned()]
);
assert_eq!(options.benches, vec!["nm_observe".to_owned()]);
assert_eq!(options.passthrough, vec!["--noplot".to_owned()]);
assert!(!options.overwrite);
}
#[test]
fn collect_workspace_and_package_conflict() {
let error = Cli::from_args(
&["cargo-bench-history"],
&["collect", "--workspace", "-p", "nm"],
)
.unwrap_err();
assert_eq!(error.status, Err(()));
assert!(
error.output.contains("cannot be used with"),
"{}",
error.output
);
}
#[test]
fn collect_parses_exclude_filters() {
let command = parse(&["collect", "--exclude", "nm", "--exclude", "many_cpus"]);
let Command::Collect(options) = command else {
panic!("expected collect command");
};
assert!(
options.packages.is_empty(),
"exclude implies workspace scope"
);
assert_eq!(
options.excludes,
vec!["nm".to_owned(), "many_cpus".to_owned()]
);
}
#[test]
fn collect_parses_feature_selection() {
let command = parse(&[
"collect",
"--features",
"foo,bar",
"--features",
"baz",
"--no-default-features",
]);
let Command::Collect(options) = command else {
panic!("expected collect command");
};
assert_eq!(
options.features,
vec!["foo,bar".to_owned(), "baz".to_owned()]
);
assert!(!options.all_features);
assert!(options.no_default_features);
}
#[test]
fn collect_parses_all_features() {
let command = parse(&["collect", "--all-features"]);
let Command::Collect(options) = command else {
panic!("expected collect command");
};
assert!(options.all_features);
assert!(options.features.is_empty());
}
#[test]
fn collect_best_of_defaults_to_one_and_parses_a_value() {
let Command::Collect(options) = parse(&["collect"]) else {
panic!("expected collect command");
};
assert_eq!(
options.best_of.get(),
1,
"--best-of defaults to a single run"
);
let Command::Collect(options) = parse(&["collect", "--best-of", "5", "--no-store"]) else {
panic!("expected collect command");
};
assert_eq!(options.best_of.get(), 5);
assert!(options.no_store, "--best-of coexists with --no-store");
}
#[test]
fn collect_best_of_rejects_zero() {
let parsed = Cli::from_args(&["cargo-bench-history"], &["collect", "--best-of", "0"]);
assert!(parsed.is_err(), "--best-of 0 must be rejected");
}
#[test]
fn collect_exclude_and_package_conflict() {
let error = Cli::from_args(
&["cargo-bench-history"],
&["collect", "--exclude", "nm", "-p", "many_cpus"],
)
.unwrap_err();
assert_eq!(error.status, Err(()));
assert!(
error.output.contains("cannot be used with"),
"{}",
error.output
);
}
#[test]
fn backfill_workspace_and_package_conflict() {
let error = Cli::from_args(
&["cargo-bench-history"],
&["backfill", "abc", "def", "--workspace", "-p", "nm"],
)
.unwrap_err();
assert_eq!(error.status, Err(()));
assert!(
error.output.contains("cannot be used with"),
"{}",
error.output
);
}
#[test]
fn backfill_collects_exclude_filters() {
let command = parse(&["backfill", "abc", "def", "--exclude", "nm"]);
let Command::Backfill(options) = command else {
panic!("expected backfill command");
};
assert!(
options.packages.is_empty(),
"exclude implies workspace scope"
);
assert_eq!(options.excludes, vec!["nm".to_owned()]);
}
#[test]
fn backfill_collects_feature_selection() {
let command = parse(&[
"backfill",
"abc",
"def",
"--features",
"foo",
"--all-features",
]);
let Command::Backfill(options) = command else {
panic!("expected backfill command");
};
assert_eq!(options.features, vec!["foo".to_owned()]);
assert!(options.all_features);
assert!(!options.no_default_features);
}
#[test]
fn backfill_exclude_and_package_conflict() {
let error = Cli::from_args(
&["cargo-bench-history"],
&[
"backfill",
"abc",
"def",
"--exclude",
"nm",
"-p",
"many_cpus",
],
)
.unwrap_err();
assert_eq!(error.status, Err(()));
assert!(
error.output.contains("cannot be used with"),
"{}",
error.output
);
}
#[test]
fn collect_parses_overwrite_switch() {
let command = parse(&["collect", "--overwrite"]);
let Command::Collect(options) = command else {
panic!("expected collect command");
};
assert!(options.overwrite);
}
#[test]
fn collect_parses_skip_existing_switch() {
let command = parse(&["collect", "--skip-existing"]);
let Command::Collect(options) = command else {
panic!("expected collect command");
};
assert!(options.skip_existing);
assert!(!options.overwrite);
}
#[test]
fn collect_rejects_skip_existing_with_overwrite() {
let parsed = Cli::from_args(
&["cargo-bench-history"],
&["collect", "--overwrite", "--skip-existing"],
);
assert!(
parsed.is_err(),
"--skip-existing and --overwrite are mutually exclusive"
);
}
#[test]
fn collect_parses_repo() {
let command = parse(&["collect", "--repo", "/work/folo"]);
let Command::Collect(options) = command else {
panic!("expected collect command");
};
assert_eq!(options.repo, Some(PathBuf::from("/work/folo")));
}
#[test]
fn local_defaults_to_none() {
let Command::Collect(options) = parse(&["collect"]) else {
panic!("expected collect command");
};
assert_eq!(options.local, None);
}
#[test]
fn local_with_value_selects_an_explicit_path() {
let Command::Collect(options) = parse(&["collect", "--local=./store"]) else {
panic!("expected collect command");
};
assert_eq!(
options.local,
Some(LocalStorageSelection::Path(PathBuf::from("./store")))
);
}
#[test]
fn bare_local_selects_the_environment_variable() {
let Command::Analyze(options) = parse(&["analyze", "--local"]) else {
panic!("expected analyze command");
};
assert_eq!(options.local, Some(LocalStorageSelection::FromEnv));
}
#[test]
fn local_requires_equals_for_its_value() {
let Command::Backfill(options) = parse(&["backfill", "--local", "abc", "def"]) else {
panic!("expected backfill command");
};
assert_eq!(options.local, Some(LocalStorageSelection::FromEnv));
assert_eq!(options.from, "abc");
assert_eq!(options.to, "def");
}
#[test]
fn cache_defaults_to_none() {
let Command::Analyze(options) = parse(&["analyze"]) else {
panic!("expected analyze command");
};
assert_eq!(options.cache, None);
}
#[test]
fn cache_with_value_selects_an_explicit_path() {
let Command::Analyze(options) = parse(&["analyze", "--cache=./mirror"]) else {
panic!("expected analyze command");
};
assert_eq!(
options.cache,
Some(CacheSelection::Path(PathBuf::from("./mirror")))
);
}
#[test]
fn bare_cache_selects_the_environment_variable() {
let Command::List(options) = parse(&["list", "discriminants", "--cache"]) else {
panic!("expected list command");
};
assert_eq!(options.cache, Some(CacheSelection::FromEnv));
}
#[test]
fn prune_parses_cache() {
let Command::Prune(options) = parse(&["prune", "--clean", "--cache=./mirror"]) else {
panic!("expected prune command");
};
assert_eq!(
options.cache,
Some(CacheSelection::Path(PathBuf::from("./mirror")))
);
}
#[test]
fn cache_requires_equals_for_its_value() {
let Command::Analyze(options) = parse(&["analyze", "--cache", "all_the_time/read_cell"])
else {
panic!("expected analyze command");
};
assert_eq!(options.cache, Some(CacheSelection::FromEnv));
assert_eq!(
options.prefixes,
vec![BenchmarkIdPrefix::new("all_the_time/read_cell").unwrap()]
);
}
#[test]
fn cache_conflicts_with_local() {
let parsed = Cli::from_args(
&["cargo-bench-history"],
&["analyze", "--local=./store", "--cache=./mirror"],
);
assert!(
parsed.is_err(),
"--cache and --local are mutually exclusive"
);
assert!(
Cli::from_args(
&["cargo-bench-history"],
&[
"list",
"discriminants",
"--local=./store",
"--cache=./mirror"
],
)
.is_err(),
"list must reject --cache with --local"
);
assert!(
Cli::from_args(
&["cargo-bench-history"],
&["prune", "--clean", "--local=./store", "--cache=./mirror"],
)
.is_err(),
"prune must reject --cache with --local"
);
}
#[test]
fn collect_parses_machine_key_override() {
let command = parse(&["collect", "--machine-key", "ci-pool-a"]);
let Command::Collect(options) = command else {
panic!("expected collect command");
};
assert_eq!(options.machine_key.as_deref(), Some("ci-pool-a"));
}
#[test]
fn collect_parses_verbose_switch() {
let Command::Collect(options) = parse(&["collect", "--verbose"]) else {
panic!("expected collect command");
};
assert!(options.verbose);
let Command::Collect(options) = parse(&["collect"]) else {
panic!("expected collect command");
};
assert!(!options.verbose);
}
#[test]
fn install_maps_to_install_command() {
let command = parse(&["install"]);
assert_eq!(command, Command::Install(InstallOptions::default()));
}
#[test]
fn install_captures_config_path() {
let command = parse(&["install", "--config", "custom/bench.toml"]);
let Command::Install(options) = command else {
panic!("expected install command");
};
assert_eq!(
options.config_path,
Some(PathBuf::from("custom/bench.toml"))
);
}
#[test]
fn install_parses_verbose_switch() {
let Command::Install(options) = parse(&["install", "--verbose"]) else {
panic!("expected install command");
};
assert!(options.verbose);
let Command::Install(options) = parse(&["install"]) else {
panic!("expected install command");
};
assert!(!options.verbose);
}
#[test]
fn machine_key_maps_to_machine_key_command() {
let command = parse(&["machine-key"]);
assert_eq!(command, Command::MachineKey(MachineKeyOptions::default()));
}
#[test]
fn machine_key_parses_verbose_switch() {
let Command::MachineKey(options) = parse(&["machine-key", "--verbose"]) else {
panic!("expected machine-key command");
};
assert!(options.verbose);
let Command::MachineKey(options) = parse(&["machine-key"]) else {
panic!("expected machine-key command");
};
assert!(!options.verbose);
}
#[test]
fn analyze_parses_verbose_switch() {
let Command::Analyze(options) = parse(&["analyze", "--verbose"]) else {
panic!("expected analyze command");
};
assert!(options.verbose);
let Command::Analyze(options) = parse(&["analyze"]) else {
panic!("expected analyze command");
};
assert!(!options.verbose);
}
#[test]
fn analyze_collects_switches() {
let command = parse(&["analyze", "--include-improvements"]);
let Command::Analyze(options) = command else {
panic!("expected analyze command");
};
assert!(options.include_improvements);
}
#[test]
fn analyze_collects_topology_and_repeatable_facets() {
let command = parse(&[
"analyze",
"--repo",
"/work/folo",
"--context",
"feature",
"--base",
"master",
"--since",
"2024-06-01T00:00:00Z",
"--no-dirty",
"--engine",
"callgrind",
"--engine",
"criterion",
"--target-triple",
"all",
"--machine-key",
"ci-pool",
]);
let Command::Analyze(options) = command else {
panic!("expected analyze command");
};
assert_eq!(options.repo, Some(PathBuf::from("/work/folo")));
assert_eq!(options.context.as_deref(), Some("feature"));
assert_eq!(options.base.as_deref(), Some("master"));
assert_eq!(options.since.as_deref(), Some("2024-06-01T00:00:00Z"));
assert!(options.no_dirty);
assert_eq!(
options.engine,
vec!["callgrind".to_owned(), "criterion".to_owned()]
);
assert_eq!(options.target_triple, vec!["all".to_owned()]);
assert_eq!(options.machine_key, vec!["ci-pool".to_owned()]);
}
#[test]
fn analyze_facets_default_to_empty() {
let Command::Analyze(options) = parse(&["analyze"]) else {
panic!("expected analyze command");
};
assert!(options.engine.is_empty());
assert!(options.target_triple.is_empty());
assert!(options.machine_key.is_empty());
assert!(options.since.is_none());
}
#[test]
fn until_flag_is_rejected_after_removal() {
for args in [
vec!["analyze", "--until", "2024-06-01"],
vec!["list", "runs", "--until", "2024-06-01"],
vec![
"examine",
"--benchmark",
"b",
"--metric",
"m",
"--until",
"2024-06-01",
],
vec!["prune", "--dirty", "--until", "2024-06-01"],
] {
let error = Cli::from_args(&["cargo-bench-history"], &args).unwrap_err();
assert!(error.status.is_err(), "{args:?} should reject --until");
assert!(
error.output.contains("--until"),
"{args:?} error should name the rejected flag: {}",
error.output
);
assert!(
error.output.contains("unexpected argument"),
"{args:?} error should reject --until as an unexpected argument: {}",
error.output
);
}
}
#[test]
fn analyze_output_defaults_to_text_only() {
let Command::Analyze(options) = parse(&["analyze"]) else {
panic!("expected analyze command");
};
assert!(!options.no_text);
assert!(options.markdown.is_none());
assert!(options.json.is_none());
}
#[test]
fn analyze_collects_output_toggles() {
let Command::Analyze(options) = parse(&[
"analyze",
"--no-text",
"--markdown",
"out/report.md",
"--json",
"out/report.json",
]) else {
panic!("expected analyze command");
};
assert!(options.no_text);
assert_eq!(options.markdown, Some(PathBuf::from("out/report.md")));
assert_eq!(options.json, Some(PathBuf::from("out/report.json")));
}
#[test]
fn analyze_parses_include_inactive_switch() {
let Command::Analyze(options) = parse(&["analyze", "--include-inactive"]) else {
panic!("expected analyze command");
};
assert!(options.include_inactive);
let Command::Analyze(options) = parse(&["analyze"]) else {
panic!("expected analyze command");
};
assert!(!options.include_inactive);
}
#[test]
fn analyze_parses_include_ghosts_switch() {
let Command::Analyze(options) = parse(&["analyze", "--include-ghosts"]) else {
panic!("expected analyze command");
};
assert!(options.include_ghosts);
let Command::Analyze(options) = parse(&["analyze"]) else {
panic!("expected analyze command");
};
assert!(
!options.include_ghosts,
"ghost filtering is on by default (the flag opts out of it)"
);
}
#[test]
fn list_requires_a_subject() {
let parsed = Cli::from_args(&["cargo-bench-history"], &["list"]);
let early = parsed.unwrap_err();
assert!(early.status.is_err(), "a missing subject is a parse error");
for subject in ["runs", "discriminants", "blessings"] {
assert!(
early.output.contains(subject),
"the error names the {subject} subject: {}",
early.output
);
}
}
#[test]
fn list_runs_collects_selection() {
let command = parse(&[
"list",
"runs",
"--repo",
"/work/folo",
"--context",
"feature",
"--base",
"master",
"--no-dirty",
"--engine",
"callgrind",
"--target-triple",
"x86_64-unknown-linux-gnu",
"--machine-key",
"ci-pool",
"--no-text",
"--markdown",
"list.md",
"--json",
"list.json",
"--verbose",
]);
let Command::List(options) = command else {
panic!("expected list command");
};
assert_eq!(options.subject, ListSubject::Runs);
assert_eq!(options.repo, Some(PathBuf::from("/work/folo")));
assert_eq!(options.context.as_deref(), Some("feature"));
assert_eq!(options.base.as_deref(), Some("master"));
assert!(options.no_dirty);
assert_eq!(options.engine, vec!["callgrind".to_owned()]);
assert_eq!(
options.target_triple,
vec!["x86_64-unknown-linux-gnu".to_owned()]
);
assert_eq!(options.machine_key, vec!["ci-pool".to_owned()]);
assert!(options.no_text);
assert_eq!(options.markdown, Some(PathBuf::from("list.md")));
assert_eq!(options.json, Some(PathBuf::from("list.json")));
assert!(options.verbose);
}
#[test]
fn list_discriminants_selects_the_subject() {
let Command::List(options) = parse(&["list", "discriminants"]) else {
panic!("expected list command");
};
assert_eq!(options.subject, ListSubject::Discriminants);
}
#[test]
fn list_blessings_collects_all_switch() {
let Command::List(options) = parse(&["list", "blessings", "--all"]) else {
panic!("expected list command");
};
assert_eq!(options.subject, ListSubject::Blessings);
assert!(options.all);
let Command::List(options) = parse(&["list", "blessings"]) else {
panic!("expected list command");
};
assert!(!options.all);
}
#[test]
fn examine_collects_selection_scope_and_output() {
let command = parse(&[
"examine",
"--benchmark",
"nm/nm::observe/pull",
"--metric",
"instruction_count",
"--repo",
"/work/folo",
"--context",
"feature",
"--base",
"master",
"--no-dirty",
"--engine",
"callgrind",
"--target-triple",
"x86_64-unknown-linux-gnu",
"--machine-key",
"ci-pool",
"--since",
"2024-01-01",
"--no-text",
"--markdown",
"examine.md",
"--json",
"examine.json",
"--verbose",
]);
let Command::Examine(options) = command else {
panic!("expected examine command");
};
assert_eq!(options.benchmark, "nm/nm::observe/pull");
assert_eq!(options.metric, "instruction_count");
assert_eq!(options.repo, Some(PathBuf::from("/work/folo")));
assert_eq!(options.context.as_deref(), Some("feature"));
assert_eq!(options.base.as_deref(), Some("master"));
assert!(options.no_dirty);
assert_eq!(options.engine, vec!["callgrind".to_owned()]);
assert_eq!(
options.target_triple,
vec!["x86_64-unknown-linux-gnu".to_owned()]
);
assert_eq!(options.machine_key, vec!["ci-pool".to_owned()]);
assert_eq!(options.since.as_deref(), Some("2024-01-01"));
assert!(options.no_text);
assert_eq!(options.markdown, Some(PathBuf::from("examine.md")));
assert_eq!(options.json, Some(PathBuf::from("examine.json")));
assert!(options.verbose);
}
#[test]
fn examine_requires_benchmark_and_metric() {
let early = Cli::from_args(&["cargo-bench-history"], &["examine"]).unwrap_err();
assert!(
early.status.is_err(),
"missing required flags are a parse error"
);
assert!(early.output.contains("--benchmark"), "{}", early.output);
assert!(early.output.contains("--metric"), "{}", early.output);
let missing_metric = Cli::from_args(
&["cargo-bench-history"],
&["examine", "--benchmark", "nm/nm::observe/pull"],
)
.unwrap_err();
assert!(missing_metric.status.is_err());
assert!(
missing_metric.output.contains("--metric"),
"{}",
missing_metric.output
);
}
#[test]
fn bless_collects_prefixes_facets_and_context() {
let command = parse(&[
"bless",
"--engine",
"callgrind",
"--context",
"abc123",
"all_the_time/read_cell",
"overhead/groups_",
]);
let Command::Bless(options) = command else {
panic!("expected bless command");
};
assert_eq!(
options.prefixes,
vec![
BenchmarkIdPrefix::new("all_the_time/read_cell").unwrap(),
BenchmarkIdPrefix::new("overhead/groups_").unwrap()
]
);
assert_eq!(options.engine, vec!["callgrind".to_owned()]);
assert_eq!(options.context.as_deref(), Some("abc123"));
assert!(!options.all);
}
#[test]
fn bless_all_switch_needs_no_prefixes() {
let Command::Bless(options) = parse(&["bless", "--all"]) else {
panic!("expected bless command");
};
assert!(options.all);
assert!(options.prefixes.is_empty());
}
#[test]
fn bless_all_conflicts_with_prefixes() {
let error =
Cli::from_args(&["cargo-bench-history"], &["bless", "--all", "foo/bar"]).unwrap_err();
assert_eq!(error.status, Err(()));
assert!(
error.output.contains("cannot be used with"),
"{}",
error.output
);
}
#[test]
fn bless_rejects_an_empty_prefix() {
let error = Cli::from_args(&["cargo-bench-history"], &["bless", ""]).unwrap_err();
assert_eq!(error.status, Err(()));
assert!(
error.output.contains("benchmark-id prefix"),
"{}",
error.output
);
}
#[test]
fn unbless_parses_facets() {
let command = parse(&[
"unbless",
"--context",
"abc123",
"--target-triple",
"x86_64-unknown-linux-gnu",
"--machine-key",
"ci-pool",
]);
let Command::Unbless(options) = command else {
panic!("expected unbless command");
};
assert_eq!(options.context.as_deref(), Some("abc123"));
assert_eq!(
options.target_triple,
vec!["x86_64-unknown-linux-gnu".to_owned()]
);
assert_eq!(options.machine_key, vec!["ci-pool".to_owned()]);
}
#[test]
fn prune_collects_commits_selection_and_dry_run() {
let command = parse(&[
"prune",
"abc123",
"def456",
"--repo",
"/work/folo",
"--context",
"feature",
"--base",
"master",
"--since",
"2024-01-01T00:00:00Z",
"--engine",
"callgrind",
"--target-triple",
"x86_64-unknown-linux-gnu",
"--machine-key",
"ci-pool",
"--dirty",
"--dry-run",
"--no-text",
"--json",
"prune.json",
"--verbose",
]);
let Command::Prune(options) = command else {
panic!("expected prune command");
};
assert_eq!(options.repo, Some(PathBuf::from("/work/folo")));
assert_eq!(options.context.as_deref(), Some("feature"));
assert_eq!(options.base.as_deref(), Some("master"));
assert_eq!(
options.commit,
vec!["abc123".to_owned(), "def456".to_owned()]
);
assert_eq!(options.since.as_deref(), Some("2024-01-01T00:00:00Z"));
assert_eq!(options.engine, vec!["callgrind".to_owned()]);
assert_eq!(
options.target_triple,
vec!["x86_64-unknown-linux-gnu".to_owned()]
);
assert_eq!(options.machine_key, vec!["ci-pool".to_owned()]);
assert!(options.dirty);
assert!(!options.clean);
assert!(options.dry_run);
assert!(options.no_text);
assert_eq!(options.json, Some(PathBuf::from("prune.json")));
assert!(options.verbose);
}
#[test]
fn prune_all_expands_to_clean_and_dirty() {
let command = parse(&[
"prune",
"--target-triple",
"x86_64-unknown-linux-gnu",
"--all",
]);
let Command::Prune(options) = command else {
panic!("expected prune command");
};
assert_eq!(
options.target_triple,
vec!["x86_64-unknown-linux-gnu".to_owned()]
);
assert!(options.commit.is_empty());
assert!(options.clean, "--all enables clean removal");
assert!(options.dirty, "--all enables dirty removal");
assert!(!options.dry_run);
}
#[test]
fn backfill_collects_range_and_passthrough() {
let command = parse(&[
"backfill",
"abc123",
"def456",
"--package",
"nm",
"--bench",
"nm_observe",
"--machine-key",
"ci-pool",
"--overwrite",
"--ignore-errors",
"--",
"--noplot",
]);
let Command::Backfill(options) = command else {
panic!("expected backfill command");
};
assert_eq!(options.from, "abc123");
assert_eq!(options.to, "def456");
assert_eq!(options.packages, vec!["nm".to_owned()]);
assert_eq!(options.benches, vec!["nm_observe".to_owned()]);
assert_eq!(options.machine_key.as_deref(), Some("ci-pool"));
assert!(options.overwrite);
assert!(options.ignore_errors);
assert_eq!(options.passthrough, vec!["--noplot".to_owned()]);
}
#[test]
fn backfill_requires_from_and_to() {
let parsed = Cli::from_args(&["cargo-bench-history"], &["backfill", "abc123"]);
assert!(parsed.is_err(), "a missing `to` must be rejected");
}
#[test]
fn backfill_parses_verbose_switch() {
let Command::Backfill(options) = parse(&["backfill", "abc123", "def456", "--verbose"])
else {
panic!("expected backfill command");
};
assert!(options.verbose);
let Command::Backfill(options) = parse(&["backfill", "abc123", "def456"]) else {
panic!("expected backfill command");
};
assert!(!options.verbose);
}
#[test]
fn backfill_best_of_defaults_to_one_and_parses_a_value() {
let Command::Backfill(options) = parse(&["backfill", "abc123", "def456"]) else {
panic!("expected backfill command");
};
assert_eq!(
options.best_of.get(),
1,
"--best-of defaults to a single run"
);
let Command::Backfill(options) = parse(&["backfill", "abc123", "def456", "--best-of", "3"])
else {
panic!("expected backfill command");
};
assert_eq!(options.best_of.get(), 3);
}
#[test]
fn backfill_best_of_rejects_zero() {
let parsed = Cli::from_args(
&["cargo-bench-history"],
&["backfill", "abc123", "def456", "--best-of", "0"],
);
assert!(parsed.is_err(), "--best-of 0 must be rejected");
}
#[test]
fn unknown_subcommand_is_rejected() {
Cli::from_args(&["cargo-bench-history"], &["frobnicate"]).unwrap_err();
}
#[test]
fn collect_rejects_unknown_flag() {
Cli::from_args(&["cargo-bench-history"], &["collect", "--frobnicate"]).unwrap_err();
}
#[test]
fn help_request_lists_subcommands() {
let early_exit = Cli::from_args(&["cargo-bench-history"], &["--help"]).unwrap_err();
assert!(
early_exit.output.contains("collect"),
"help should list subcommands: {}",
early_exit.output
);
assert!(
early_exit.output.contains("install"),
"{}",
early_exit.output
);
}
#[test]
fn help_text_describes_each_command_in_alphabetical_order() {
let help = Cli::help("cargo-bench-history");
assert!(
help.contains("Analyze stored history"),
"help should describe `analyze`: {help}"
);
assert!(
help.contains("Replay `collect` across a range"),
"help should describe `backfill`: {help}"
);
let order = ["analyze", "backfill", "collect", "install", "list", "prune"];
let positions: Vec<usize> = order
.iter()
.map(|name| help.find(&format!("\n {name} ")).unwrap())
.collect();
assert!(
positions.is_sorted(),
"commands should be listed alphabetically: {help}"
);
}
}