use clap::{CommandFactory, Parser};
use indicatif::{ProgressBar, ProgressStyle};
use std::borrow::Cow;
use std::process::Command;
use std::time::Duration;
use waitup::{wait_for_connection, Target, WaitConfig, WaitForError, WaitResult};
#[derive(thiserror::Error, Debug)]
enum CliError {
#[error("Wait error: {0}")]
WaitError(#[from] WaitForError),
#[error("Invalid timeout format '{0}': {1}")]
InvalidTimeout(String, String),
#[error("Invalid interval format '{0}': {1}")]
InvalidInterval(String, String),
#[error("Command execution failed: {0}")]
CommandExecution(String),
#[error("JSON serialization failed: {0}")]
JsonSerialization(#[from] serde_json::Error),
}
type Result<T> = std::result::Result<T, CliError>;
#[derive(Parser)]
#[command(name = "waitup")]
#[command(about = "Block until host:port is reachable; exit non-zero on timeout")]
#[command(version)]
#[expect(
clippy::struct_excessive_bools,
reason = "CLI arg structures naturally have many boolean flags"
)]
struct Args {
#[arg(value_name = "TARGET")]
targets: Vec<String>,
#[arg(short, long, env = "WAITUP_TIMEOUT", default_value = "30s")]
timeout: String,
#[arg(short, long, env = "WAITUP_INTERVAL", default_value = "1s")]
interval: String,
#[arg(long, default_value = "30s")]
max_interval: String,
#[arg(long, default_value = "200")]
expect_status: u16,
#[arg(long, conflicts_with = "all")]
any: bool,
#[arg(long, conflicts_with = "any")]
all: bool,
#[arg(short, long, conflicts_with = "json")]
quiet: bool,
#[arg(short, long, conflicts_with = "quiet")]
verbose: bool,
#[arg(long, conflicts_with = "quiet")]
json: bool,
#[arg(long)]
retry_limit: Option<u32>,
#[arg(long, action = clap::ArgAction::Append)]
header: Vec<String>,
#[arg(long, default_value = "10s")]
connection_timeout: String,
#[arg(long, value_enum)]
generate_completion: Option<clap_complete::Shell>,
#[arg(last = true)]
command: Vec<String>,
}
#[derive(Debug, Clone)]
struct CliConfig {
targets: Vec<Target>,
wait_config: WaitConfig,
quiet: bool,
verbose: bool,
json: bool,
command: Vec<String>,
}
impl CliConfig {
fn from_args(args: Args) -> Result<Self> {
if args.generate_completion.is_some() {
return Ok(Self {
targets: Vec::new(),
wait_config: WaitConfig::default(),
quiet: true,
verbose: false,
json: false,
command: Vec::new(),
});
}
if args.targets.is_empty() {
return Err(CliError::WaitError(WaitForError::InvalidTarget(
Cow::Borrowed("At least one target must be specified"),
)));
}
let mut targets = Vec::new();
let mut headers = Vec::new();
for header_str in &args.header {
let parts: Vec<&str> = header_str.splitn(2, ':').collect();
if parts.len() != 2 {
return Err(CliError::WaitError(WaitForError::InvalidTarget(
Cow::Owned(format!(
"Invalid header format '{header_str}': expected 'key:value'"
)),
)));
}
headers.push((parts[0].trim().to_string(), parts[1].trim().to_string()));
}
for target_str in &args.targets {
if target_str.starts_with("http://") || target_str.starts_with("https://") {
let url = url::Url::parse(target_str).map_err(|_| {
CliError::WaitError(WaitForError::InvalidTarget(Cow::Owned(target_str.clone())))
})?;
if headers.is_empty() {
targets.push(Target::http(url, args.expect_status)?);
} else {
targets.push(Target::http_with_headers(
url,
args.expect_status,
headers.clone(),
)?);
}
} else {
targets.push(Target::parse(target_str, args.expect_status)?);
}
}
let timeout = args
.timeout
.parse::<humantime::Duration>()
.map_err(|e| CliError::InvalidTimeout(args.timeout, e.to_string()))?
.into();
let initial_interval = args
.interval
.parse::<humantime::Duration>()
.map_err(|e| CliError::InvalidInterval(args.interval, e.to_string()))?
.into();
let max_interval = args
.max_interval
.parse::<humantime::Duration>()
.map_err(|e| CliError::InvalidInterval(args.max_interval, e.to_string()))?
.into();
let connection_timeout = args
.connection_timeout
.parse::<humantime::Duration>()
.map_err(|e| CliError::InvalidInterval(args.connection_timeout, e.to_string()))?
.into();
let wait_for_any = args.any || (!args.all && targets.len() == 1);
let wait_config = WaitConfig::builder()
.timeout(timeout)
.interval(initial_interval)
.max_interval(max_interval)
.wait_for_any(wait_for_any)
.max_retries(args.retry_limit)
.connection_timeout(connection_timeout)
.build();
Ok(Self {
targets,
wait_config,
quiet: args.quiet,
verbose: args.verbose,
json: args.json,
command: args.command,
})
}
}
mod output {
use super::{CliConfig, Result, WaitResult};
use serde::Serialize;
#[derive(Serialize)]
pub struct JsonOutput {
pub success: bool,
pub elapsed_ms: u64,
pub total_attempts: u32,
pub targets: Vec<JsonTargetResult>,
}
#[derive(Serialize)]
pub struct JsonTargetResult {
pub target: String,
pub success: bool,
pub elapsed_ms: u64,
pub attempts: u32,
pub error: Option<String>,
}
#[allow(
clippy::print_stdout,
clippy::print_stderr,
reason = "CLI output to stdout/stderr is required"
)]
pub fn format_result(result: &WaitResult, config: &CliConfig) -> Result<()> {
if config.json {
let json_output = JsonOutput {
success: result.success,
elapsed_ms: u64::try_from(result.elapsed.as_millis().min(u128::from(u64::MAX)))
.unwrap_or(u64::MAX),
total_attempts: result.attempts,
targets: result
.target_results
.iter()
.map(|tr| JsonTargetResult {
target: tr.target.display(),
success: tr.success,
elapsed_ms: u64::try_from(tr.elapsed.as_millis().min(u128::from(u64::MAX)))
.unwrap_or(u64::MAX),
attempts: tr.attempts,
error: tr.error.clone(),
})
.collect(),
};
println!(
"{json_output}",
json_output = serde_json::to_string_pretty(&json_output)?
);
} else if !config.quiet {
if result.success {
if config.wait_config.wait_for_any {
println!("At least one target is ready!");
} else {
println!("All targets are ready!");
}
} else {
eprintln!("Failed to connect to targets");
}
}
Ok(())
}
}
async fn wait_with_progress(config: &CliConfig) -> Result<WaitResult> {
if config.verbose && !config.quiet && !config.json {
let multi_progress = indicatif::MultiProgress::new();
let progress_bars: Result<Vec<_>> = config
.targets
.iter()
.map(|target| -> Result<ProgressBar> {
let pb = multi_progress.add(ProgressBar::new_spinner());
pb.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.green} {msg}")
.map_err(|_| {
CliError::WaitError(WaitForError::InvalidTimeout(
std::borrow::Cow::Borrowed("progress"),
std::borrow::Cow::Borrowed("Invalid progress template"),
))
})?,
);
pb.set_message(format!("Waiting for {target}", target = target.display()));
pb.enable_steady_tick(Duration::from_millis(100));
Ok(pb)
})
.collect();
let progress_bars = progress_bars?;
let result = wait_for_connection(&config.targets, &config.wait_config)
.await
.map_err(CliError::WaitError)?;
for (i, target_result) in result.target_results.iter().enumerate() {
if let Some(pb) = progress_bars.get(i) {
if target_result.success {
pb.finish_with_message(format!(
"✓ {target}",
target = target_result.target.display()
));
} else {
pb.finish_with_message(format!(
"✗ {target} ({error})",
target = target_result.target.display(),
error = target_result.error.as_deref().unwrap_or("failed")
));
}
}
}
Ok(result)
} else {
wait_for_connection(&config.targets, &config.wait_config)
.await
.map_err(CliError::WaitError)
}
}
fn execute_command(command: &[String]) -> Result<()> {
if command.is_empty() {
return Ok(());
}
let mut cmd = Command::new(&command[0]);
if command.len() > 1 {
cmd.args(&command[1..]);
}
let output = cmd
.output()
.map_err(|e| CliError::CommandExecution(e.to_string()))?;
if !output.status.success() {
return Err(CliError::CommandExecution(format!(
"Command exited with code: {:?}",
output.status.code()
)));
}
Ok(())
}
#[allow(
clippy::print_stdout,
clippy::print_stderr,
clippy::if_not_else,
reason = "CLI functions require stdout/stderr output and complex conditional logic"
)]
pub async fn run() -> i32 {
let args = Args::parse();
if let Some(shell) = args.generate_completion {
let mut cmd = Args::command();
let name = cmd.get_name().to_string();
clap_complete::generate(shell, &mut cmd, name, &mut std::io::stdout());
return 0;
}
let config = match CliConfig::from_args(args) {
Ok(config) => config,
Err(e) => {
eprintln!("Error: {e}");
return 2;
}
};
let result = match wait_with_progress(&config).await {
Ok(result) => result,
Err(e) => {
if !config.json {
eprintln!("Error: {e}");
} else {
let json_error = serde_json::json!({
"success": false,
"error": e.to_string()
});
println!("{json_error}");
}
return 1;
}
};
if let Err(e) = output::format_result(&result, &config) {
eprintln!("Output error: {e}");
return 1;
}
if !result.success {
return 1;
}
if let Err(e) = execute_command(&config.command) {
if !config.json {
eprintln!("Command execution error: {e}");
} else {
let json_error = serde_json::json!({
"success": false,
"error": format!("Command execution failed: {e}")
});
println!("{json_error}");
}
return 3;
}
0
}