use clap::{self, Parser};
use console::style;
use simple_logger;
use std::{
collections::{HashMap, HashSet},
future::Future,
path::PathBuf,
pin::Pin,
sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex,
},
time::{Duration, SystemTime},
};
use hen::{
automation, benchmark, collection,
error::{print_error, HenError, HenErrorKind, HenResult},
parser,
prompt_generator::{collection_prompt, request_prompt, scan_directory},
report::{self, BodyReportOptions},
request,
};
#[derive(clap::Args, Debug, Clone, Default)]
struct RunArgs {
path: Option<String>,
selector: Option<String>,
#[arg(long)]
export: bool,
#[arg(long)]
benchmark: Option<usize>,
#[arg(short = 'v', long)]
verbose: bool,
#[arg(long = "input", value_parser = parse_input_kv)]
inputs: Vec<(String, String)>,
#[arg(long, help = "Execute independent requests concurrently")]
parallel: bool,
#[arg(
long = "max-concurrency",
default_value_t = 0,
help = "Limit concurrent requests when running with --parallel"
)]
max_concurrency: usize,
#[arg(
long = "continue-on-error",
help = "Do not stop execution after the first request failure"
)]
continue_on_error: bool,
#[arg(
long,
help = "Fail instead of prompting for collection or request selection"
)]
non_interactive: bool,
#[arg(
long,
value_enum,
default_value_t = OutputFormat::Text,
help = "Render output as text, json, ndjson, or junit"
)]
output: OutputFormat,
}
#[derive(clap::Args, Debug, Clone)]
struct VerifyArgs {
path: String,
#[arg(
long,
value_enum,
default_value_t = OutputFormat::Text,
help = "Render output as text, json, ndjson, or junit"
)]
output: OutputFormat,
}
#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq, Default)]
enum OutputFormat {
#[default]
Text,
Json,
Ndjson,
Junit,
}
impl OutputFormat {
fn is_text(self) -> bool {
matches!(self, Self::Text)
}
fn as_str(self) -> &'static str {
match self {
Self::Text => "text",
Self::Json => "json",
Self::Ndjson => "ndjson",
Self::Junit => "junit",
}
}
}
#[derive(clap::Subcommand, Debug, Clone)]
enum Command {
Run(RunArgs),
Verify(VerifyArgs),
}
enum Invocation {
Run(RunArgs),
Verify(VerifyArgs),
}
#[derive(clap::Parser, Debug)]
#[command(name = "hen")]
#[command(version = env!("CARGO_PKG_VERSION"))]
#[command(about = "Command line API client.")]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
#[command(flatten)]
run: RunArgs,
}
impl Cli {
fn into_invocation(self) -> Invocation {
match self.command {
Some(Command::Run(args)) => Invocation::Run(args),
Some(Command::Verify(args)) => Invocation::Verify(args),
None => Invocation::Run(self.run),
}
}
}
struct PromptSession {
previous_mode: parser::context::PromptMode,
}
impl PromptSession {
fn configure(args: &RunArgs) -> Self {
let previous_mode = parser::context::prompt_mode();
let prompt_inputs: HashMap<String, String> = args.inputs.iter().cloned().collect();
parser::context::set_prompt_mode(parser::context::PromptMode::NonInteractive);
parser::context::set_prompt_inputs(prompt_inputs);
Self { previous_mode }
}
}
impl Drop for PromptSession {
fn drop(&mut self) {
parser::context::set_prompt_inputs(HashMap::new());
parser::context::set_prompt_mode(self.previous_mode);
}
}
const PREVIEW_MAX_LINES: usize = 12;
const PREVIEW_MAX_CHARS: usize = 800;
const STRUCTURED_BODY_MAX_CHARS: usize = 4_000;
struct CommandOutcome {
exit_code: i32,
}
impl CommandOutcome {
fn success() -> Self {
Self { exit_code: 0 }
}
fn with_exit_code(exit_code: i32) -> Self {
Self { exit_code }
}
}
#[derive(Debug, Clone)]
struct ExecutionState {
records: Vec<request::ExecutionRecord>,
failures: Vec<request::RequestFailure>,
execution_failed: bool,
interrupted: Option<request::InterruptSignal>,
}
#[derive(Debug, Default, Clone)]
struct CollectedExecution {
records: Arc<Mutex<Vec<request::ExecutionRecord>>>,
failures: Arc<Mutex<Vec<request::RequestFailure>>>,
}
impl CollectedExecution {
fn snapshot(&self) -> (Vec<request::ExecutionRecord>, Vec<request::RequestFailure>) {
let mut records = self.records.lock().unwrap().clone();
let mut failures = self.failures.lock().unwrap().clone();
records.sort_by_key(|record| record.index);
failures.sort_by_key(|failure| failure.index().unwrap_or(usize::MAX));
(records, failures)
}
}
type InterruptFuture = Pin<Box<dyn Future<Output = HenResult<request::InterruptSignal>> + Send>>;
fn tracking_observer(
downstream: Option<request::ExecutionObserver>,
) -> (request::ExecutionObserver, CollectedExecution) {
let collected = CollectedExecution::default();
let records = Arc::clone(&collected.records);
let failures = Arc::clone(&collected.failures);
let observer: request::ExecutionObserver = Arc::new(move |event| {
match &event {
request::ExecutionEvent::RequestCompleted { record } => {
records.lock().unwrap().push(record.clone());
}
request::ExecutionEvent::RequestFailed { failure } => {
failures.lock().unwrap().push(failure.clone());
}
request::ExecutionEvent::AssertionPassed { .. } => {}
}
if let Some(callback) = downstream.as_ref() {
callback(event);
}
});
(observer, collected)
}
fn interrupt_listener() -> HenResult<InterruptFuture> {
#[cfg(unix)]
{
let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.map_err(|err| {
HenError::new(HenErrorKind::Execution, "Failed to install SIGTERM handler")
.with_detail(err.to_string())
})?;
Ok(Box::pin(async move {
tokio::select! {
result = tokio::signal::ctrl_c() => result
.map(|_| request::InterruptSignal::Sigint)
.map_err(|err| {
HenError::new(HenErrorKind::Execution, "Failed while waiting for SIGINT")
.with_detail(err.to_string())
}),
signal = terminate.recv() => match signal {
Some(()) => Ok(request::InterruptSignal::Sigterm),
None => Err(HenError::new(
HenErrorKind::Execution,
"SIGTERM listener closed unexpectedly",
)),
},
}
}))
}
#[cfg(not(unix))]
{
Ok(Box::pin(async move {
tokio::signal::ctrl_c()
.await
.map(|_| request::InterruptSignal::Sigint)
.map_err(|err| {
HenError::new(HenErrorKind::Execution, "Failed while waiting for Ctrl-C")
.with_detail(err.to_string())
})
}))
}
}
async fn execute_plan_with_interrupt(
requests: &[request::Request],
plan: &[usize],
options: request::ExecutionOptions,
observer: Option<request::ExecutionObserver>,
) -> HenResult<ExecutionState> {
let (observer, collected) = tracking_observer(observer);
let execution = request::execute_request_plan_with_observer(
requests,
plan,
options,
Some(observer),
);
let interrupt = interrupt_listener()?;
tokio::pin!(execution);
tokio::pin!(interrupt);
tokio::select! {
result = &mut execution => {
let (records, failures, execution_failed) = match result {
Ok(records) => (records, Vec::new(), false),
Err(err) => {
let (failures, completed) = err.into_parts();
(completed, failures, true)
}
};
Ok(ExecutionState {
records,
failures,
execution_failed,
interrupted: None,
})
}
result = &mut interrupt => {
let signal = result?;
let (records, failures) = collected.snapshot();
Ok(ExecutionState {
records,
failures,
execution_failed: true,
interrupted: Some(signal),
})
}
}
}
#[tokio::main]
async fn main() {
let result = match Cli::parse().into_invocation() {
Invocation::Run(args) => run(args).await,
Invocation::Verify(args) => verify(args),
};
match result {
Ok(outcome) => {
if outcome.exit_code != 0 {
std::process::exit(outcome.exit_code);
}
}
Err(err) => {
print_error(&err);
std::process::exit(err.exit_code());
}
}
}
async fn run(args: RunArgs) -> HenResult<CommandOutcome> {
if args.output.is_text() {
run_text(args).await?;
return Ok(CommandOutcome::success());
}
Ok(run_machine_output(args).await)
}
async fn run_text(args: RunArgs) -> HenResult<()> {
let _prompt_session = PromptSession::configure(&args);
if args.verbose {
simple_logger::init_with_level(log::Level::Debug).map_err(|err| {
HenError::new(HenErrorKind::Cli, "Failed to initialize logger")
.with_detail(err.to_string())
})?;
}
log::debug!("Starting hen with args {:?}", args);
let cwd = std::env::current_dir().map_err(|err| {
HenError::new(HenErrorKind::Io, "Failed to determine current directory")
.with_detail(err.to_string())
})?;
let collection = load_collection(&args, cwd.clone()).map_err(|err| match err.kind() {
HenErrorKind::Parse | HenErrorKind::Io => err,
_ => err.with_detail("While loading collection"),
})?;
log::debug!("PARSED COLLECTION\n{:#?}", collection);
let planner = request::RequestPlanner::new(&collection.requests).map_err(|err| {
HenError::new(
HenErrorKind::Planner,
"Failed to build request dependency graph",
)
.with_detail(err.to_string())
})?;
let (plan, display_targets, primary_target) =
resolve_execution_plan(&collection, &planner, &args)?;
automation::validate_plan_prompt_inputs(&collection.requests, &plan)?;
if args.export {
for idx in &display_targets {
if let Some(request) = collection.requests.get(*idx) {
println!("{}", request.as_curl());
}
}
return Ok(());
}
if let Some(count) = args.benchmark {
if let Some(target) = primary_target {
benchmark::benchmark(&collection.requests, &planner, target, count)
.await
.map_err(|err| {
HenError::new(HenErrorKind::Benchmark, "Benchmark execution failed")
.with_detail(err.to_string())
})?;
} else {
return Err(HenError::new(
HenErrorKind::Benchmark,
"Benchmark requires selecting a specific request",
)
.with_exit_code(2));
}
return Ok(());
}
let parallel_enabled = args.parallel || args.max_concurrency > 0;
let max_concurrency = if args.max_concurrency == 0 {
None
} else {
Some(args.max_concurrency)
};
let execution_options = request::ExecutionOptions {
parallel: parallel_enabled,
max_concurrency,
continue_on_error: args.continue_on_error,
};
log::debug!(
"Execution options: parallel={}, max_concurrency={:?}, continue_on_error={}",
execution_options.parallel,
execution_options.max_concurrency,
execution_options.continue_on_error
);
let display_set: HashSet<usize> = display_targets.iter().copied().collect();
let display_set_shared = Arc::new(display_set.clone());
let verbose = args.verbose;
#[derive(Default)]
struct StreamPrinterState {
needs_separator: bool,
}
let printer_state = Arc::new(Mutex::new(StreamPrinterState::default()));
let events_emitted = Arc::new(AtomicBool::new(false));
let observer: request::ExecutionObserver = Arc::new({
let display_set = Arc::clone(&display_set_shared);
let printer_state = Arc::clone(&printer_state);
let events_emitted = Arc::clone(&events_emitted);
move |event: request::ExecutionEvent| match event {
request::ExecutionEvent::RequestCompleted { record } => {
let mut state = printer_state.lock().unwrap();
events_emitted.store(true, Ordering::Relaxed);
if state.needs_separator {
println!();
} else {
state.needs_separator = true;
}
print_status_line(&record);
if display_set.contains(&record.index) {
print_body_preview(&record, verbose);
}
}
request::ExecutionEvent::RequestFailed { failure } => {
let mut state = printer_state.lock().unwrap();
events_emitted.store(true, Ordering::Relaxed);
if state.needs_separator {
println!();
} else {
state.needs_separator = true;
}
print_failure_line(&failure);
}
request::ExecutionEvent::AssertionPassed { request, assertion } => {
let _guard = printer_state.lock().unwrap();
events_emitted.store(true, Ordering::Relaxed);
println!("✅ [{}] [{}]", request, assertion);
}
}
});
let execution_result = execute_plan_with_interrupt(
&collection.requests,
&plan,
execution_options,
Some(observer),
)
.await;
let execution_result = execution_result?;
let records = execution_result.records;
let failures = execution_result.failures;
let execution_failed = execution_result.execution_failed;
let interrupted = execution_result.interrupted;
if events_emitted.load(Ordering::Relaxed) {
println!();
} else {
if !records.is_empty() {
for (idx, record) in records.iter().enumerate() {
if idx > 0 {
println!();
}
print_status_line(record);
if display_set.contains(&record.index) {
print_body_preview(record, verbose);
}
}
}
if !failures.is_empty() {
if !records.is_empty() {
println!();
}
for failure in &failures {
print_failure_line(failure);
}
}
if !records.is_empty() || !failures.is_empty() {
println!();
}
}
print_summary(&records, &failures, interrupted, plan.len());
if let Some(signal) = interrupted {
return Err(
HenError::new(
HenErrorKind::Execution,
format!("Execution interrupted by {}", signal.as_str()),
)
.with_exit_code(signal.exit_code()),
);
}
if !failures.is_empty() {
let failure_details = failures
.iter()
.map(|failure| failure.to_string())
.collect::<Vec<_>>()
.join("\n");
return Err(
HenError::new(HenErrorKind::Execution, "One or more requests failed")
.with_detail(failure_details),
);
}
if execution_failed {
return Err(HenError::new(
HenErrorKind::Execution,
"Execution terminated before completing all requests",
));
}
Ok(())
}
fn verify(args: VerifyArgs) -> HenResult<CommandOutcome> {
if args.output.is_text() {
verify_text(args)?;
return Ok(CommandOutcome::success());
}
Ok(verify_machine_output(args))
}
fn verify_text(args: VerifyArgs) -> HenResult<()> {
let result = automation::verify_path(PathBuf::from(args.path))?;
print_verification_result(&result);
Ok(())
}
async fn run_machine_output(args: RunArgs) -> CommandOutcome {
let output = args.output;
let suite_name = args.path.as_deref().unwrap_or("hen run").to_string();
match build_machine_run_outcome(&args).await {
Ok(outcome) => {
print_run_report(output, &outcome);
let exit_code = if let Some(signal) = outcome.interrupted {
signal.exit_code()
} else if outcome.failures.is_empty() && !outcome.execution_failed {
0
} else {
1
};
CommandOutcome::with_exit_code(exit_code)
}
Err(err) => {
print_machine_error(output, &suite_name, "run", &err);
CommandOutcome::with_exit_code(err.exit_code())
}
}
}
fn verify_machine_output(args: VerifyArgs) -> CommandOutcome {
let output = args.output;
let suite_name = args.path.clone();
match automation::verify_path(PathBuf::from(args.path)) {
Ok(result) => {
print_verification_report(output, &result);
CommandOutcome::success()
}
Err(err) => {
print_machine_error(output, &suite_name, "verify", &err);
CommandOutcome::with_exit_code(err.exit_code())
}
}
}
async fn build_machine_run_outcome(args: &RunArgs) -> HenResult<automation::RunOutcome> {
if args.export {
return Err(HenError::new(
HenErrorKind::Input,
format!(
"--output {} cannot be combined with --export",
args.output.as_str()
),
)
.with_exit_code(2));
}
if args.benchmark.is_some() {
return Err(HenError::new(
HenErrorKind::Input,
format!(
"--output {} cannot be combined with --benchmark",
args.output.as_str()
),
)
.with_exit_code(2));
}
let path = match args.path.as_ref() {
Some(path) => PathBuf::from(path),
None => std::env::current_dir().map_err(|err| {
HenError::new(HenErrorKind::Io, "Failed to determine current directory")
.with_detail(err.to_string())
})?,
};
let parallel_enabled = args.parallel || args.max_concurrency > 0;
let max_concurrency = if args.max_concurrency == 0 {
None
} else {
Some(args.max_concurrency)
};
let execution_options = request::ExecutionOptions {
parallel: parallel_enabled,
max_concurrency,
continue_on_error: args.continue_on_error,
};
let prepared = automation::prepare_run_path(automation::RunRequest {
path,
selector: args.selector.clone(),
inputs: args.inputs.iter().cloned().collect(),
execution_options: execution_options.clone(),
})
.await?;
let execution = execute_plan_with_interrupt(
&prepared.requests,
&prepared.plan,
execution_options,
None,
)
.await?;
Ok(automation::finish_run(
prepared,
execution.records,
execution.failures,
execution.execution_failed,
execution.interrupted,
))
}
fn print_run_report(output: OutputFormat, outcome: &automation::RunOutcome) {
match output {
OutputFormat::Text => unreachable!("text output should be handled separately"),
OutputFormat::Json => print_json(&report::run_outcome_json(
outcome,
BodyReportOptions {
include_body: true,
max_body_chars: Some(STRUCTURED_BODY_MAX_CHARS),
},
)),
OutputFormat::Ndjson => println!(
"{}",
report::run_outcome_ndjson(
outcome,
BodyReportOptions {
include_body: true,
max_body_chars: Some(STRUCTURED_BODY_MAX_CHARS),
},
)
),
OutputFormat::Junit => println!("{}", report::run_outcome_junit(outcome)),
}
}
fn print_verification_report(output: OutputFormat, result: &automation::VerificationResult) {
match output {
OutputFormat::Text => unreachable!("text output should be handled separately"),
OutputFormat::Json => print_json(&report::verification_result_json(result)),
OutputFormat::Ndjson => println!("{}", report::verification_result_ndjson(result)),
OutputFormat::Junit => println!("{}", report::verification_result_junit(result)),
}
}
fn print_machine_error(output: OutputFormat, suite_name: &str, case_name: &str, error: &HenError) {
match output {
OutputFormat::Text => unreachable!("text errors should be printed by main"),
OutputFormat::Json => print_json(&report::hen_error_json(error)),
OutputFormat::Ndjson => println!("{}", report::hen_error_ndjson(error)),
OutputFormat::Junit => {
println!("{}", report::hen_error_junit(suite_name, case_name, error))
}
}
}
fn print_json(value: &serde_json::Value) {
let rendered = serde_json::to_string_pretty(value).expect("json output should serialize");
println!("{}", rendered);
}
fn load_collection(args: &RunArgs, cwd: PathBuf) -> HenResult<collection::Collection> {
if args.non_interactive {
return load_collection_non_interactive(args, cwd);
}
let collection = match args.path.as_ref() {
Some(path) => {
let path = PathBuf::from(path);
if path.is_dir() {
let hen_files = scan_directory(path.clone()).map_err(|err| {
err.with_detail(format!("While scanning directory {}", path.display()))
})?;
if hen_files.len() == 1 {
collection::Collection::new(hen_files[0].clone())
} else {
collection_prompt(path)
}
} else {
collection::Collection::new(path)
}
}
None => collection_prompt(cwd),
}?;
Ok(collection)
}
fn load_collection_non_interactive(
args: &RunArgs,
cwd: PathBuf,
) -> HenResult<collection::Collection> {
let path = args.path.as_ref().map(PathBuf::from).unwrap_or(cwd);
if path.is_dir() {
let hen_files = scan_directory(path.clone()).map_err(|err| {
err.with_detail(format!("While scanning directory {}", path.display()))
})?;
return match hen_files.len() {
0 => Err(
HenError::new(HenErrorKind::Input, "No .hen files found in directory")
.with_detail(format!("Directory: {}", path.display()))
.with_exit_code(2),
),
1 => collection::Collection::new(hen_files[0].clone()),
_ => Err(HenError::new(
HenErrorKind::Input,
"Directory contains multiple .hen files and cannot be resolved non-interactively",
)
.with_detail(format!("Directory: {}", path.display()))
.with_detail("Provide a specific collection file path instead.")
.with_exit_code(2)),
};
}
collection::Collection::new(path)
}
fn resolve_execution_plan(
collection: &collection::Collection,
planner: &request::RequestPlanner,
args: &RunArgs,
) -> HenResult<(Vec<usize>, Vec<usize>, Option<usize>)> {
if args.non_interactive {
return resolve_execution_plan_non_interactive(
collection,
planner,
args.selector.as_deref(),
);
}
match args.selector.as_deref() {
Some("all") => {
let order = planner.order_all();
Ok((order.clone(), order, None))
}
Some(selector) => {
let idx = selector.parse::<usize>().map_err(|_| {
HenError::new(HenErrorKind::Input, "Selector must be an integer")
.with_detail(format!("Received: {}", selector))
.with_exit_code(2)
})?;
let order = planner.order_for(idx).map_err(|err| {
HenError::new(HenErrorKind::Planner, "Failed to plan for selected request")
.with_detail(err.to_string())
})?;
Ok((order.clone(), vec![idx], Some(idx)))
}
None => {
if collection.requests.len() == 1 {
let order = planner.order_for(0).map_err(|err| {
HenError::new(HenErrorKind::Planner, "Failed to plan for the only request")
.with_detail(err.to_string())
})?;
Ok((order.clone(), vec![0], Some(0)))
} else {
let selection = request_prompt(collection);
let order = planner.order_for(selection).map_err(|err| {
HenError::new(HenErrorKind::Planner, "Failed to plan for selected request")
.with_detail(err.to_string())
})?;
Ok((order.clone(), vec![selection], Some(selection)))
}
}
}
}
fn resolve_execution_plan_non_interactive(
collection: &collection::Collection,
planner: &request::RequestPlanner,
selector: Option<&str>,
) -> HenResult<(Vec<usize>, Vec<usize>, Option<usize>)> {
match selector {
Some("all") => {
let order = planner.order_all();
Ok((order.clone(), order, None))
}
Some(selector) => {
let idx = selector.parse::<usize>().map_err(|_| {
HenError::new(HenErrorKind::Input, "Selector must be an integer or 'all'")
.with_detail(format!("Received: {}", selector))
.with_exit_code(2)
})?;
let order = planner.order_for(idx).map_err(|err| {
HenError::new(HenErrorKind::Planner, "Failed to plan for selected request")
.with_detail(err.to_string())
})?;
Ok((order.clone(), vec![idx], Some(idx)))
}
None => {
if collection.requests.len() == 1 {
let order = planner.order_for(0).map_err(|err| {
HenError::new(HenErrorKind::Planner, "Failed to plan for the only request")
.with_detail(err.to_string())
})?;
Ok((order.clone(), vec![0], Some(0)))
} else {
Err(HenError::new(
HenErrorKind::Input,
"A selector is required when a collection contains multiple requests",
)
.with_detail("Provide a request index or use 'all'.")
.with_exit_code(2))
}
}
}
}
fn parse_input_kv(s: &str) -> Result<(String, String), String> {
let mut parts = s.splitn(2, '=');
let key = parts
.next()
.map(str::trim)
.filter(|k| !k.is_empty())
.ok_or_else(|| "--input expects key=value".to_string())?;
let value = parts
.next()
.map(|v| v.trim().to_string())
.ok_or_else(|| "--input expects key=value".to_string())?;
Ok((key.to_string(), value))
}
fn print_verification_result(result: &automation::VerificationResult) {
println!("{}", style("Verification passed").green().bold());
if let Some(path) = &result.path {
println!("{} {}", style("Path:").dim(), path.display());
}
if !result.summary.name.trim().is_empty() {
println!(
"{} {}",
style("Collection:").dim(),
result.summary.name.trim()
);
}
if !result.summary.description.trim().is_empty() {
println!(
"{} {}",
style("Description:").dim(),
result.summary.description.trim()
);
}
println!("{}", style("Requests").bold());
for request in &result.summary.requests {
println!(" [{}] {} {}", request.index, request.method, request.url);
}
println!("{}", style("Required inputs").bold());
if result.required_inputs.is_empty() {
println!(" {}", style("<none>").dim());
return;
}
for prompt in &result.required_inputs {
match &prompt.default {
Some(default) => println!(" - {} (default: {})", prompt.name, default),
None => println!(" - {}", prompt.name),
}
}
}
#[cfg(test)]
mod cli_tests {
use super::*;
#[test]
fn cli_parses_run_subcommand_non_interactive_mode() {
let cli = Cli::try_parse_from(["hen", "run", "./example.hen", "all", "--non-interactive"])
.expect("cli should parse");
let Invocation::Run(args) = cli.into_invocation() else {
panic!("expected run invocation");
};
assert_eq!(args.path.as_deref(), Some("./example.hen"));
assert_eq!(args.selector.as_deref(), Some("all"));
assert!(args.non_interactive);
}
#[test]
fn cli_parses_verify_subcommand() {
let cli =
Cli::try_parse_from(["hen", "verify", "./example.hen"]).expect("cli should parse");
let Invocation::Verify(args) = cli.into_invocation() else {
panic!("expected verify invocation");
};
assert_eq!(args.path, "./example.hen");
}
#[test]
fn cli_parses_run_output_format() {
let cli = Cli::try_parse_from(["hen", "run", "./example.hen", "--output", "json"])
.expect("cli should parse");
let Invocation::Run(args) = cli.into_invocation() else {
panic!("expected run invocation");
};
assert_eq!(args.output, OutputFormat::Json);
}
}
fn print_status_line(record: &request::ExecutionRecord) {
let success_symbol = style("[ok]").green();
let index_label = style(format!("#{}", record.index)).dim();
let description_label = style(&record.description).bold();
let method_label = style(record.method.as_str()).cyan();
let url_label = style(&record.url).dim();
let target_label = format!("{} {}", method_label, url_label);
let status = record.execution.snapshot.status;
let status_text = if let Some(reason) = status.canonical_reason() {
format!("{} {}", status.as_u16(), reason)
} else {
status.as_u16().to_string()
};
let status_label = match status.as_u16() {
200..=299 => style(status_text).green(),
300..=399 => style(status_text).cyan(),
400..=499 => style(status_text).yellow(),
500..=599 => style(status_text).red(),
_ => style(status_text),
};
let duration_label = style(format_duration(record.duration)).dim();
println!(
"{} {} {} ({}) — {} — {}",
success_symbol, index_label, description_label, target_label, status_label, duration_label
);
}
fn print_body_preview(record: &request::ExecutionRecord, verbose: bool) {
let heading = if verbose { "Body:" } else { "Body preview:" };
println!(" {}", style(heading).dim());
let body = record.execution.output.trim();
if body.is_empty() {
println!(" {}", style("<empty>").dim());
return;
}
let (preview, truncated) = build_body_output(body, verbose);
for line in preview.lines() {
println!(" {}", line);
}
if truncated {
println!(
" {}",
style(format!(
"... truncated to {} lines / {} chars",
PREVIEW_MAX_LINES, PREVIEW_MAX_CHARS
))
.dim()
);
}
}
fn build_body_output(body: &str, verbose: bool) -> (String, bool) {
if verbose {
(body.to_string(), false)
} else {
build_preview(body, PREVIEW_MAX_LINES, PREVIEW_MAX_CHARS)
}
}
fn print_failure_line(failure: &request::RequestFailure) {
let failure_symbol = style("[x]").red();
let index_label = match failure.index() {
Some(idx) => style(format!("#{}", idx)).dim().to_string(),
None => style("#?").dim().to_string(),
};
let request_label = style(failure.request()).bold();
let detail = match failure.kind() {
request::RequestFailureKind::Execution { message, .. } => message.clone(),
request::RequestFailureKind::Dependency { dependency } => {
format!("skipped: dependency '{}' failed", dependency)
}
request::RequestFailureKind::MissingDependency { dependency } => {
format!("missing dependency '{}'", dependency)
}
request::RequestFailureKind::Join { message } => message.clone(),
request::RequestFailureKind::MapAborted { group, cause } => {
format!("skipped: '{}' aborted after failure in '{}'", group, cause)
}
};
println!(
"{} {} {} — {}",
failure_symbol,
index_label,
request_label,
style(detail).red()
);
}
fn print_summary(
records: &[request::ExecutionRecord],
failures: &[request::RequestFailure],
interrupted: Option<request::InterruptSignal>,
planned_count: usize,
) {
println!("{}", style("Summary").bold());
let success_text = format!("{} succeeded", records.len());
let success_label = if records.is_empty() {
style(success_text).dim()
} else {
style(success_text).green()
};
println!(" {}", success_label);
let failure_text = format!("{} failed", failures.len());
let failure_label = if failures.is_empty() {
style(failure_text).dim()
} else {
style(failure_text).red()
};
println!(" {}", failure_label);
if let Some(total) = total_elapsed(records) {
println!(
" {}",
style(format!("elapsed {}", format_duration(total))).dim()
);
}
if let Some(slowest) = records.iter().max_by_key(|record| record.duration) {
println!(
" {}",
style(format!(
"slowest {} ({})",
slowest.description,
format_duration(slowest.duration)
))
.dim()
);
}
if let Some(signal) = interrupted {
println!(
" {}",
style(format!(
"interrupted by {} after {} of {} planned requests finished",
signal.as_str(),
records.len() + failures.len(),
planned_count,
))
.yellow()
);
}
}
fn build_preview(text: &str, max_lines: usize, max_chars: usize) -> (String, bool) {
if text.is_empty() {
return (String::new(), false);
}
let mut preview = String::new();
let mut truncated = false;
let mut remaining_chars = max_chars;
let mut consumed_all = true;
for (idx, line) in text.lines().enumerate() {
if idx >= max_lines || remaining_chars == 0 {
truncated = true;
consumed_all = false;
break;
}
let mut line_segment = String::new();
for ch in line.chars() {
if remaining_chars == 0 {
truncated = true;
consumed_all = false;
break;
}
line_segment.push(ch);
remaining_chars -= 1;
}
if !preview.is_empty() {
preview.push('\n');
}
preview.push_str(&line_segment);
if remaining_chars == 0 {
truncated = true;
consumed_all = false;
break;
}
}
if preview.is_empty() {
let collected: String = text.chars().take(max_chars).collect();
let total_chars = text.chars().count();
let collected_chars = collected.chars().count();
return (collected, collected_chars < total_chars);
}
(preview, truncated || !consumed_all)
}
fn format_duration(duration: Duration) -> String {
if duration.as_secs() >= 1 {
let secs = duration.as_secs_f64();
if secs >= 10.0 {
format!("{:.1}s", secs)
} else {
format!("{:.2}s", secs)
}
} else if duration.as_millis() >= 1 {
format!("{} ms", duration.as_millis())
} else {
format!("{} us", duration.as_micros())
}
}
fn total_elapsed(records: &[request::ExecutionRecord]) -> Option<Duration> {
if records.is_empty() {
return None;
}
let mut earliest: Option<Duration> = None;
let mut latest: Option<Duration> = None;
for record in records {
let start = match record.started_at.duration_since(SystemTime::UNIX_EPOCH) {
Ok(duration) => duration,
Err(_) => continue,
};
let finish = match record
.started_at
.checked_add(record.duration)
.and_then(|time| time.duration_since(SystemTime::UNIX_EPOCH).ok())
{
Some(duration) => duration,
None => continue,
};
earliest = Some(match earliest {
Some(current) => current.min(start),
None => start,
});
latest = Some(match latest {
Some(current) => current.max(finish),
None => finish,
});
}
match (earliest, latest) {
(Some(start), Some(end)) if end >= start => Some(end - start),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_body_output_truncates_when_not_verbose() {
let body = (1..=20)
.map(|idx| format!("line {idx}"))
.collect::<Vec<_>>()
.join("\n");
let (output, truncated) = build_body_output(&body, false);
assert!(truncated);
assert!(output.lines().count() <= PREVIEW_MAX_LINES);
}
#[test]
fn build_body_output_keeps_full_text_when_verbose() {
let body = (1..=20)
.map(|idx| format!("line {idx}"))
.collect::<Vec<_>>()
.join("\n");
let (output, truncated) = build_body_output(&body, true);
assert!(!truncated);
assert_eq!(output, body);
}
}