iskra 0.2.2

A safe, modern, Rust-native data transfer tool.
Documentation
use clap::{Parser, Subcommand};
use crate::{IskraClient, IskraError};
use crate::util::clamp_timeout;

#[derive(Subcommand, Debug)]
pub enum Commands {
    /// HTTP request with arbitrary method
    #[command(trailing_var_arg = true, about = "HTTP request with arbitrary method (e.g. PATCH, OPTIONS, etc.)")]
    Custom {
        /// HTTP method (e.g. PATCH, OPTIONS)
        #[arg(value_parser = validate_method)]
        method: String,
        /// Target URL
        #[arg(value_parser = validate_url)]
        url: String,
        /// Custom headers (key:value)
        #[arg(short, long, value_parser = parse_header, num_args = 0.., value_name = "key:value", help = "Custom headers to include")] 
        header: Vec<(String, String)>,
        /// Query parameters (key=value)
        #[arg(short = 'q', long, value_parser = parse_query, num_args = 0.., value_name = "key=value", help = "Query parameters to include")]
        query: Vec<(String, String)>,
        /// Output file path
        #[arg(short, long, value_name = "file", value_hint = clap::ValueHint::FilePath, help = "Write response body to file")]
        output: Option<String>,
        /// Download a byte range (e.g. 1000-2000 or 1000-)
        #[arg(long, value_name = "start-end", value_parser = parse_range, help = "Download a byte range")]
        range: Option<(u64, Option<u64>)>,
        /// Resume download if file exists (appends using HTTP Range)
        #[arg(long, help = "Resume download if file exists")]
        resume: bool,
        /// Request body (as trailing argument or --body)
        #[arg(long, value_name = "body", group = "body_input", help = "Request body (alternative to trailing argument)")]
        body: Option<String>,
        /// Trailing argument for request body (mutually exclusive with --body)
        #[arg(value_name = "body", group = "body_input", required = false, help = "Request body as trailing argument")]
        trailing_body: Vec<String>,
        /// Exit with non-zero code if response is not 2xx
        #[arg(long, help = "Exit with non-zero code if response is not 2xx")]
        fail: bool,
    },
    /// HTTP GET request
    Get {
        url: String,
        #[arg(short, long, value_parser = parse_header, num_args = 0.., value_name = "key:value")]
        header: Vec<(String, String)>,
        #[arg(short = 'q', long, value_parser = parse_query, num_args = 0.., value_name = "key=value")]
        query: Vec<(String, String)>,
    #[arg(short, long, value_name = "file")]
    output: Option<String>,
    #[arg(long, value_name = "start-end", value_parser = parse_range)]
    range: Option<(u64, Option<u64>)>,
    #[arg(long, help = "Resume download if file exists")]
    resume: bool,
        #[arg(long, help = "Exit with non-zero code if response is not 2xx")]
        fail: bool,
    },
    /// HTTP POST request
    Post {
        url: String,
        body: String,
        #[arg(short, long, value_parser = parse_header, num_args = 0.., value_name = "key:value")]
        header: Vec<(String, String)>,
        #[arg(short = 'q', long, value_parser = parse_query, num_args = 0.., value_name = "key=value")]
        query: Vec<(String, String)>,
    #[arg(short, long, value_name = "file")]
    output: Option<String>,
    #[arg(long, value_name = "start-end", value_parser = parse_range)]
    range: Option<(u64, Option<u64>)>,
    #[arg(long, help = "Resume download if file exists")]
    resume: bool,
        #[arg(long, help = "Exit with non-zero code if response is not 2xx")]
        fail: bool,
    },
    /// HTTP PUT request
    Put {
        url: String,
        body: String,
        #[arg(short, long, value_parser = parse_header, num_args = 0.., value_name = "key:value")]
        header: Vec<(String, String)>,
        #[arg(short = 'q', long, value_parser = parse_query, num_args = 0.., value_name = "key=value")]
        query: Vec<(String, String)>,
    #[arg(short, long, value_name = "file")]
    output: Option<String>,
    #[arg(long, value_name = "start-end", value_parser = parse_range)]
    range: Option<(u64, Option<u64>)>,
    #[arg(long, help = "Resume download if file exists")]
    resume: bool,
        #[arg(long, help = "Exit with non-zero code if response is not 2xx")]
        fail: bool,
    },
    /// HTTP DELETE request
    Delete {
        url: String,
        #[arg(short, long, value_parser = parse_header, num_args = 0.., value_name = "key:value")]
        header: Vec<(String, String)>,
        #[arg(short = 'q', long, value_parser = parse_query, num_args = 0.., value_name = "key=value")]
        query: Vec<(String, String)>,
    #[arg(short, long, value_name = "file")]
    output: Option<String>,
    #[arg(long, value_name = "start-end", value_parser = parse_range)]
    range: Option<(u64, Option<u64>)>,
    #[arg(long, help = "Resume download if file exists")]
    resume: bool,
        #[arg(long, help = "Exit with non-zero code if response is not 2xx")]
        fail: bool,
    }
}

