use clap::{self, Parser};
use console::style;
use simple_logger;
use std::{
collections::{HashMap, HashSet},
path::PathBuf,
sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex,
},
time::{Duration, SystemTime},
};
use prompt_generator::{collection_prompt, request_prompt, scan_directory};
mod error;
mod benchmark;
mod collection;
mod parser;
mod prompt_generator;
mod request;
use error::{print_error, HenError, HenErrorKind, HenResult};
#[derive(clap::Parser, Debug)]
#[command(name = "hen")]
#[command(version = env!("CARGO_PKG_VERSION"))]
#[command(about = "Command line API client.")]
struct Cli {
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,
}
const PREVIEW_MAX_LINES: usize = 12;
const PREVIEW_MAX_CHARS: usize = 800;
#[tokio::main]
async fn main() {
let args: Cli = Cli::parse();
match run(args).await {
Ok(()) => {}
Err(err) => {
print_error(&err);
std::process::exit(err.exit_code());
}
}
}
async fn run(args: Cli) -> HenResult<()> {
if !args.inputs.is_empty() {
let prompt_inputs: HashMap<String, String> =
args.inputs.iter().cloned().collect::<HashMap<_, _>>();
parser::context::set_prompt_inputs(prompt_inputs);
}
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)?;
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());
#[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);
}
}
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 = request::execute_request_plan_with_observer(
&collection.requests,
&plan,
execution_options,
Some(observer),
)
.await;
let (records, failures, execution_failed) = match execution_result {
Ok(records) => (records, Vec::new(), false),
Err(err) => {
let (failures, completed) = err.into_parts();
(completed, failures, true)
}
};
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);
}
}
}
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);
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 load_collection(args: &Cli, cwd: PathBuf) -> HenResult<collection::Collection> {
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 resolve_execution_plan(
collection: &collection::Collection,
planner: &request::RequestPlanner,
args: &Cli,
) -> HenResult<(Vec<usize>, Vec<usize>, Option<usize>)> {
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 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_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) {
println!(" {}", style("Body preview:").dim());
let body = record.execution.output.trim();
if body.is_empty() {
println!(" {}", style("<empty>").dim());
return;
}
let (preview, truncated) = build_preview(body, PREVIEW_MAX_LINES, PREVIEW_MAX_CHARS);
for line in preview.lines() {
println!(" {}", line);
}
if truncated {
println!(
" {}",
style(format!(
"... truncated to {} lines / {} chars",
PREVIEW_MAX_LINES, PREVIEW_MAX_CHARS
))
.dim()
);
}
}
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(),
};
println!(
"{} {} {} — {}",
failure_symbol,
index_label,
request_label,
style(detail).red()
);
}
fn print_summary(records: &[request::ExecutionRecord], failures: &[request::RequestFailure]) {
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()
);
}
}
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,
}
}