mod config;
mod db;
mod dirs;
mod doctor;
mod feature;
mod hawk;
mod http;
mod include;
mod json;
mod macros;
mod options;
mod plugin;
mod polling;
mod report;
mod resource;
mod runner;
mod srp;
mod steps;
mod unique;
mod validate;
mod vars;
mod world;
use anyhow::{Context, Result};
use clap::{Args, Parser, Subcommand};
use std::path::PathBuf;
use std::sync::Arc;
const LONG_VERSION: &str = concat!(
env!("CARGO_PKG_VERSION"),
"\nGherkin acceptance testing for backend services.\n",
"\n bddkit steps list every step this binary understands",
"\n bddkit doctor --config suite.yaml check a suite before it runs",
);
#[derive(Parser)]
#[command(
name = "bddkit",
version,
long_version = LONG_VERSION,
about = "Run Gherkin scenarios against an HTTP API"
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Run(RunArgs),
Steps(StepsArgs),
Doctor(DoctorArgs),
Resource(ResourceArgs),
Plugin(PluginArgs),
Version,
}
const LAYERS: [&str; 6] = [
"shared",
"shared.local",
"user",
"user.local",
"project",
"project.local",
];
#[derive(Args)]
#[command(after_help = "Examples:
bddkit plugin list every plugin in the index
bddkit plugin list mail only those whose name or description matches
bddkit plugin install exec the latest release, into the user layer
bddkit plugin install exec@0.1.0 --layer project.local
bddkit plugin install someone/bddkit-widget
bddkit plugin update --dry-run what would move, without moving it
bddkit plugin show exec one plugin in detail")]
struct PluginArgs {
#[command(subcommand)]
command: Option<PluginCommand>,
#[arg(long, global = true)]
config: Option<PathBuf>,
#[arg(long = "bddkit-dir", global = true)]
bddkit_dir: Option<PathBuf>,
}
#[derive(Subcommand)]
enum PluginCommand {
List {
query: Option<String>,
},
Install {
plugin: String,
#[arg(long, value_parser = clap::builder::PossibleValuesParser::new(LAYERS))]
layer: Option<String>,
},
Update {
names: Vec<String>,
#[arg(long, value_parser = clap::builder::PossibleValuesParser::new(LAYERS))]
layer: Option<String>,
#[arg(long)]
dry_run: bool,
},
Remove {
name: String,
#[arg(long, value_parser = clap::builder::PossibleValuesParser::new(LAYERS))]
layer: Option<String>,
},
Show { name: Option<String> },
}
async fn plugin_command(args: PluginArgs) -> Result<i32> {
let Some(command) = args.command else {
use clap::CommandFactory;
let mut cli = Cli::command();
cli.build();
cli.find_subcommand_mut("plugin")
.expect("the plugin subcommand is declared")
.print_help()?;
println!();
return Ok(0);
};
let outcome: Result<i32> = async {
let config_dir = match config::resolve_config_path(args.config.as_deref())? {
Some(resolved) => config_dir(&resolved.path).to_path_buf(),
None => PathBuf::from("."),
};
let ctx =
plugin::manage::Context::new(dirs::Env::from_process(args.bddkit_dir), &config_dir)?;
match command {
PluginCommand::List { query } => ctx.list(query.as_deref()).await,
PluginCommand::Install { plugin, layer } => {
ctx.install(&plugin, layer.as_deref()).await
}
PluginCommand::Update {
names,
layer,
dry_run,
} => ctx.update(&names, layer.as_deref(), dry_run).await,
PluginCommand::Remove { name, layer } => ctx.remove(&name, layer.as_deref()).await,
PluginCommand::Show { name } => ctx.show(name.as_deref()).await,
}
}
.await;
Ok(outcome.unwrap_or_else(|error| {
eprintln!("error: {error:#}");
1
}))
}
#[derive(Args)]
#[command(after_help = "Examples:
bddkit resource fields every resource kind bddkit serves itself
bddkit resource fields db only the database connection keys
bddkit resource fields --config suite.yaml also the groups this suite's plugins serve
bddkit resource fields --json the same listing, machine-readable
bddkit resource add api staging --config suite.yaml --base_url http://staging.local
bddkit resource add db reporting --config suite.yaml --no-check --dsn postgres://...
# --config/--env/--json/--no-check must come before any --<field> value")]
struct ResourceArgs {
#[command(subcommand)]
command: Option<ResourceCommand>,
}
#[derive(Subcommand)]
enum ResourceCommand {
Fields(FieldsArgs),
Add(AddArgs),
}
#[derive(Args)]
struct AddArgs {
group: String,
name: String,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long = "env")]
env: Option<String>,
#[arg(long)]
json: Option<String>,
#[arg(long = "no-check")]
no_check: bool,
#[arg(trailing_var_arg = true, allow_hyphen_values = true, num_args = 0..)]
fields: Vec<String>,
}
#[derive(Args)]
struct FieldsArgs {
kind: Option<String>,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long, conflicts_with = "config")]
no_config: bool,
#[arg(long)]
json: bool,
}
async fn resource_command(args: ResourceArgs) -> Result<i32> {
match args.command {
Some(ResourceCommand::Fields(args)) => list_fields(args),
Some(ResourceCommand::Add(mut args)) => {
if let Some(pos) = args.fields.iter().position(|f| f == "--no-check") {
args.fields.remove(pos);
args.no_check = true;
}
let config_path = match config::resolve_config_path(args.config.as_deref()) {
Ok(Some(resolved)) => resolved.path,
Ok(None) => {
println!("{}\n\nnothing was written.", config::NO_CONFIG_FOUND);
return Ok(1);
}
Err(error) => {
println!("{error:#}\n\nnothing was written.");
return Ok(1);
}
};
resource::add(resource::AddInput {
group: &args.group,
name: &args.name,
config: &config_path,
env: args.env.as_deref(),
json: args.json.as_deref(),
no_check: args.no_check,
flags: &args.fields,
})
.await
}
None => {
use clap::CommandFactory;
let mut cli = Cli::command();
cli.build();
cli.find_subcommand_mut("resource")
.expect("the resource subcommand is declared")
.print_help()?;
println!();
Ok(0)
}
}
}
fn list_fields(args: FieldsArgs) -> Result<i32> {
let mut kinds = resource::host_kinds();
if !args.no_config
&& let Some(resolved) = config::resolve_config_path(args.config.as_deref())?
{
let path = &resolved.path;
let cfg = config::load(path, None)?;
let generator = unique::Generator::new();
if let Some(plugins) = load_plugins(path, &cfg, &generator, &dirs::Env::from_process(None))?
{
kinds.extend(resource::plugin_kinds(&plugins));
}
}
if let Some(kind) = &args.kind {
if !kinds.iter().any(|k| &k.kind == kind) {
anyhow::bail!("no such resource: {kind:?}");
}
kinds.retain(|k| &k.kind == kind);
}
if args.json {
println!("{}", serde_json::to_string_pretty(&kinds)?);
} else {
print!("{}", resource::render(&kinds));
}
Ok(0)
}
#[derive(Args)]
#[command(after_help = "Examples:
bddkit doctor --config suite.yaml every static check, no socket opened
bddkit doctor --config suite.yaml --live also probe every API and database
bddkit doctor --config suite.yaml --json the same report, machine-readable
bddkit doctor --config suite.yaml --junit reports/junit.xml
also check that run's report path can be written")]
struct DoctorArgs {
#[arg(long)]
config: Option<PathBuf>,
#[arg(long = "env")]
env: Option<String>,
#[arg(long)]
live: bool,
#[arg(long)]
json: bool,
#[command(flatten)]
dir: DirArgs,
#[command(flatten)]
reports: ReportArgs,
}
async fn doctor_command(args: DoctorArgs) -> Result<i32> {
let dir_env = dirs::Env::from_process(args.dir.bddkit_dir.clone());
let report = doctor::check(
args.config.as_deref(),
args.env.as_deref(),
args.live,
&dir_env,
&args.reports.paths(),
)
.await;
if args.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print!("{}", report.render());
}
Ok(report.exit_code())
}
#[derive(Args)]
struct DirArgs {
#[arg(long = "bddkit-dir")]
bddkit_dir: Option<PathBuf>,
}
#[derive(Args)]
struct RunArgs {
#[arg(long)]
config: Option<PathBuf>,
paths: Vec<PathBuf>,
#[arg(long = "tag")]
tags: Vec<String>,
#[arg(long = "env")]
env: Option<String>,
#[arg(long = "fail-fast")]
fail_fast: bool,
#[command(flatten)]
dir: DirArgs,
#[command(flatten)]
reports: ReportArgs,
}
#[derive(Args)]
struct ReportArgs {
#[arg(long)]
junit: Option<PathBuf>,
#[arg(long = "cucumber-json")]
cucumber_json: Option<PathBuf>,
}
impl ReportArgs {
fn paths(&self) -> Vec<&std::path::Path> {
[&self.junit, &self.cucumber_json]
.into_iter()
.flatten()
.map(PathBuf::as_path)
.collect()
}
fn write(&self, results: &[report::FileResult]) -> Result<()> {
if let Some(path) = &self.junit {
report::write_junit(results, path)?;
}
if let Some(path) = &self.cucumber_json {
report::write_cucumber_json(results, path)?;
}
Ok(())
}
}
#[derive(Args)]
#[command(after_help = "Examples:
bddkit steps list every builtin step, grouped by resource
bddkit steps list db only the database steps
bddkit steps list --filter response -v narrow, and describe what is left
bddkit steps list --json the same listing, machine-readable
bddkit steps list --config suite.yaml also the steps of that suite's plugins")]
struct StepsArgs {
#[command(subcommand)]
command: Option<StepsCommand>,
}
#[derive(Subcommand)]
enum StepsCommand {
List(ListArgs),
}
#[derive(Args)]
struct ListArgs {
resource: Option<String>,
#[arg(long)]
filter: Option<String>,
#[arg(short, long)]
verbose: bool,
#[arg(long)]
json: bool,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long, conflicts_with = "config")]
no_config: bool,
#[arg(long)]
lang: Option<String>,
#[command(flatten)]
dir: DirArgs,
}
fn steps_command(args: StepsArgs) -> Result<i32> {
let Some(StepsCommand::List(args)) = args.command else {
use clap::CommandFactory;
let mut cli = Cli::command();
cli.build();
cli.find_subcommand_mut("steps")
.expect("the steps subcommand is declared")
.print_help()?;
println!();
return Ok(0);
};
list_steps(args)
}
fn list_steps(args: ListArgs) -> Result<i32> {
let overlay = steps::help::translations(&steps::help::language(args.lang.as_deref()));
let mut rows = steps::help::builtin_rows(&overlay);
if !args.no_config
&& let Some(resolved) = config::resolve_config_path(args.config.as_deref())?
{
let path = &resolved.path;
let cfg = config::load(path, None)?;
let generator = unique::Generator::new();
let env = dirs::Env::from_process(args.dir.bddkit_dir.clone());
if let Some(plugins) = load_plugins(path, &cfg, &generator, &env)? {
rows.extend(steps::help::plugin_rows(
plugins.described_steps(),
&plugins.group_names(),
&overlay,
));
}
}
if let Some(resource) = &args.resource {
if !rows.iter().any(|row| &row.group == resource) {
anyhow::bail!("no such resource: {resource:?}");
}
rows.retain(|row| &row.group == resource);
}
if let Some(filter) = &args.filter {
let searches_descriptions = args.verbose || args.json;
rows.retain(|row| steps::help::matches_filter(row, filter, searches_descriptions));
}
if args.json {
println!("{}", serde_json::to_string_pretty(&rows)?);
} else {
print!("{}", steps::help::render(&rows, args.verbose));
}
Ok(0)
}
#[tokio::main]
async fn main() {
sqlx::any::install_default_drivers();
let cli = Cli::parse();
let (result, nothing_happened) = match cli.command {
Command::Run(args) => (run(args).await, "run not started"),
Command::Steps(args) => (steps_command(args), "nothing listed"),
Command::Doctor(args) => (doctor_command(args).await, "nothing checked"),
Command::Resource(args) => (resource_command(args).await, "nothing listed"),
Command::Plugin(args) => (plugin_command(args).await, "nothing changed"),
Command::Version => {
use clap::CommandFactory;
print!("{}", Cli::command().render_long_version());
(Ok(0), "nothing printed")
}
};
match result {
Ok(code) => std::process::exit(code),
Err(error) => {
eprintln!("error: {error:#}\n\n{nothing_happened}");
std::process::exit(2);
}
}
}
fn load_plugins(
config_path: &std::path::Path,
cfg: &config::Config,
generator: &unique::Generator,
env: &dirs::Env,
) -> Result<Option<Arc<plugin::Plugins>>> {
let groups_in_config: Vec<String> = cfg.group_names().cloned().collect();
let layers = dirs::layers(dirs::Os::current(), env, config_dir(config_path))?;
let mut plugins = plugin::Plugins::load(
plugin::lock::load(&dirs::candidates(&layers, "plugins"))?,
&cfg.plugin_instances,
&groups_in_config,
cfg.concurrency,
&cfg.effective_options,
)?;
if !plugins.is_empty() {
cfg.check_group_defaults()?;
}
let mut defaults = std::collections::BTreeMap::new();
for group in &groups_in_config {
if let Some(name) = cfg.resolve_default_group(group)? {
defaults.insert(group.clone(), name);
}
}
plugins.add_defaults(defaults);
plugins.set_artifacts_root(std::env::temp_dir().join(format!("bddkit-{}", generator.run_id())));
if plugins.is_empty() {
return Ok(None);
}
let plugins = Arc::new(plugins);
std::mem::forget(plugins.clone());
Ok(Some(plugins))
}
pub(crate) fn config_dir(config_path: &std::path::Path) -> &std::path::Path {
match config_path.parent() {
Some(dir) if !dir.as_os_str().is_empty() => dir,
_ => std::path::Path::new("."),
}
}
fn build_registry(
cfg: &config::Config,
plugins: Option<&Arc<plugin::Plugins>>,
) -> std::result::Result<steps::Registry, String> {
let plugin_steps = plugins.map(|p| p.steps()).unwrap_or_default();
let plugin_groups = plugins.map(|p| p.group_names()).unwrap_or_default();
macros::MacroCatalog::load(&cfg.macro_paths).and_then(|catalog| {
steps::Registry::with_macros_and_plugins(catalog, &plugin_steps, &plugin_groups)
})
}
async fn run(cli: RunArgs) -> Result<i32> {
cli.reports
.paths()
.into_iter()
.try_for_each(report::prepare)?;
let config_path = config::resolve_config_path(cli.config.as_deref())?
.ok_or_else(|| anyhow::anyhow!("{}", config::NO_CONFIG_FOUND))?
.path;
let cfg = config::load(&config_path, cli.env.as_deref())?;
let generator = Arc::new(unique::Generator::new());
let env = dirs::Env::from_process(cli.dir.bddkit_dir.clone());
let plugins = load_plugins(&config_path, &cfg, &generator, &env)?;
let reg = match build_registry(&cfg, plugins.as_ref()) {
Ok(registry) => registry,
Err(error) => {
eprintln!("error: 1 problem, run not started\n\n {error}");
std::process::exit(2);
}
};
let filter = feature::TagFilter::new(&cli.tags);
let paths = if cli.paths.is_empty() {
cfg.paths.as_slice()
} else {
cli.paths.as_slice()
};
let mut loaded = Vec::new();
for path in feature::discover(paths)? {
let lf = feature::load(&path)?;
if lf.has_selected_scenario(&filter) {
loaded.push(Arc::new(lf));
}
}
if loaded.is_empty() {
eprintln!("error: no scenario selected, run not started");
std::process::exit(2);
}
let for_check: Vec<&feature::LoadedFeature> = loaded.iter().map(Arc::as_ref).collect();
let problems = validate::check(&for_check, ®, &filter);
if !problems.is_empty() {
eprintln!("error: {} problem(s), run not started\n", problems.len());
for p in &problems {
eprintln!("{p}");
}
std::process::exit(2);
}
let chains = runner::build_chains(loaded).map_err(anyhow::Error::msg)?;
let mut by_name = std::collections::HashMap::new();
for (name, api) in &cfg.resources.api {
let headers = api
.default_headers
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
by_name.insert(
name.clone(),
http::ApiResource::new(
&api.base_url,
api.timeout_secs,
headers,
api.effective_options.clone(),
)?,
);
}
let apis = Arc::new(http::Apis::new(by_name, cfg.resolve_default_api()?)?);
for (name, srp) in &cfg.resources.srp {
srp.to_params()
.with_context(|| format!("resources.srp.{name}"))?;
}
let db = if cfg.resources.db.is_empty() {
None
} else {
Some(Arc::new(
db::Db::connect(&cfg.resources.db, cfg.concurrency as u32)
.await
.map_err(anyhow::Error::msg)?,
))
};
let default_db = cfg.resolve_default_db()?.unwrap_or_default();
let srp = match cfg.resolve_default_srp()? {
Some(name) => Some(Arc::new(cfg.resources.srp[&name].to_params()?)),
None => None,
};
println!("run {}", generator.run_id());
let ctx = Arc::new(runner::RunContext::new(
reg,
apis,
generator.clone(),
filter,
db,
default_db,
srp,
plugins.clone(),
cfg.effective_options.clone(),
cli.fail_fast,
));
let results = runner::run_all(chains, ctx, cfg.concurrency).await;
if let Some(plugins) = &plugins {
plugins.shutdown();
}
let code = report::print_summary(&results, generator.run_id());
if let Err(error) = cli.reports.write(&results) {
eprintln!("error: {error:#}");
return Ok(2);
}
Ok(code)
}