xbp 10.28.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
use colored::Colorize;
use serde_json::Value;
use std::time::Duration;
use tokio::time::{sleep, timeout};
use tracing::debug;

fn is_localhost_like(raw: &str) -> bool {
    let authority = raw.split('/').next().unwrap_or(raw);
    if authority.starts_with('[') {
        if let Some(end_bracket) = authority.find(']') {
            let host = authority[1..end_bracket].to_ascii_lowercase();
            return host == "::1";
        }
    }

    let host = authority
        .split(':')
        .next()
        .unwrap_or(authority)
        .to_ascii_lowercase();
    host == "localhost" || host == "127.0.0.1" || host == "::1"
}

fn has_scheme(raw: &str) -> bool {
    raw.starts_with("https://") || raw.starts_with("http://")
}

fn display_url(raw: &str, canonical: &str) -> String {
    let raw = raw.trim();
    if !has_scheme(raw) && is_localhost_like(raw) {
        raw.to_string()
    } else {
        canonical.to_string()
    }
}

fn canonicalize_url(raw: &str) -> String {
    let raw = raw.trim();
    if raw.starts_with("https://https://") {
        raw["https://".len()..].to_string()
    } else if has_scheme(raw) {
        raw.to_string()
    } else if is_localhost_like(raw) {
        format!("http://{}", raw)
    } else {
        format!("https://{}", raw)
    }
}

fn render_colored_json(value: &Value, indent: usize) -> String {
    match value {
        Value::Object(map) => {
            if map.is_empty() {
                return "{}".to_string();
            }
            let current_pad = " ".repeat(indent);
            let nested_pad = " ".repeat(indent + 2);
            let mut out = String::from("{\n");
            let total = map.len();
            for (index, (key, val)) in map.iter().enumerate() {
                let key_json =
                    serde_json::to_string(key).unwrap_or_else(|_| format!("\"{}\"", key));
                out.push_str(&nested_pad);
                out.push_str(&key_json.bright_cyan().bold().to_string());
                out.push_str(": ");
                out.push_str(&render_colored_json(val, indent + 2));
                if index + 1 < total {
                    out.push(',');
                }
                out.push('\n');
            }
            out.push_str(&current_pad);
            out.push('}');
            out
        }
        Value::Array(items) => {
            if items.is_empty() {
                return "[]".to_string();
            }
            let current_pad = " ".repeat(indent);
            let nested_pad = " ".repeat(indent + 2);
            let mut out = String::from("[\n");
            let total = items.len();
            for (index, item) in items.iter().enumerate() {
                out.push_str(&nested_pad);
                out.push_str(&render_colored_json(item, indent + 2));
                if index + 1 < total {
                    out.push(',');
                }
                out.push('\n');
            }
            out.push_str(&current_pad);
            out.push(']');
            out
        }
        Value::String(text) => serde_json::to_string(text)
            .unwrap_or_else(|_| format!("\"{}\"", text))
            .bright_green()
            .to_string(),
        Value::Number(number) => number.to_string().bright_yellow().to_string(),
        Value::Bool(boolean) => boolean.to_string().bright_magenta().to_string(),
        Value::Null => "null".bright_black().to_string(),
    }
}

fn colorize_status(status: reqwest::StatusCode) -> String {
    if status.is_success() {
        status.to_string().bright_green().bold().to_string()
    } else if status.is_redirection() {
        status.to_string().bright_yellow().bold().to_string()
    } else if status.is_client_error() || status.is_server_error() {
        status.to_string().bright_red().bold().to_string()
    } else {
        status.to_string().bright_white().to_string()
    }
}

pub async fn run_curl(raw_url: &str, no_timeout: bool, debug: bool) -> Result<(), String> {
    let url = canonicalize_url(raw_url);
    let shown_url = display_url(raw_url, &url);
    let client = reqwest::Client::builder()
        .redirect(reqwest::redirect::Policy::limited(10))
        .build()
        .map_err(|e| format!("failed to build HTTP client: {}", e))?;

    if debug {
        debug!("Requesting {}", shown_url);
    }

    let progress = tokio::spawn(async {
        sleep(Duration::from_secs(5)).await;
        eprintln!("Taking longer than expected to respond...");
    });

    let request = async {
        let response = client
            .get(&url)
            .send()
            .await
            .map_err(|e| format!("request failed: {}", e.without_url()))?;

        let status = response.status();
        let headers = response.headers().clone();
        let body = response
            .text()
            .await
            .map_err(|e| format!("failed to read response body: {}", e))?;

        Ok::<_, String>((status, headers, body))
    };

    let result = if no_timeout {
        request.await
    } else {
        timeout(Duration::from_secs(15), request)
            .await
            .map_err(|_| {
                format!(
                    "Request to {} timed out after 15 seconds. Use --no-timeout to wait longer.",
                    shown_url
                )
            })?
    };

    progress.abort();

    let (status, headers, body) = result?;

    println!(
        "{}",
        format!("=== Curl to {}", shown_url).bright_cyan().bold()
    );
    println!(
        "{} {}",
        "--- Status ---".bright_blue().bold(),
        colorize_status(status)
    );
    println!("{}", "--- Headers ---".bright_blue().bold());
    for (name, value) in &headers {
        let header_name = name.as_str().bright_yellow().bold();
        let header_value = value
            .to_str()
            .map(|text| text.bright_white().to_string())
            .unwrap_or_else(|_| "<binary>".bright_black().to_string());
        println!("{}: {}", header_name, header_value);
    }
    println!("{}", "--- Body ---".bright_blue().bold());

    if let Ok(json) = serde_json::from_str::<Value>(&body) {
        println!("{}", render_colored_json(&json, 0));
    } else if body.is_empty() {
        println!("{}", "<empty>".bright_black());
    } else {
        println!("{}", body.bright_white());
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::{canonicalize_url, display_url};

    #[test]
    fn canonicalizes_plain_domains() {
        assert_eq!(canonicalize_url("example.com"), "https://example.com");
    }

    #[test]
    fn deduplicates_https_prefix() {
        assert_eq!(
            canonicalize_url("https://https://example.com"),
            "https://example.com"
        );
    }

    #[test]
    fn canonicalizes_localhost_with_http() {
        assert_eq!(canonicalize_url("localhost:8080"), "http://localhost:8080");
    }

    #[test]
    fn canonicalizes_loopback_with_http() {
        assert_eq!(canonicalize_url("127.0.0.1:3000"), "http://127.0.0.1:3000");
    }

    #[test]
    fn keeps_localhost_display_without_scheme() {
        let canonical = canonicalize_url("localhost:8080");
        assert_eq!(display_url("localhost:8080", &canonical), "localhost:8080");
    }
}