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(¤t_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(¤t_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");
}
}