use anyhow::Result;
use clap::{Parser, Subcommand, ValueEnum};
use momus_core::ast::*;
use momus_core::engine::runner;
use std::path::PathBuf;
#[derive(ValueEnum, Debug, Clone)]
enum OutputFormat {
Auto,
Html,
Text,
}
#[derive(Parser)]
#[command(
name = "momus",
about = "Generic API test harness with a composable assertion AST"
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Run {
plan: String,
#[arg(long)]
base_url: Option<String>,
#[arg(long, default_value = "./output")]
output: PathBuf,
#[arg(long, value_enum, default_value = "auto")]
format: OutputFormat,
},
Validate {
plan: PathBuf,
},
Mock {
#[arg(long, default_value = "0")]
port: u16,
},
Bench {
plan: PathBuf,
#[arg(long, default_value = "10")]
concurrency: usize,
#[arg(long, default_value = "30")]
duration: 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, default_value = "1000")]
iterations: usize,
#[arg(long)]
base_url: Option<String>,
},
Chaos {
plan: PathBuf,
#[arg(long)]
base_url: Option<String>,
},
Convert {
format: String,
input: String,
#[arg(short, long)]
output: Option<PathBuf>,
},
Contract {
plan: PathBuf,
#[arg(long)]
spec: String,
#[arg(long)]
base_url: Option<String>,
},
Guard {
plan: PathBuf,
#[arg(long)]
base_url: Option<String>,
},
Diff {
plan: PathBuf,
#[arg(long)]
baseline: String,
#[arg(long)]
target: String,
},
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive(tracing::Level::INFO.into()),
)
.init();
let cli = Cli::parse();
match cli.command {
Commands::Run {
plan,
base_url,
output,
format,
} => {
let content = if plan == "-" {
use std::io::Read;
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
buf
} else {
std::fs::read_to_string(&plan)?
};
let mut test_plan: TestPlan = serde_json::from_str(&content)?;
if let Some(url) = base_url {
test_plan.base_url = url;
}
tracing::info!(
"Running test plan '{}' with {} test(s) against {}",
test_plan.name,
test_plan.total_tests(),
test_plan.base_url
);
let report = runner::execute_plan(&test_plan).await?;
let want_html = match format {
OutputFormat::Html => true,
OutputFormat::Text => false,
OutputFormat::Auto => output
.extension()
.and_then(|e| e.to_str())
.map(|e| e == "html")
.unwrap_or(false),
};
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);
}
std::fs::create_dir_all(&output)?;
report.write_results(&output)?;
println!("\nResults written to: {}/results/", output.display());
if report.failed > 0 {
std::process::exit(1);
}
Ok(())
}
Commands::Validate { plan } => {
let content = std::fs::read_to_string(&plan)?;
let test_plan: TestPlan = serde_json::from_str(&content)?;
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,
} => {
let plan = momus_convert::convert(&format, &input)?;
let json = serde_json::to_string_pretty(&plan)?;
match output {
Some(path) => std::fs::write(path, json)?,
None => println!("{}", json),
}
Ok(())
}
Commands::Bench {
plan,
concurrency,
duration,
base_url,
output,
format,
} => {
let content = std::fs::read_to_string(&plan)?;
let test_plan: TestPlan = serde_json::from_str(&content)?;
let config = momus_bench::BenchConfig {
mode: momus_bench::BenchMode::Steady {
concurrency,
duration_secs: duration,
},
base_url,
..Default::default()
};
let report = momus_bench::run_bench(&test_plan, &config).await?;
let want_html = match format {
OutputFormat::Html => true,
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),
};
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,
} => {
let content = std::fs::read_to_string(&plan)?;
let test_plan: TestPlan = serde_json::from_str(&content)?;
let config = momus_fuzz::FuzzConfig {
iterations,
base_url,
..Default::default()
};
let report = momus_fuzz::run_fuzz(&test_plan, &config).await?;
println!("{}", report);
Ok(())
}
Commands::Chaos { plan, base_url } => {
let content = std::fs::read_to_string(&plan)?;
let test_plan: TestPlan = serde_json::from_str(&content)?;
let config = momus_chaos::ChaosConfig {
base_url,
..Default::default()
};
let reports = momus_chaos::run_chaos(&test_plan, &config).await?;
for report in &reports {
println!("{}", report);
}
Ok(())
}
Commands::Contract {
plan,
spec,
base_url,
} => {
let content = std::fs::read_to_string(&plan)?;
let test_plan: TestPlan = serde_json::from_str(&content)?;
let config = momus_contract::ContractConfig {
spec_path: spec,
base_url,
..Default::default()
};
let report = momus_contract::run_contract(&test_plan, &config).await?;
println!("{}", report);
Ok(())
}
Commands::Guard { plan, base_url } => {
let content = std::fs::read_to_string(&plan)?;
let test_plan: TestPlan = serde_json::from_str(&content)?;
let config = momus_guard::GuardConfig {
base_url,
..Default::default()
};
let report = momus_guard::run_guard(&test_plan, &config).await?;
println!("{}", report);
Ok(())
}
Commands::Diff {
plan,
baseline,
target,
} => {
let content = std::fs::read_to_string(&plan)?;
let test_plan: TestPlan = serde_json::from_str(&content)?;
let config = momus_diff::DiffConfig {
baseline_url: baseline,
target_url: target,
..Default::default()
};
let report = momus_diff::run_diff(&test_plan, &config).await?;
println!("{}", report);
Ok(())
}
}
}