use clap::{arg, builder::PossibleValuesParser, command, value_parser, ArgMatches};
use miette::IntoDiagnostic;
use schematic::{Config, ConfigEnum, ConfigLoader};
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, env, path::PathBuf};
const CONFIGS: &[&str] = &[
"toolproof.json",
"toolproof.yml",
"toolproof.yaml",
"toolproof.toml",
];
pub fn configure() -> ToolproofContext {
let cli_matches = get_cli_matches();
let configs: Vec<&str> = CONFIGS
.iter()
.filter(|c| std::path::Path::new(c).exists())
.cloned()
.collect();
if configs.len() > 1 {
eprintln!(
"Found multiple possible config files: [{}]",
configs.join(", ")
);
eprintln!("Toolproof only supports loading one configuration file format, please ensure only one file exists.");
std::process::exit(1);
}
let mut loader = ConfigLoader::<ToolproofParams>::new();
for config in configs {
if let Err(e) = loader.file(config).into_diagnostic() {
eprintln!("Failed to load {config}:\n{e:?}");
std::process::exit(1);
}
}
match loader.load().into_diagnostic() {
Err(e) => {
eprintln!("Failed to initialize configuration: {e:?}");
std::process::exit(1);
}
Ok(mut result) => {
result.config.override_from_cli(cli_matches);
match ToolproofContext::load(result.config) {
Ok(ctx) => ctx,
Err(_e) => {
eprintln!("Failed to initialize configuration");
std::process::exit(1);
}
}
}
}
}
fn get_cli_matches() -> ArgMatches {
command!()
.arg(
arg!(
-r --root <DIR> "The location from which to look for toolproof test files"
)
.required(false)
.value_parser(value_parser!(PathBuf)),
)
.arg(
arg!(
-c --concurrency <NUM> "How many tests should be run concurrently"
)
.required(false)
.value_parser(value_parser!(usize)),
)
.arg(
arg!(--placeholders <PAIRS> "Define placeholders for tests")
.long_help("e.g. --placeholders key=value second_key=second_value")
.required(false)
.num_args(0..),
)
.arg(
arg!(--"placeholder-delimiter" <DELIM> "Define which character delimits placeholders for test steps")
.required(false)
)
.arg(
arg!(
-v --verbose ... "Print verbose logging while running tests"
)
.action(clap::ArgAction::SetTrue),
)
.arg(
arg!(
--porcelain ... "Reduce logging to be stable"
)
.action(clap::ArgAction::SetTrue),
)
.arg(
arg!(
-i --interactive ... "Run toolproof in interactive mode"
)
.action(clap::ArgAction::SetTrue),
)
.arg(
arg!(
-a --all ... "Run all tests when in interactive mode"
)
.action(clap::ArgAction::SetTrue),
)
.arg(
arg!(
-s --skiphooks ... "Skip running any hooks (e.g. before_all)"
)
.action(clap::ArgAction::SetTrue),
)
.arg(
arg!(
--timeout <NUM> "How long in seconds until a step times out"
)
.required(false)
.value_parser(value_parser!(u64)),
)
.arg(
arg!(
--"browser-timeout" <NUM> "How long in seconds until actions in a browser time out"
)
.required(false)
.value_parser(value_parser!(u64)),
)
.arg(
arg!(
-n --name <NAME> "Exact name of a test to run")
.long_help("case-sensitive")
.required(false)
)
.arg(
arg!(
-p --path <PATH> "Path to a specific test file or directory to run")
.long_help("Run tests from a specific file or all tests in a directory")
.required(false)
.value_parser(value_parser!(PathBuf)),
)
.arg(
arg!(
--browser <IMPL> ... "Specify which browser to use when running browser automation tests"
)
.required(false)
.value_parser(PossibleValuesParser::new(["chrome", "pagebrowse"])),
)
.arg(
arg!(
--"retry-count" <COUNT> "Number of times to retry failed tests before marking them as failed (for handling flaky tests)"
)
.required(false)
.value_parser(value_parser!(usize)),
)
.arg(
arg!(
--"failure-screenshot-location" <DIR> "If set, Toolproof will screenshot the browser to this location when a test fails (if applicable)"
)
.required(false)
.value_parser(value_parser!(PathBuf)),
)
.arg(
arg!(
--debugger ... "Run in debugger mode with step-by-step execution"
)
.action(clap::ArgAction::SetTrue),
)
.get_matches()
}
#[derive(ConfigEnum, Default, Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolproofBrowserImpl {
#[default]
Chrome,
Pagebrowse,
}
#[derive(Config, Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
#[config(rename_all = "snake_case")]
pub struct ToolproofBeforeAll {
pub command: String,
}
#[derive(Config, Debug, Clone)]
#[config(rename_all = "snake_case")]
pub struct ToolproofParams {
#[setting(env = "TOOLPROOF_ROOT")]
pub root: Option<PathBuf>,
#[setting(env = "TOOLPROOF_VERBOSE")]
pub verbose: bool,
#[setting(env = "TOOLPROOF_PORCELAIN")]
pub porcelain: bool,
pub interactive: bool,
pub all: bool,
#[setting(env = "TOOLPROOF_RUN_NAME")]
pub run_name: Option<String>,
#[setting(env = "TOOLPROOF_RUN_PATH")]
pub run_path: Option<PathBuf>,
#[setting(env = "TOOLPROOF_BROWSER")]
pub browser: ToolproofBrowserImpl,
#[setting(env = "TOOLPROOF_CONCURRENCY")]
#[setting(default = 10)]
pub concurrency: usize,
#[setting(env = "TOOLPROOF_TIMEOUT")]
#[setting(default = 10)]
pub timeout: u64,
#[setting(env = "TOOLPROOF_BROWSER_TIMEOUT")]
#[setting(default = 8)]
pub browser_timeout: u64,
#[setting(env = "TOOLPROOF_PLACEHOLDER_DELIM")]
#[setting(default = "%")]
pub placeholder_delimiter: String,
pub placeholders: HashMap<String, String>,
pub before_all: Vec<ToolproofBeforeAll>,
#[setting(env = "TOOLPROOF_SKIPHOOKS")]
pub skip_hooks: bool,
#[setting(env = "TOOLPROOF_SUPPORTED_VERSIONS")]
pub supported_versions: Option<String>,
#[setting(env = "TOOLPROOF_FAILURE_SCREENSHOT_LOCATION")]
pub failure_screenshot_location: Option<PathBuf>,
#[setting(env = "TOOLPROOF_RETRY_COUNT")]
#[setting(default = 0)]
pub retry_count: usize,
#[setting(env = "TOOLPROOF_DEBUGGER")]
pub debugger: bool,
}
#[derive(Debug, Clone)]
pub struct ToolproofContext {
pub version: &'static str,
pub working_directory: PathBuf,
pub params: ToolproofParams,
}
impl ToolproofContext {
fn load(mut config: ToolproofParams) -> Result<Self, ()> {
let working_directory = env::current_dir().unwrap();
if let Some(root) = config.root.as_mut() {
*root = working_directory.join(root.clone());
}
Ok(Self {
working_directory,
version: env!("CARGO_PKG_VERSION"),
params: config,
})
}
}
impl ToolproofParams {
fn override_from_cli(&mut self, cli_matches: ArgMatches) {
if cli_matches.get_flag("verbose") {
self.verbose = true;
}
if cli_matches.get_flag("porcelain") {
self.porcelain = true;
}
if cli_matches.get_flag("interactive") {
self.interactive = true;
}
if cli_matches.get_flag("all") {
self.all = true;
}
if cli_matches.get_flag("skiphooks") {
self.skip_hooks = true;
}
if let Some(name) = cli_matches.get_one::<String>("name") {
self.run_name = Some(name.clone());
}
if let Some(path) = cli_matches.get_one::<PathBuf>("path") {
self.run_path = Some(path.clone());
}
if let Some(root) = cli_matches.get_one::<PathBuf>("root") {
self.root = Some(root.clone());
}
if let Some(concurrency) = cli_matches.get_one::<usize>("concurrency") {
self.concurrency = *concurrency;
}
if let Some(timeout) = cli_matches.get_one::<u64>("timeout") {
self.timeout = *timeout;
}
if let Some(browser_timeout) = cli_matches.get_one::<u64>("browser-timeout") {
self.browser_timeout = *browser_timeout;
}
if let Some(placeholder_delimiter) = cli_matches.get_one::<String>("placeholder-delimiter")
{
self.placeholder_delimiter = placeholder_delimiter.clone();
}
if let Some(placeholders) = cli_matches.get_many::<String>("placeholders") {
for placeholder in placeholders {
let Some((key, value)) = placeholder.split_once('=') else {
eprintln!("Error parsing --placeholders, expected a value of key=value but received {placeholder}");
std::process::exit(1);
};
self.placeholders.insert(key.into(), value.into());
}
}
if let Some(failure_screenshot_location) =
cli_matches.get_one::<PathBuf>("failure-screenshot-location")
{
self.failure_screenshot_location = Some(failure_screenshot_location.clone());
}
if let Some(retry_count) = cli_matches.get_one::<usize>("retry-count") {
self.retry_count = *retry_count;
}
if cli_matches.get_flag("debugger") {
self.debugger = true;
}
}
}