#[derive(Parser, Debug)]
#[command(name = "iskra")]
#[command(about = "Iskra: Secure, modern, Rust-native data transfer tool", long_about = None)]
pub struct Cli {
    /// Timeout in seconds (default: 30)
    #[arg(long, value_name = "seconds", default_value_t = 30)]
    pub timeout: u64,
    /// Disable automatic response decompression (gzip, deflate, brotli)
    #[arg(long, help = "Disable automatic response decompression (gzip, deflate, brotli)")]
    pub no_decompress: bool,
    #[command(subcommand)]
    pub command: Commands,
}
/// Run the Iskra CLI.
pub async fn run_cli() -> Result<(), IskraError> {
    let cli = Cli::parse();
    let timeout = clamp_timeout(cli.timeout);
    let client = if cli.no_decompress {
        IskraClient::new_with_timeout_and_decompression(std::time::Duration::from_secs(timeout), false)?
    } else {
        IskraClient::new_with_timeout(std::time::Duration::from_secs(timeout))?
    };
    match cli.command {
        Commands::Get { url, header, query, output, range, resume, fail } => {
            let headers: Vec<(&str, &str)> = header.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
            let queries: Vec<(&str, &str)> = query.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
            if let Some(path) = output {
                let downloaded = client.download_with_range("GET", &url, None, &headers, &queries, std::path::Path::new(&path), range, resume, None).await?;
                println!("Downloaded {downloaded} bytes to {path}");
            } else {
                let resp = client.get(&url, &headers, &queries).await?;
                let status = resp.status();
                if !header.is_empty() {
                    println!("Headers:");
                    for (k, v) in resp.headers().iter() {
                        println!("{}: {}", k, v.to_str().unwrap_or("<binary>"));
                    }
                }
                let body = resp.text().await.map_err(|e| IskraError::Http { source: e, url: url.clone() })?;
                println!("Status: {}\n{}", status, body);
                if fail && !status.is_success() {
                    eprintln!("Request failed with status: {}", status);
                    std::process::exit(1);
                }
            }
        }
        Commands::Post { url, body, header, query, output, range, resume, fail } => {
            let headers: Vec<(&str, &str)> = header.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
            let queries: Vec<(&str, &str)> = query.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
            if let Some(path) = output {
                let downloaded = client.download_with_range("POST", &url, Some(&body), &headers, &queries, std::path::Path::new(&path), range, resume, None).await?;
                println!("Downloaded {downloaded} bytes to {path}");
            } else {
                let resp = client.post(&url, &body, &headers, &queries).await?;
                let status = resp.status();
                if !header.is_empty() {
                    println!("Headers:");
                    for (k, v) in resp.headers().iter() {
                        println!("{}: {}", k, v.to_str().unwrap_or("<binary>"));
                    }
                }
                let body = resp.text().await.map_err(|e| IskraError::Http { source: e, url: url.clone() })?;
                println!("Status: {}\n{}", status, body);
                if fail && !status.is_success() {
                    eprintln!("Request failed with status: {}", status);
                    std::process::exit(1);
                }
            }
        }
        Commands::Put { url, body, header, query, output, range, resume, fail } => {
            let headers: Vec<(&str, &str)> = header.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
            let queries: Vec<(&str, &str)> = query.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
            if let Some(path) = output {
                let downloaded = client.download_with_range("PUT", &url, Some(&body), &headers, &queries, std::path::Path::new(&path), range, resume, None).await?;
                println!("Downloaded {downloaded} bytes to {path}");
            } else {
                let resp = client.put(&url, &body, &headers, &queries).await?;
                let status = resp.status();
                if !header.is_empty() {
                    println!("Headers:");
                    for (k, v) in resp.headers().iter() {
                        println!("{}: {}", k, v.to_str().unwrap_or("<binary>"));
                    }
                }
                let body = resp.text().await.map_err(|e| IskraError::Http { source: e, url: url.clone() })?;
                println!("Status: {}\n{}", status, body);
                if fail && !status.is_success() {
                    eprintln!("Request failed with status: {}", status);
                    std::process::exit(1);
                }
            }
        }
        Commands::Delete { url, header, query, output, range, resume, fail } => {
            let headers: Vec<(&str, &str)> = header.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
            let queries: Vec<(&str, &str)> = query.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
            if let Some(path) = output {
                let downloaded = client.download_with_range("DELETE", &url, None, &headers, &queries, std::path::Path::new(&path), range, resume, None).await?;
                println!("Downloaded {downloaded} bytes to {path}");
            } else {
                let resp = client.delete(&url, &headers, &queries).await?;
                let status = resp.status();
                if !header.is_empty() {
                    println!("Headers:");
                    for (k, v) in resp.headers().iter() {
                        println!("{}: {}", k, v.to_str().unwrap_or("<binary>"));
                    }
                }
                let body = resp.text().await.map_err(|e| IskraError::Http { source: e, url: url.clone() })?;
                println!("Status: {}\n{}", status, body);
                if fail && !status.is_success() {
                    eprintln!("Request failed with status: {}", status);
                    std::process::exit(1);
                }
            }
        }
        Commands::Custom { method, url, header, query, output, body, trailing_body, range, resume, fail } => {
            let headers: Vec<(&str, &str)> = header.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
            let queries: Vec<(&str, &str)> = query.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
            // Precedence: --body > trailing_body > None
            let body_str = if let Some(b) = body {
                Some(b)
            } else if !trailing_body.is_empty() {
                Some(trailing_body.join(" "))
            } else {
                None
            };
            if let Some(path) = output {
                let downloaded = client.download_with_range(&method, &url, body_str.as_deref(), &headers, &queries, std::path::Path::new(&path), range, resume, None).await?;
                println!("Downloaded {downloaded} bytes to {path}");
            } else {
                let resp = client.request(&method, &url, body_str.as_deref(), &headers, &queries).await?;
                let status = resp.status();
                if !header.is_empty() {
                    println!("Headers:");
                    for (k, v) in resp.headers().iter() {
                        println!("{}: {}", k, v.to_str().unwrap_or("<binary>"));
                    }
                }
                let body_text = resp.text().await.map_err(|e| IskraError::Http { source: e, url: url.clone() })?;
                println!("Status: {}\n{}", status, body_text);
                if fail && !status.is_success() {
                    eprintln!("Request failed with status: {}", status);
                    std::process::exit(1);
                }
            }
        }
    }
    Ok(())
}

