iskra 0.4.0

A safe, modern, Rust-native data transfer tool.
Documentation
use crate::util::{color_green, color_red, color_yellow};
use std::io::BufRead;
use crate::cli_args::*;
use crate::{IskraClient, IskraError};
use crate::util::clamp_timeout;

/// Run the Iskra CLI.
pub async fn run_cli() -> Result<(), IskraError> {
    // Show help if --help is present or no subcommand is given
    let args: Vec<_> = std::env::args().collect();
    if args.iter().any(|a| a == "--help" || a == "-h") || args.len() == 1 {
        crate::cli_args::print_iskra_help();
        return Ok(());
    }
    let cli = match parse_iskra_cli_args(std::env::args_os()) {
        Ok(cli) => cli,
        Err(e) => {
            eprintln!("\x1b[1;31mIskra CLI parse error:\x1b[0m {e}\n");
            crate::cli_args::print_iskra_help();
            return Err(IskraError::Other(format!("CLI parse error: {e}")));
        }
    };
    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))?
    };
    let verbose = cli.verbose;
    let quiet = cli.quiet;
    let fail_on_error = cli.fail;
    return match &cli.subcommand {
        IskraSubcommand::Burst { input, output_dir, concurrency, throttle, retries, backoff, summary } => {
            use std::fs::File;
            use std::io::{self, BufReader};
            use crate::burst::{BurstRequest, BurstOptions, BurstEngine};
            // Read input (file or stdin)
            let reader: Box<dyn BufRead> = if input == "-" {
                Box::new(BufReader::new(io::stdin()))
            } else {
                Box::new(BufReader::new(File::open(input).map_err(|e| IskraError::Other(e.to_string()))?))
            };
            let requests = BurstRequest::parse_batch(reader)
                .map_err(|e| IskraError::Other(format!("Burst input parse error: {e}")))?;
            if requests.is_empty() {
                return Err(IskraError::Other("No burst requests found in input".to_string()));
            }
            let options = BurstOptions {
                concurrency: *concurrency,
                throttle_per_sec: *throttle,
                retries: *retries,
                backoff: if *backoff > 0 { Some(std::time::Duration::from_secs(*backoff)) } else { None },
                summary: *summary,
                timeout: Some(std::time::Duration::from_secs(timeout)),
                output_dir: output_dir.clone(),
            };
            let engine = BurstEngine::new_with_timeout(requests, options, std::time::Duration::from_secs(cli.timeout));
            let results = engine.run_with_verbosity(verbose, quiet).await;
            // Print a simple completion message if not using summary, not quiet, and not verbose
            if !summary && !quiet && !verbose {
                let total = results.len();
                let successes = results.iter().filter(|r| r.success).count();
                let failures = total - successes;
                println!("Burst complete: {} total | {} success | {} fail", total, successes, failures);
            }
            // If --fail is set and any request failed, return error
            if fail_on_error && results.iter().any(|r| !r.success) {
                return Err(IskraError::Other("One or more burst requests failed".to_string()));
            }
            Ok(())
        },
        IskraSubcommand::Get { url, header, query, output, range, resume } => {
            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 url.starts_with("file://") {
                // Native file:// support
                let path = &url[7..];
                use std::fs;
                match fs::read_to_string(path) {
                    Ok(contents) => {
                        if let Some(out_path) = output {
                            fs::write(&out_path, &contents).map_err(|e| IskraError::Io { source: e, path: out_path.clone() })?;
                            if !quiet { println!("Downloaded {} bytes to {}", contents.len(), out_path); }
                        } else {
                            if !quiet { println!("{}", contents); }
                        }
                        Ok(())
                    }
                    Err(e) => Err(IskraError::Io { source: e, path: path.to_string() })
                }
            } else if url.starts_with("http://") || url.starts_with("https://") {
                if let Some(path) = output {
                    let downloaded = client.download_with_range("GET", url, None, &headers, &queries, std::path::Path::new(&path), range.clone(), *resume, None).await?;
                    if !quiet { println!("Downloaded {downloaded} bytes to {path}"); }
                } else {
                    let resp = client.get(url, &headers, &queries).await?;
                    let status = resp.status();
                    let status_str = if status.is_success() {
                        color_green(&status.to_string())
                    } else if status.as_u16() == 408 {
                        color_yellow(&status.to_string())
                    } else {
                        color_red(&status.to_string())
                    };
                    if (cli.show_headers || !header.is_empty()) && !quiet {
                        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() })?;
                    if !quiet { println!("Status: {}\n{}", status_str, body); }
                    if fail_on_error && !status.is_success() {
                        return Err(IskraError::Status { status: status.as_u16(), url: url.clone() });
                    }
                }
                Ok(())
            } else {
                Err(IskraError::Config { msg: format!("Unsupported URL scheme for get: {}", url) })
            }
        },
        IskraSubcommand::Post { url, body, header, query, output, range, resume } => {
            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.clone(), *resume, None).await?;
                if !quiet { println!("Downloaded {downloaded} bytes to {path}"); }
            } else {
                let resp = client.post(url, body, &headers, &queries).await?;
                let status = resp.status();
                let status_str = if status.is_success() {
                    color_green(&status.to_string())
                } else if status.as_u16() == 408 {
                    color_yellow(&status.to_string())
                } else {
                    color_red(&status.to_string())
                };
                if (cli.show_headers || !header.is_empty()) && !quiet {
                    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() })?;
                if !quiet { println!("Status: {}\n{}", status_str, body); }
                if fail_on_error && !status.is_success() {
                    return Err(IskraError::Status { status: status.as_u16(), url: url.clone() });
                }
            }
            Ok(())
        },
        IskraSubcommand::Put { url, body, header, query, output, range, resume } => {
            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.clone(), *resume, None).await?;
                if !quiet { println!("Downloaded {downloaded} bytes to {path}"); }
            } else {
                let resp = client.put(url, body, &headers, &queries).await?;
                let status = resp.status();
                let status_str = if status.is_success() {
                    color_green(&status.to_string())
                } else if status.as_u16() == 408 {
                    color_yellow(&status.to_string())
                } else {
                    color_red(&status.to_string())
                };
                if (cli.show_headers || !header.is_empty()) && !quiet {
                    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() })?;
                if !quiet { println!("Status: {}\n{}", status_str, body); }
                if fail_on_error && !status.is_success() {
                    return Err(IskraError::Status { status: status.as_u16(), url: url.clone() });
                }
            }
            Ok(())
        },
        IskraSubcommand::Delete { url, header, query, output, range, resume } => {
            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.clone(), *resume, None).await?;
                if !quiet { println!("Downloaded {downloaded} bytes to {path}"); }
            } else {
                let resp = client.delete(url, &headers, &queries).await?;
                let status = resp.status();
                let status_str = if status.is_success() {
                    color_green(&status.to_string())
                } else if status.as_u16() == 408 {
                    color_yellow(&status.to_string())
                } else {
                    color_red(&status.to_string())
                };
                if (cli.show_headers || !header.is_empty()) && !quiet {
                    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() })?;
                if !quiet { println!("Status: {}\n{}", status_str, body); }
                if fail_on_error && !status.is_success() {
                    return Err(IskraError::Status { status: status.as_u16(), url: url.clone() });
                }
            }
            Ok(())
        },
        IskraSubcommand::Custom { method, url, header, query, output, body, trailing_body, range, resume } => {
            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();
            let body_str: Option<String> = if let Some(b) = body {
                Some(b.clone())
            } 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.clone(), *resume, None).await?;
                if !quiet { println!("Downloaded {downloaded} bytes to {path}"); }
            } else {
                let resp = client.request(method, url, body_str.as_deref(), &headers, &queries).await?;
                let status = resp.status();
                let status_str = if status.is_success() {
                    color_green(&status.to_string())
                } else if status.as_u16() == 408 {
                    color_yellow(&status.to_string())
                } else {
                    color_red(&status.to_string())
                };
                if (cli.show_headers || !header.is_empty()) && !quiet {
                    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() })?;
                if !quiet { println!("Status: {}\n{}", status_str, body_text); }
                if fail_on_error && !status.is_success() {
                    return Err(IskraError::Status { status: status.as_u16(), url: url.clone() });
                }
            }
            Ok(())
        }
    }
    }