use anyhow::{Context, Result};
use clap::{Parser, Subcommand, ValueEnum};
use momus_core::ast::*;
use momus_core::config::{AuthConfig, MomusConfig};
use momus_core::engine::runner;
use std::path::PathBuf;
#[derive(ValueEnum, Debug, Clone)]
enum OutputFormat {
Auto,
Html,
Junit,
Json,
Text,
}
#[derive(ValueEnum, Debug, Clone)]
enum BenchModeCli {
Steady,
MaxThroughput,
Soak,
}
#[derive(Parser)]
#[command(
name = "momus",
about = "Generic API test harness with a composable assertion AST",
version,
long_about = "Momus is a domain-agnostic test runner for HTTP APIs.\n\
Tests are defined as a JSON plan — a tree of steps (requests,\n\
sequences, parallel blocks) with composable assertions on responses.\n\n\
Configuration is loaded from:\n\
1. --config <path> (explicit)\n\
2. $MOMUS_CONFIG (env var)\n\
3. ./momus.toml\n\
4. ./.momus.toml\n\
5. ~/.config/momus/config.toml\n\n\
CLI flags override config file values."
)]
struct Cli {
#[arg(long, global = true, env = "MOMUS_CONFIG")]
config: Option<String>,
#[arg(short, long, global = true)]
verbose: bool,
#[arg(long, global = true)]
timeout: Option<u64>,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Run {
plan: String,
#[arg(long)]
base_url: Option<String>,
#[arg(long, default_value = "./output/run")]
output: PathBuf,
#[arg(long, value_enum, default_value = "auto")]
format: OutputFormat,
#[arg(long)]
dry_run: bool,
},
Validate {
plan: String,
},
Mock {
#[arg(long, default_value = "0")]
port: u16,
},
Bench {
plan: PathBuf,
#[arg(long, value_enum)]
mode: Option<BenchModeCli>,
#[arg(long)]
concurrency: Option<usize>,
#[arg(long)]
duration: Option<u64>,
#[arg(long)]
min_concurrency: Option<usize>,
#[arg(long)]
max_concurrency: Option<usize>,
#[arg(long)]
step: Option<usize>,
#[arg(long)]
step_duration: Option<u64>,
#[arg(long)]
max_error_rate: Option<f64>,
#[arg(long)]
max_p99_ms: Option<u64>,
#[arg(long)]
warmup: Option<usize>,
#[arg(long)]
timeout: Option<u64>,
#[arg(long)]
base_url: Option<String>,
#[arg(long)]
output: Option<PathBuf>,
#[arg(long, value_enum, default_value = "auto")]
format: OutputFormat,
},
Fuzz {
plan: PathBuf,
#[arg(long)]
iterations: Option<usize>,
#[arg(long)]
base_url: Option<String>,
#[arg(long)]
mutators: Vec<String>,
#[arg(long)]
timeout: Option<u64>,
},
Chaos {
plan: PathBuf,
#[arg(long)]
base_url: Option<String>,
#[arg(long)]
experiment: Vec<String>,
#[arg(long)]
interval: Option<u64>,
},
Convert {
#[arg(value_parser = clap::builder::PossibleValuesParser::new([
"openapi", "postman", "har", "curl", "graphql", "grpc", "fhir",
]))]
format: String,
input: String,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
seed_data: bool,
},
Contract {
plan: PathBuf,
#[arg(long)]
spec: String,
#[arg(long)]
base_url: Option<String>,
#[arg(long)]
strict: Option<bool>,
#[arg(long)]
timeout: Option<u64>,
},
Guard {
plan: PathBuf,
#[arg(long)]
base_url: Option<String>,
#[arg(long)]
check_headers: Option<bool>,
#[arg(long)]
check_cors: Option<bool>,
#[arg(long)]
check_leaks: Option<bool>,
#[arg(long)]
check_exposed: Option<bool>,
#[arg(long)]
timeout: Option<u64>,
},
Diff {
plan: PathBuf,
#[arg(long)]
baseline: String,
#[arg(long)]
target: String,
#[arg(long)]
diff_headers: Option<bool>,
#[arg(long)]
diff_bodies: Option<bool>,
#[arg(long)]
diff_status: Option<bool>,
#[arg(long)]
timeout: Option<u64>,
},
Init {
#[arg(default_value = "plan")]
template: String,
#[arg(short, long)]
output: Option<PathBuf>,
},
Plan {
plan: String,
#[arg(long, default_value = "./output/plan")]
output: PathBuf,
},
Completions {
#[arg(value_enum)]
shell: clap_complete::Shell,
},
Docs,
#[command(subcommand)]
Fhir(FhirCommands),
}
#[derive(Subcommand)]
enum FhirCommands {
Mock {
#[arg(long, default_value = "0")]
port: u16,
},
Validate {
package: String,
#[arg(short, long)]
resource: String,
#[arg(long)]
profile: Option<String>,
},
Generate {
package: String,
#[arg(long, default_value = "10")]
count: u64,
#[arg(long, default_value = "./output/fhir")]
output: PathBuf,
},
#[command(name = "openapi")]
OpenApi {
package: Option<String>,
#[arg(long)]
base_url: Option<String>,
#[arg(long, default_value = "yaml")]
format: String,
#[arg(short, long)]
output: Option<PathBuf>,
},
Upload {
#[arg(long, default_value = "./output/fhir/data")]
data_dir: PathBuf,
#[arg(long)]
endpoint: String,
#[arg(long, default_value = "PUT")]
method: String,
#[arg(long)]
username: Option<String>,
#[arg(long)]
password: Option<String>,
#[arg(long, default_value = "8")]
concurrency: usize,
},
Delete {
#[arg(long, default_value = "./output/fhir/data")]
data_dir: PathBuf,
#[arg(long)]
endpoint: String,
#[arg(long)]
username: Option<String>,
#[arg(long)]
password: Option<String>,
#[arg(long, default_value = "8")]
concurrency: usize,
},
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
if cli.verbose && std::env::var("RUST_LOG").is_err() {
unsafe {
std::env::set_var(
"RUST_LOG",
"momus=debug,momus_convert=debug,momus_core=debug,momus_mock=debug",
);
}
}
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive(tracing::Level::INFO.into()),
)
.init();
let mut cfg = load_config(cli.config.as_deref())?;
if let Some(timeout) = cli.timeout {
cfg.global.timeout_secs = timeout;
}
match cli.command {
Commands::Run {
plan,
base_url,
output,
format,
dry_run,
} => {
let content = read_plan_content(&plan)?;
let mut test_plan: TestPlan = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse test plan '{plan}'"))?;
if let Some(url) = base_url.or(cfg.run.base_url).or(cfg.global.base_url) {
test_plan.base_url = url;
}
if dry_run {
return dry_run_plan(&test_plan);
}
let output = if output.to_str() != Some("./output/run") {
output
} else if cfg.run.output != *"./output/run" {
cfg.run.output
} else if let Some(global_output) = &cfg.global.output {
global_output.clone()
} else {
PathBuf::from("./output/run")
};
if !cfg.global.headers.is_empty() {
for (k, v) in &cfg.global.headers {
test_plan
.default_headers
.entry(k.clone())
.or_insert_with(|| v.clone());
}
}
if !cfg.run.headers.is_empty() {
for (k, v) in &cfg.run.headers {
test_plan
.default_headers
.entry(k.clone())
.or_insert_with(|| v.clone());
}
}
let auth = if cfg.run.auth.is_empty() {
&cfg.global.auth
} else {
&cfg.run.auth
};
apply_auth_to_plan(&mut test_plan, auth)?;
let timeout_secs = if cfg.run.timeout_secs != 30 {
cfg.run.timeout_secs
} else if cfg.global.timeout_secs != 30 {
cfg.global.timeout_secs
} else {
30
};
tracing::info!(
"Running test plan '{}' with {} test(s) against {}",
test_plan.name,
test_plan.total_tests(),
test_plan.base_url
);
let plan_dir = if plan == "-" {
std::path::PathBuf::from(".")
} else {
std::path::Path::new(&plan)
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| std::path::PathBuf::from("."))
};
let report =
runner::execute_plan_with_timeout_and_dir(&test_plan, timeout_secs, &plan_dir)
.await?;
let format = if matches!(format, OutputFormat::Auto) {
match cfg.global.output_format.as_deref() {
Some("html") => OutputFormat::Html,
Some("junit") => OutputFormat::Junit,
Some("text") => OutputFormat::Text,
_ => format,
}
} else {
format
};
let want_junit = match format {
OutputFormat::Junit => true,
OutputFormat::Html => false,
OutputFormat::Json => false,
OutputFormat::Text => false,
OutputFormat::Auto => output
.extension()
.and_then(|e| e.to_str())
.map(|e| e == "xml")
.unwrap_or(false),
};
let want_html = if want_junit {
false
} else {
match format {
OutputFormat::Html => true,
OutputFormat::Json => false,
OutputFormat::Text => false,
OutputFormat::Auto => output
.extension()
.and_then(|e| e.to_str())
.map(|e| e == "html")
.unwrap_or(false),
OutputFormat::Junit => false,
}
};
if want_junit {
std::fs::create_dir_all(&output)?;
report.write_junit_xml(&output)?;
let junit_path = output.join("junit.xml");
println!("JUnit XML report written to: {}", junit_path.display());
} else if want_html {
let html = report.to_html();
if output.extension().and_then(|e| e.to_str()) == Some("html") {
std::fs::create_dir_all(output.parent().unwrap_or(&output))?;
std::fs::write(&output, &html)?;
println!("HTML report written to: {}", output.display());
} else {
std::fs::create_dir_all(&output)?;
let html_path = output.join("report.html");
std::fs::write(&html_path, &html)?;
println!("HTML report written to: {}", html_path.display());
}
} else {
println!("{report}");
}
let (results_base, results_print) =
if output.extension().and_then(|e| e.to_str()).is_some() {
let parent = output.parent().unwrap_or(std::path::Path::new("."));
(parent.to_path_buf(), parent.to_path_buf())
} else {
(output.clone(), output.clone())
};
let results_dir = results_base.join("results");
std::fs::create_dir_all(&results_dir)?;
report.write_results(&results_base)?;
println!("\nResults written to: {}/results/", results_print.display());
if report.failed > 0 {
std::process::exit(1);
}
Ok(())
}
Commands::Validate { plan } => {
let content = read_plan_content(&plan)?;
let test_plan: TestPlan = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse test plan '{plan}'"))?;
println!("✓ Valid test plan: '{}'", test_plan.name);
println!(" Total tests: {}", test_plan.total_tests());
println!(" Steps: {}", test_plan.steps.len());
if !test_plan.setup.is_empty() {
println!(" Setup steps: {}", test_plan.setup.len());
}
if !test_plan.teardown.is_empty() {
println!(" Teardown steps: {}", test_plan.teardown.len());
}
Ok(())
}
Commands::Mock { port } => {
let addr = if port > 0 {
format!("0.0.0.0:{port}")
} else {
"0.0.0.0:0".into()
};
let listener = tokio::net::TcpListener::bind(&addr).await?;
let local_addr = listener.local_addr()?;
println!("Momus mock server listening on http://{local_addr}");
println!("All requests return 200 with 'status: ok'");
println!("Press Ctrl+C to stop.");
use axum::{Json, Router, routing::any};
let app = Router::new().route(
"/{*path}",
any(|| async {
(
axum::http::StatusCode::OK,
Json(serde_json::json!({"status": "ok"})),
)
}),
);
axum::serve(listener, app).await?;
Ok(())
}
Commands::Convert {
format,
input,
output,
seed_data,
} => {
let plan = momus_convert::convert(&format, &input, seed_data)?;
let json = serde_json::to_string_pretty(&plan)?;
let path = match output {
Some(p) => p,
None => {
if format == "curl" {
println!("{json}");
return Ok(());
}
let input_path = std::path::Path::new(&input);
let stem = input_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("test_plan");
let parent = std::path::Path::new(".");
parent.join(format!("{stem}.momus.json"))
}
};
std::fs::write(&path, json)?;
println!("Test plan written to: {}", path.display());
Ok(())
}
Commands::Bench {
plan,
mode,
concurrency,
duration,
min_concurrency,
max_concurrency,
step,
step_duration,
max_error_rate,
max_p99_ms,
warmup,
timeout,
base_url,
output,
format,
} => {
let content = std::fs::read_to_string(&plan)
.with_context(|| format!("Failed to read plan file '{}'", plan.display()))?;
let mut test_plan: TestPlan = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse test plan '{}'", plan.display()))?;
apply_auth_to_plan(&mut test_plan, &cfg.global.auth)?;
let base_url = base_url.or(cfg.bench.base_url).or(cfg.global.base_url);
let warmup_requests = warmup.unwrap_or(cfg.bench.warmup_requests);
let timeout_secs = timeout
.or(Some(cfg.bench.timeout_secs))
.or(Some(cfg.global.timeout_secs))
.unwrap_or(30);
let bench_mode = match mode.unwrap_or({
match &cfg.bench.mode {
momus_bench::BenchMode::Steady { .. } => BenchModeCli::Steady,
momus_bench::BenchMode::MaxThroughput { .. } => BenchModeCli::MaxThroughput,
momus_bench::BenchMode::Soak { .. } => BenchModeCli::Soak,
}
}) {
BenchModeCli::Steady => {
let concurrency = concurrency
.or(match &cfg.bench.mode {
momus_bench::BenchMode::Steady { concurrency, .. } => {
Some(*concurrency)
}
_ => None,
})
.unwrap_or(10);
let duration_secs = duration
.or(match &cfg.bench.mode {
momus_bench::BenchMode::Steady { duration_secs, .. } => {
Some(*duration_secs)
}
_ => None,
})
.unwrap_or(30);
momus_bench::BenchMode::Steady {
concurrency,
duration_secs,
}
}
BenchModeCli::MaxThroughput => momus_bench::BenchMode::MaxThroughput {
min_concurrency: min_concurrency.unwrap_or(10),
max_concurrency: max_concurrency.unwrap_or(200),
step: step.unwrap_or(10),
step_duration_secs: step_duration.unwrap_or(10),
max_error_rate: max_error_rate.unwrap_or(0.05),
max_p99_ms: max_p99_ms.unwrap_or(2000),
},
BenchModeCli::Soak => {
let concurrency = concurrency
.or(match &cfg.bench.mode {
momus_bench::BenchMode::Soak { concurrency, .. } => Some(*concurrency),
_ => None,
})
.unwrap_or(10);
let duration_secs = duration
.or(match &cfg.bench.mode {
momus_bench::BenchMode::Soak { duration_secs, .. } => {
Some(*duration_secs)
}
_ => None,
})
.unwrap_or(30);
momus_bench::BenchMode::Soak {
concurrency,
duration_secs,
}
}
};
let config = momus_bench::BenchConfig {
mode: bench_mode,
base_url,
warmup_requests,
timeout_secs,
output: output.clone().unwrap_or(cfg.bench.output),
};
let cancel = tokio_util::sync::CancellationToken::new();
let cancel_handle = cancel.clone();
let bench_fut = momus_bench::run_bench_with_cancel(&test_plan, &config, &cancel_handle);
tokio::pin!(bench_fut);
let report = tokio::select! {
r = &mut bench_fut => r?,
_ = tokio::signal::ctrl_c() => {
cancel.cancel();
tracing::info!("Ctrl+C received — stopping benchmark and reporting partial results");
bench_fut.await?
}
};
let want_html = match format {
OutputFormat::Html => true,
OutputFormat::Junit => false,
OutputFormat::Json => false,
OutputFormat::Text => false,
OutputFormat::Auto => output
.as_ref()
.and_then(|p| p.extension())
.and_then(|e| e.to_str())
.map(|e| e == "html")
.unwrap_or(false),
};
let want_json = match format {
OutputFormat::Json => true,
OutputFormat::Html => false,
OutputFormat::Junit => false,
OutputFormat::Text => false,
OutputFormat::Auto => output
.as_ref()
.and_then(|p| p.extension())
.and_then(|e| e.to_str())
.map(|e| e == "json")
.unwrap_or(false),
};
if want_json {
let json = report.to_json()?;
if let Some(path) = &output {
std::fs::create_dir_all(path.parent().unwrap_or(path))?;
std::fs::write(path, &json)?;
println!("JSON report written to: {}", path.display());
} else {
println!("{json}");
}
} else if want_html {
let html = report.to_html();
if let Some(path) = &output {
std::fs::create_dir_all(path.parent().unwrap_or(path))?;
std::fs::write(path, &html)?;
println!("HTML report written to: {}", path.display());
} else {
println!("{html}");
}
} else {
println!("{report}");
}
Ok(())
}
Commands::Fuzz {
plan,
iterations,
base_url,
mutators: _mutators,
timeout: _timeout,
} => {
let content = std::fs::read_to_string(&plan)
.with_context(|| format!("Failed to read plan file '{}'", plan.display()))?;
let mut test_plan: TestPlan = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse test plan '{}'", plan.display()))?;
apply_auth_to_plan(&mut test_plan, &cfg.global.auth)?;
let base_url = base_url.or(cfg.fuzz.base_url).or(cfg.global.base_url);
let fuzz_output = cfg.fuzz.output.clone();
let config = momus_fuzz::FuzzConfig {
iterations: iterations.unwrap_or(cfg.fuzz.iterations),
base_url,
output: fuzz_output.clone(),
..Default::default()
};
let report = momus_fuzz::run_fuzz(&test_plan, &config).await?;
println!("{report}");
std::fs::create_dir_all(&fuzz_output)?;
let report_json = serde_json::to_string_pretty(&report)?;
std::fs::write(fuzz_output.join("fuzz-report.json"), &report_json)?;
println!(
"\nReport written to: {}/fuzz-report.json",
fuzz_output.display()
);
Ok(())
}
Commands::Chaos {
plan,
base_url,
experiment,
interval,
} => {
let content = std::fs::read_to_string(&plan)
.with_context(|| format!("Failed to read plan file '{}'", plan.display()))?;
let mut test_plan: TestPlan = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse test plan '{}'", plan.display()))?;
apply_auth_to_plan(&mut test_plan, &cfg.global.auth)?;
let base_url = base_url.or(cfg.chaos.base_url).or(cfg.global.base_url);
let interval_secs = interval.unwrap_or(cfg.chaos.interval_secs);
let experiments = if experiment.is_empty() {
cfg.chaos.experiments.clone()
} else {
experiment
.iter()
.map(|s| {
serde_json::from_str::<momus_chaos::ChaosExperiment>(s)
.with_context(|| format!("Failed to parse chaos experiment JSON: {s}"))
})
.collect::<Result<Vec<_>>>()?
};
let chaos_output = cfg.chaos.output.clone();
let config = momus_chaos::ChaosConfig {
experiments,
base_url,
interval_secs,
timeout_secs: cfg.chaos.timeout_secs,
output: chaos_output.clone(),
};
let reports = momus_chaos::run_chaos(&test_plan, &config).await?;
for report in &reports {
println!("{report}");
}
std::fs::create_dir_all(&chaos_output)?;
let report_json = serde_json::to_string_pretty(&reports)?;
std::fs::write(chaos_output.join("chaos-report.json"), &report_json)?;
println!(
"\nReport written to: {}/chaos-report.json",
chaos_output.display()
);
Ok(())
}
Commands::Contract {
plan,
spec,
base_url,
strict,
timeout,
} => {
let content = std::fs::read_to_string(&plan)
.with_context(|| format!("Failed to read plan file '{}'", plan.display()))?;
let mut test_plan: TestPlan = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse test plan '{}'", plan.display()))?;
apply_auth_to_plan(&mut test_plan, &cfg.global.auth)?;
let base_url = base_url.or(cfg.contract.base_url).or(cfg.global.base_url);
let strict = strict.unwrap_or(cfg.contract.strict);
let timeout_secs = timeout
.or(Some(cfg.contract.timeout_secs))
.or(Some(cfg.global.timeout_secs))
.unwrap_or(30);
let contract_output = cfg.contract.output.clone();
let config = momus_contract::ContractConfig {
spec_path: spec,
base_url,
strict,
timeout_secs,
output: contract_output.clone(),
};
let report = momus_contract::run_contract(&test_plan, &config).await?;
println!("{report}");
std::fs::create_dir_all(&contract_output)?;
let report_json = serde_json::to_string_pretty(&report)?;
std::fs::write(contract_output.join("contract-report.json"), &report_json)?;
println!(
"\nReport written to: {}/contract-report.json",
contract_output.display()
);
Ok(())
}
Commands::Guard {
plan,
base_url,
check_headers,
check_cors,
check_leaks,
check_exposed,
timeout,
} => {
let content = std::fs::read_to_string(&plan)
.with_context(|| format!("Failed to read plan file '{}'", plan.display()))?;
let mut test_plan: TestPlan = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse test plan '{}'", plan.display()))?;
apply_auth_to_plan(&mut test_plan, &cfg.global.auth)?;
let base_url = base_url.or(cfg.guard.base_url).or(cfg.global.base_url);
let timeout_secs = timeout
.or(Some(cfg.guard.timeout_secs))
.or(Some(cfg.global.timeout_secs))
.unwrap_or(30);
let guard_output = cfg.guard.output.clone();
let config = momus_guard::GuardConfig {
base_url,
check_headers: check_headers.unwrap_or(cfg.guard.check_headers),
check_cors: check_cors.unwrap_or(cfg.guard.check_cors),
check_leaks: check_leaks.unwrap_or(cfg.guard.check_leaks),
check_exposed: check_exposed.unwrap_or(cfg.guard.check_exposed),
timeout_secs,
output: guard_output.clone(),
};
let report = momus_guard::run_guard(&test_plan, &config).await?;
println!("{report}");
std::fs::create_dir_all(&guard_output)?;
let report_json = serde_json::to_string_pretty(&report)?;
std::fs::write(guard_output.join("guard-report.json"), &report_json)?;
println!(
"\nReport written to: {}/guard-report.json",
guard_output.display()
);
Ok(())
}
Commands::Diff {
plan,
baseline,
target,
diff_headers,
diff_bodies,
diff_status,
timeout,
} => {
let content = std::fs::read_to_string(&plan)
.with_context(|| format!("Failed to read plan file '{}'", plan.display()))?;
let mut test_plan: TestPlan = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse test plan '{}'", plan.display()))?;
apply_auth_to_plan(&mut test_plan, &cfg.global.auth)?;
let timeout_secs = timeout
.or(Some(cfg.diff.timeout_secs))
.or(Some(cfg.global.timeout_secs))
.unwrap_or(30);
let diff_output = cfg.diff.output.clone();
let config = momus_diff::DiffConfig {
baseline_url: baseline,
target_url: target,
diff_headers: diff_headers.unwrap_or(cfg.diff.diff_headers),
diff_bodies: diff_bodies.unwrap_or(cfg.diff.diff_bodies),
diff_status: diff_status.unwrap_or(cfg.diff.diff_status),
timeout_secs,
output: diff_output.clone(),
};
let report = momus_diff::run_diff(&test_plan, &config).await?;
println!("{report}");
std::fs::create_dir_all(&diff_output)?;
let report_json = serde_json::to_string_pretty(&report)?;
std::fs::write(diff_output.join("diff-report.json"), &report_json)?;
println!(
"\nReport written to: {}/diff-report.json",
diff_output.display()
);
Ok(())
}
Commands::Plan { plan, output } => {
let content = read_plan_content(&plan)?;
let test_plan: TestPlan = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse test plan '{plan}'"))?;
let output = if output.to_str() != Some("./output/plan") {
output
} else {
cfg.plan.output
};
let display = test_plan.display_plan();
let output_path = output.join("plan.txt");
std::fs::create_dir_all(&output)?;
std::fs::write(&output_path, &display)?;
println!("Plan written to: {}", output_path.display());
Ok(())
}
Commands::Completions { shell } => {
use clap::CommandFactory;
let mut cmd = Cli::command();
let name = cmd.get_name().to_string();
clap_complete::generate(shell, &mut cmd, name, &mut std::io::stdout());
Ok(())
}
Commands::Docs => {
let url = "https://docs.rs/momus";
println!("Documentation: {url}");
if std::env::var("MOMUS_OPEN_DOCS").as_deref() == Ok("1")
&& let Err(e) = open::that(url)
{
eprintln!("Could not open browser ({e}); docs are available at {url}");
}
Ok(())
}
Commands::Init { template, output } => {
match template.as_str() {
"plan" => {
let path = output.unwrap_or_else(|| PathBuf::from("test-plan.json"));
let skeleton = serde_json::json!({
"name": "my test plan",
"base_url": "http://localhost:8080",
"default_headers": {
"Accept": "application/json"
},
"steps": [
{
"type": "request",
"name": "health",
"method": "GET",
"url": "/health",
"assert": [
{ "status": 200 },
{ "valid_json": null }
]
}
]
});
let json = serde_json::to_string_pretty(&skeleton)?;
std::fs::write(&path, json)?;
println!("✓ Skeleton test plan written to: {}", path.display());
}
"config" => {
let path = output.unwrap_or_else(|| PathBuf::from("momus.toml"));
let content = r#"# Momus Configuration
# CLI flags override these values.
[global]
# Base URL for all requests (overrides the plan's base_url).
# base_url = "http://localhost:8080"
# Default headers sent with every request.
# [global.headers]
# Authorization = "Bearer your-token-here"
# Authentication applied as an `Authorization` header on every request.
# Set at most one of `bearer` or `basic`. Values support $ENV_VAR interpolation.
# All commands fall back to this unless they provide their own auth
# (e.g. `[run.auth]` or `momus fhir upload --username`).
# [global.auth]
# bearer = "$API_TOKEN"
# [global.auth.basic]
# username = "admin"
# password = "$API_PASSWORD"
# Request timeout in seconds (default: 30).
# timeout_secs = 60
[run]
# Output directory for results (default: ./output).
# output = "./results"
# Per-command auth overrides [global.auth].
# [run.auth]
# bearer = "$API_TOKEN"
[bench]
# Output directory for results (default: ./output).
# output = "./bench-results"
# Execution mode: Steady, MaxThroughput, or Soak.
# [bench.mode]
# type = "Steady"
# concurrency = 20
# duration_secs = 60
[fuzz]
# Output directory for results (default: ./output).
# output = "./fuzz-results"
# Number of mutations to generate per input (default: 1000).
# iterations = 5000
# Select specific mutators (empty = all).
# mutators = ["null_injection", "boundary"]
# Request timeout in seconds (default: 30).
# timeout_secs = 60
[chaos]
# Output directory for results (default: ./output).
# output = "./chaos-results"
# How long to wait between experiments in seconds (default: 5).
# interval_secs = 10
[contract]
# Output directory for results (default: ./output).
# output = "./contract-results"
# Path to the API spec file (OpenAPI YAML/JSON or GraphQL SDL).
# spec_path = "./api-spec.yaml"
# strict = true
# Request timeout in seconds (default: 30).
# timeout_secs = 60
[guard]
# Output directory for results (default: ./output).
# output = "./guard-results"
# Check for missing security headers (default: true).
# check_headers = true
# check_cors = true
# check_leaks = true
# check_exposed = true
# Request timeout in seconds (default: 30).
# timeout_secs = 60
[plan]
# Output directory for the plan display (default: ./output).
# output = "./plan-output"
[diff]
# Output directory for results (default: ./output).
# output = "./diff-results"
# Baseline environment URL (e.g. production).
# baseline_url = "https://prod.example.com"
# Target environment URL (e.g. staging).
# target_url = "https://staging.example.com"
# Diff response headers (default: true).
# diff_headers = true
# Diff response bodies (default: true).
# diff_bodies = true
# Diff status codes (default: true).
# diff_status = true
# Request timeout in seconds (default: 30).
# timeout_secs = 60
"#;
std::fs::write(&path, content)?;
println!("✓ Skeleton config written to: {}", path.display());
}
other => {
anyhow::bail!("Unknown template '{other}'. Available: plan, config");
}
}
Ok(())
}
Commands::Fhir(cmd) => match cmd {
FhirCommands::Mock { port } => {
let addr = momus_mock::fhir::start_fhir_mock_server(port).await?;
println!("FHIR mock server listening on http://{addr}");
println!("Endpoints:");
println!(" POST /fhir/{{type}} — create resource");
println!(" GET /fhir/{{type}} — search resources");
println!(" GET /fhir/{{type}}/{{id}} — read resource");
println!(" PUT /fhir/{{type}}/{{id}} — update resource");
println!(" DELETE /fhir/{{type}}/{{id}} — delete resource");
println!("Press Ctrl+C to stop.");
tokio::signal::ctrl_c().await?;
Ok(())
}
FhirCommands::Validate {
package,
resource,
profile,
} => {
tokio::task::block_in_place(|| {
momus_convert::fhir_validate_resource(&package, &resource, profile.as_deref())
})?;
Ok(())
}
FhirCommands::Generate {
package,
count,
output,
} => {
std::fs::create_dir_all(&output)?;
tokio::task::block_in_place(|| {
momus_convert::generate_fhir_bulk_test_data(&package, count, &output)
})?;
println!(
"✓ FHIR bulk test data written to {}/data/",
output.display()
);
Ok(())
}
FhirCommands::OpenApi {
package,
base_url,
format,
output,
} => {
let spec = match base_url {
Some(base_url) => {
momus_convert::fhir_server_to_openapi(&base_url, &format).await?
}
None => {
let package = package.as_deref().ok_or_else(|| {
anyhow::anyhow!(
"Provide a local IG package path or --base-url <FHIR server URL>"
)
})?;
momus_convert::fhir_ig_to_openapi(package, &format)?
}
};
match output {
Some(path) => {
std::fs::write(&path, &spec)?;
println!("OpenAPI spec written to: {}", path.display());
}
None => println!("{spec}"),
}
Ok(())
}
FhirCommands::Upload {
data_dir,
endpoint,
method,
username,
password,
concurrency,
} => {
let write_endpoint = write_endpoint_from_cli(
endpoint,
method,
username,
password,
concurrency,
&cfg.global.auth,
)?;
println!(
"Uploading FHIR NDJSON data from {} to {} ({})",
data_dir.display(),
write_endpoint.base_url(),
write_endpoint.upload_method(),
);
let ids = momus_convert::upload_fhir_bulk_data(&data_dir, &write_endpoint).await?;
let total: usize = ids.values().map(|v| v.len()).sum();
println!("✓ Uploaded {total} resources");
for (rtype, type_ids) in &ids {
println!(" {rtype}: {} resources", type_ids.len());
}
println!(
"Run `momus fhir delete --endpoint {}` to clean up.",
write_endpoint.base_url()
);
Ok(())
}
FhirCommands::Delete {
data_dir,
endpoint,
username,
password,
concurrency,
} => {
let write_endpoint = write_endpoint_from_cli(
endpoint,
"DELETE".to_string(),
username,
password,
concurrency,
&cfg.global.auth,
)?;
println!("Deleting FHIR resources from {}", write_endpoint.base_url());
momus_convert::delete_fhir_bulk_data(&data_dir, &write_endpoint).await?;
println!("✓ Cleanup complete");
Ok(())
}
},
}
}
fn apply_auth_to_plan(test_plan: &mut TestPlan, auth: &AuthConfig) -> anyhow::Result<()> {
if let Some(value) = auth.authorization_header()? {
test_plan
.default_headers
.entry("Authorization".to_string())
.or_insert(value);
}
Ok(())
}
fn write_endpoint_from_cli(
endpoint: String,
method: String,
username: Option<String>,
password: Option<String>,
concurrency: usize,
global_auth: &AuthConfig,
) -> anyhow::Result<momus_convert::WriteEndpoint> {
match username {
Some(user) => Ok(momus_convert::WriteEndpoint::Repository {
base_url: endpoint,
username: user,
password: password.unwrap_or_default(),
upload_method: method,
concurrency,
}),
None => {
let mut headers = std::collections::HashMap::new();
if let Some(value) = global_auth.authorization_header()? {
headers.insert("Authorization".to_string(), value);
}
Ok(momus_convert::WriteEndpoint::Server {
base_url: endpoint,
headers,
upload_method: method,
concurrency,
})
}
}
}
fn dry_run_plan(plan: &TestPlan) -> Result<()> {
use std::collections::HashMap;
println!("Dry-run of plan: '{}'", plan.name);
if !plan.base_url.is_empty() {
println!("Base URL: {}", plan.base_url);
}
let empty: HashMap<String, serde_json::Value> = HashMap::new();
let mut total = 0usize;
let mut validation_errors = Vec::new();
for req in plan.request_steps() {
let method = req.method;
let url = momus_core::engine::templates::resolve_url(&req.url, &plan.base_url, &empty);
let headers: Vec<String> = req
.headers
.iter()
.map(|(k, v)| {
let val = if is_sensitive_header(k) {
"[REDACTED]".to_string()
} else {
v.clone()
};
format!("{k}: {val}")
})
.collect();
let body_size = req
.body
.as_ref()
.map(|b| serde_json::to_string(b).map(|s| s.len()).unwrap_or(0));
println!(
" {method} {url} [{}]",
match body_size {
Some(n) => format!("body={n} bytes"),
None => "no body".to_string(),
}
);
if !headers.is_empty() {
for h in &headers {
println!(" {h}");
}
}
if !req.assert.is_empty() {
println!(" assertions: {}", req.assert.len());
}
if !req.save_as.is_empty() {
println!(" save_as: {}", req.save_as);
}
total += 1;
if plan.base_url.is_empty() && !req.url.starts_with("http") {
validation_errors.push(format!(
"request '{}' has a relative URL '{}' but no base_url is set",
req.name, req.url
));
}
}
println!("\nTotal requests: {total}");
if !validation_errors.is_empty() {
for e in &validation_errors {
eprintln!(" error: {e}");
}
anyhow::bail!(
"plan failed dry-run validation: {} error(s)",
validation_errors.len()
);
}
println!("Dry-run OK: {} request(s) would be sent", total);
Ok(())
}
fn is_sensitive_header(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
lower.contains("authorization")
|| lower.contains("cookie")
|| lower.contains("token")
|| lower.contains("secret")
|| lower.contains("api-key")
|| lower.contains("apikey")
}
fn read_plan_content(plan: &str) -> Result<String> {
if plan == "-" {
use std::io::Read;
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
Ok(buf)
} else {
std::fs::read_to_string(plan).with_context(|| format!("Failed to read plan file '{plan}'"))
}
}
fn load_config(explicit: Option<&str>) -> Result<MomusConfig> {
let path = match explicit {
Some(p) => Some(p.to_string()),
None => {
let candidates = [
"momus.toml",
".momus.toml",
&dirs::config_dir()
.map(|d| d.join("momus").join("config.toml"))
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default(),
];
candidates
.iter()
.find(|p| !p.is_empty() && std::path::Path::new(p).exists())
.map(|s| s.to_string())
}
};
match path {
Some(p) => {
tracing::debug!("Loading config from: {}", p);
let mut cfg = MomusConfig::load(&p)
.with_context(|| format!("Failed to load config file '{p}'"))?;
if let Ok(env) = std::env::var("MOMUS_ENV") {
cfg.apply_env(&env)?;
}
Ok(cfg)
}
None => Ok(MomusConfig::default()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use momus_core::config::BasicAuth;
fn plan() -> TestPlan {
TestPlan {
name: "t".into(),
base_url: "http://localhost".into(),
default_headers: std::collections::HashMap::new(),
steps: vec![],
setup: vec![],
teardown: vec![],
}
}
#[test]
fn apply_auth_to_plan_adds_bearer_header() {
let mut p = plan();
let auth = AuthConfig {
bearer: Some("tok".into()),
basic: None,
};
apply_auth_to_plan(&mut p, &auth).unwrap();
assert_eq!(
p.default_headers.get("Authorization").map(String::as_str),
Some("Bearer tok")
);
}
#[test]
fn apply_auth_to_plan_adds_basic_header() {
let mut p = plan();
let auth = AuthConfig {
bearer: None,
basic: Some(BasicAuth {
username: "u".into(),
password: "p".into(),
}),
};
apply_auth_to_plan(&mut p, &auth).unwrap();
assert_eq!(
p.default_headers.get("Authorization").map(String::as_str),
Some("Basic dTpw")
);
}
#[test]
fn apply_auth_to_plan_keeps_existing_authorization() {
let mut p = plan();
p.default_headers
.insert("Authorization".into(), "Bearer own".into());
let auth = AuthConfig {
bearer: Some("global".into()),
basic: None,
};
apply_auth_to_plan(&mut p, &auth).unwrap();
assert_eq!(
p.default_headers.get("Authorization").map(String::as_str),
Some("Bearer own")
);
}
#[test]
fn apply_auth_to_plan_empty_auth_is_noop() {
let mut p = plan();
apply_auth_to_plan(&mut p, &AuthConfig::default()).unwrap();
assert!(p.default_headers.is_empty());
}
#[test]
fn write_endpoint_uses_own_credentials_when_provided() {
let ep = write_endpoint_from_cli(
"http://srv".into(),
"PUT".into(),
Some("user".into()),
Some("pass".into()),
4,
&AuthConfig::default(),
)
.unwrap();
match ep {
momus_convert::WriteEndpoint::Repository {
username,
password,
upload_method,
concurrency,
..
} => {
assert_eq!(username, "user");
assert_eq!(password, "pass");
assert_eq!(upload_method, "PUT");
assert_eq!(concurrency, 4);
}
other => panic!("expected Repository, got {other:?}"),
}
}
#[test]
fn write_endpoint_falls_back_to_global_bearer() {
let auth = AuthConfig {
bearer: Some("tok".into()),
basic: None,
};
let ep = write_endpoint_from_cli("http://srv".into(), "PUT".into(), None, None, 4, &auth)
.unwrap();
match ep {
momus_convert::WriteEndpoint::Server { headers, .. } => {
assert_eq!(
headers.get("Authorization").map(String::as_str),
Some("Bearer tok")
);
}
other => panic!("expected Server, got {other:?}"),
}
}
#[test]
fn write_endpoint_falls_back_to_global_basic() {
let auth = AuthConfig {
bearer: None,
basic: Some(BasicAuth {
username: "u".into(),
password: "p".into(),
}),
};
let ep = write_endpoint_from_cli("http://srv".into(), "PUT".into(), None, None, 4, &auth)
.unwrap();
match ep {
momus_convert::WriteEndpoint::Server { headers, .. } => {
assert_eq!(
headers.get("Authorization").map(String::as_str),
Some("Basic dTpw")
);
}
other => panic!("expected Server, got {other:?}"),
}
}
#[test]
fn write_endpoint_no_auth_yields_empty_headers() {
let ep = write_endpoint_from_cli(
"http://srv".into(),
"PUT".into(),
None,
None,
4,
&AuthConfig::default(),
)
.unwrap();
match ep {
momus_convert::WriteEndpoint::Server { headers, .. } => assert!(headers.is_empty()),
other => panic!("expected Server, got {other:?}"),
}
}
}