pub fn validate_method(s: &str) -> Result<String, String> {
    let allowed = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD", "TRACE", "CONNECT"];
    let upper = s.to_uppercase();
    if allowed.contains(&upper.as_str()) {
        Ok(upper)
    } else {
        Err(format!(
            "Invalid HTTP method: '{}'. Allowed methods: {}",
            s,
            allowed.join(", ")
        ))
    }
}

fn validate_url(s: &str) -> Result<String, String> {
    if s.starts_with("http://") || s.starts_with("https://") {
        Ok(s.to_string())
    } else {
        Err("URL must start with http:// or https://".to_string())
    }
}

fn parse_range(s: &str) -> Result<(u64, Option<u64>), String> {
    let parts: Vec<&str> = s.split('-').collect();
    if parts.len() != 2 {
        return Err("Range must be in start-end format (end optional)".to_string());
    }
    let start = parts[0].trim().parse::<u64>().map_err(|_| "Invalid start of range".to_string())?;
    let end = if parts[1].trim().is_empty() {
        None
    } else {
        Some(parts[1].trim().parse::<u64>().map_err(|_| "Invalid end of range".to_string())?)
    };
    Ok((start, end))
}

fn parse_header(s: &str) -> Result<(String, String), String> {
    let parts: Vec<&str> = s.splitn(2, ':').collect();
    if parts.len() != 2 {
        return Err("Header must be in key:value format".to_string());
    }
    Ok((parts[0].trim().to_string(), parts[1].trim().to_string()))
}

fn parse_query(s: &str) -> Result<(String, String), String> {
    let parts: Vec<&str> = s.splitn(2, '=').collect();
    if parts.len() != 2 {
        return Err("Query must be in key=value format".to_string());
    }
    Ok((parts[0].trim().to_string(), parts[1].trim().to_string()))
}