holdon 0.1.1

Wait for anything. Know why if it doesn't.
Documentation
use std::time::Duration;

use clap::{Parser, ValueEnum};
#[cfg(feature = "http")]
use holdon::checker::http::{HeaderName, HeaderValue, Method};

use crate::output::Format;

#[cfg(feature = "http")]
#[derive(Debug, Clone, Copy, ValueEnum, Default, PartialEq, Eq)]
pub(crate) enum HttpMethod {
    #[default]
    Get,
    Head,
    Post,
    Put,
    Delete,
    Options,
}

#[cfg(feature = "http")]
impl From<HttpMethod> for Method {
    fn from(m: HttpMethod) -> Self {
        match m {
            HttpMethod::Get => Self::GET,
            HttpMethod::Head => Self::HEAD,
            HttpMethod::Post => Self::POST,
            HttpMethod::Put => Self::PUT,
            HttpMethod::Delete => Self::DELETE,
            HttpMethod::Options => Self::OPTIONS,
        }
    }
}

#[cfg(feature = "http")]
const SENSITIVE_HEADER_NAMES: &[&str] = &[
    "authorization",
    "cookie",
    "proxy-authorization",
    "x-api-key",
    "x-auth-token",
    "x-amz-security-token",
];

#[cfg(feature = "http")]
#[derive(Clone)]
pub(crate) struct HeaderPair {
    pub(crate) name: HeaderName,
    pub(crate) value: HeaderValue,
}

#[cfg(feature = "http")]
impl std::fmt::Debug for HeaderPair {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let n = self.name.as_str();
        let redacted = SENSITIVE_HEADER_NAMES
            .iter()
            .any(|s| s.eq_ignore_ascii_case(n));
        if redacted {
            write!(f, "{n}: ***")
        } else {
            write!(f, "{n}: {:?}", self.value)
        }
    }
}

#[cfg(feature = "http")]
fn parse_header_pair(input: &str) -> Result<HeaderPair, String> {
    let (name, value) = holdon::checker::http::parse_header(input)?;
    Ok(HeaderPair { name, value })
}

#[derive(Debug, Clone, Copy, ValueEnum, Default, PartialEq, Eq)]
pub(crate) enum OutputFormat {
    #[default]
    Plain,
    Quiet,
    #[cfg(feature = "json-output")]
    Json,
}

impl From<OutputFormat> for Format {
    fn from(o: OutputFormat) -> Self {
        match o {
            OutputFormat::Plain => Self::Plain,
            OutputFormat::Quiet => Self::Quiet,
            #[cfg(feature = "json-output")]
            OutputFormat::Json => Self::Json,
        }
    }
}

#[derive(Debug, Parser)]
#[command(
    name = "holdon",
    version,
    about = "Wait for anything. Know why if it doesn't.",
    long_about = None,
    after_help = "Examples:\n  holdon :5432\n  holdon :5432 :6379 -- npm run migrate\n  holdon https://api.local/health --timeout 60s",
    arg_required_else_help = true,
)]
#[allow(clippy::struct_excessive_bools)]
pub(crate) struct Args {
    #[arg(value_name = "TARGET")]
    pub(crate) targets: Vec<String>,

    #[arg(long, short = 't', env = "HOLDON_TIMEOUT", value_parser = holdon::parse_duration, default_value = "30s")]
    pub(crate) timeout: Duration,

    #[arg(long, env = "HOLDON_INTERVAL", value_parser = holdon::parse_duration, default_value = "100ms")]
    pub(crate) interval: Duration,

    #[arg(long, env = "HOLDON_MAX_INTERVAL", value_parser = holdon::parse_duration, default_value = "2s")]
    pub(crate) max_interval: Duration,

    #[arg(long, env = "HOLDON_INITIAL_DELAY", value_parser = holdon::parse_duration, default_value = "0s")]
    pub(crate) initial_delay: Duration,

    #[arg(long, env = "HOLDON_ATTEMPT_TIMEOUT", value_parser = holdon::parse_duration, default_value = "5s")]
    pub(crate) attempt_timeout: Duration,

    #[arg(long, short = 'q', env = "HOLDON_QUIET")]
    pub(crate) quiet: bool,

    #[arg(long, value_enum, env = "HOLDON_OUTPUT", default_value_t = OutputFormat::Plain)]
    pub(crate) output: OutputFormat,

    #[arg(long, env = "HOLDON_REVERSE")]
    pub(crate) reverse: bool,

    #[arg(long, env = "HOLDON_ONCE")]
    pub(crate) once: bool,

    #[arg(long, short = 's', env = "HOLDON_STRICT")]
    pub(crate) strict: bool,

    #[arg(long, env = "HOLDON_SEQUENTIAL")]
    pub(crate) sequential: bool,

    #[arg(long, env = "HOLDON_SUCCESS_THRESHOLD",
          default_value_t = holdon::RunnerConfig::DEFAULT_SUCCESS_THRESHOLD)]
    pub(crate) success_threshold: u32,

    #[arg(long, env = "HOLDON_AT_LEAST")]
    pub(crate) at_least: Option<usize>,

    #[arg(long, env = "HOLDON_EXPECT_STATUS", value_parser = parse_status_range)]
    pub(crate) expect_status: Option<(u16, u16)>,

    #[arg(long, env = "HOLDON_NO_JITTER", action = clap::ArgAction::SetTrue)]
    pub(crate) no_jitter: bool,

    #[arg(long, env = "HOLDON_TIMEOUT_EXIT_CODE", default_value_t = crate::DEFAULT_TIMEOUT_EXIT_CODE)]
    pub(crate) timeout_exit_code: u8,

    #[arg(long, action = clap::ArgAction::SetTrue)]
    pub(crate) no_color: bool,

    #[arg(long, short = 'v', action = clap::ArgAction::Count)]
    pub(crate) verbose: u8,

    #[cfg(feature = "http")]
    #[arg(long = "header", short = 'H', value_parser = parse_header_pair,
          help = "Extra HTTP request header `Name: Value` (repeatable)")]
    pub(crate) headers: Vec<HeaderPair>,

    #[cfg(feature = "http")]
    #[arg(long, value_enum, default_value_t = HttpMethod::Get,
          help = "HTTP method for http(s):// probes")]
    pub(crate) method: HttpMethod,

    #[cfg(feature = "http")]
    #[arg(long, help = "Skip TLS certificate verification (dev only)")]
    pub(crate) insecure: bool,

    #[arg(last = true)]
    pub(crate) exec: Vec<String>,
}

fn parse_status_range(s: &str) -> Result<(u16, u16), String> {
    if let Some((lo, hi)) = s.split_once('-') {
        let lo: u16 = lo.parse().map_err(|e| format!("bad lo: {e}"))?;
        let hi: u16 = hi.parse().map_err(|e| format!("bad hi: {e}"))?;
        if hi < lo {
            return Err(format!("hi {hi} < lo {lo}"));
        }
        Ok((lo, hi))
    } else {
        let n: u16 = s.parse().map_err(|e| format!("bad status: {e}"))?;
        Ok((n, n))
    }
}