#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatsMode {
Always,
OnChange,
}
use std::ffi::OsString;
use crate::error::error::IskraError;
#[derive(Debug, Clone)]
pub struct IskraCli {
pub timeout: u64,
pub no_decompress: bool,
pub fail: bool,
pub show_headers: bool,
pub verbose_errors: bool,
pub verbose: bool,
pub quiet: bool,
pub subcommand: IskraSubcommand,
}
#[derive(Debug, Clone)]
pub enum IskraSubcommand {
Get {
url: String,
header: Vec<(String, String)>,
query: Vec<(String, String)>,
output: Option<String>,
range: Option<(u64, Option<u64>)>,
resume: bool,
},
Head {
url: String,
header: Vec<(String, String)>,
query: Vec<(String, String)>,
compare_file: Option<String>,
save_headers: Option<String>,
},
Trace {
url: String,
header: Vec<(String, String)>,
query: Vec<(String, String)>,
},
Connect {
url: String,
header: Vec<(String, String)>,
query: Vec<(String, String)>,
tunnel: bool,
stats: Option<StatsMode>,
hexdump: bool,
log: Option<String>,
mask_host: bool,
prompt: bool,
tunnel_timeout: Option<u64>,
keepalive: bool,
banner: bool,
protocol_detect: bool,
},
Post {
url: String,
body: String,
header: Vec<(String, String)>,
query: Vec<(String, String)>,
output: Option<String>,
range: Option<(u64, Option<u64>)>,
resume: bool,
},
Put {
url: String,
body: String,
header: Vec<(String, String)>,
query: Vec<(String, String)>,
output: Option<String>,
range: Option<(u64, Option<u64>)>,
resume: bool,
},
Delete {
url: String,
header: Vec<(String, String)>,
query: Vec<(String, String)>,
output: Option<String>,
range: Option<(u64, Option<u64>)>,
resume: bool,
},
Custom {
method: String,
url: String,
header: Vec<(String, String)>,
query: Vec<(String, String)>,
output: Option<String>,
body: Option<String>,
trailing_body: Vec<String>,
range: Option<(u64, Option<u64>)>,
resume: bool,
},
Burst {
input: String,
output_dir: Option<String>,
concurrency: usize,
throttle: Option<usize>,
retries: usize,
backoff: u64,
summary: bool,
},
}
pub fn parse_iskra_cli_args<I, T>(args: I) -> Result<IskraCli, IskraError>
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
let timeout = 30u64;
let no_decompress = false;
let fail = false;
let show_headers = false;
let verbose_errors = false;
let verbose = false;
let quiet = false;
let mut args_vec: Vec<String> = args.into_iter().map(|a| a.into().to_string_lossy().to_string()).collect();
if !args_vec.is_empty() {
args_vec.remove(0);
}
if args_vec.is_empty() {
print_iskra_help();
std::process::exit(0);
}
let mut subcmd = None;
let mut subcmd_args = Vec::new();
let mut i = 0;
while i < args_vec.len() {
let arg = &args_vec[i];
match arg.as_str() {
"connect" => {
subcmd = Some(arg.clone());
subcmd_args = args_vec[i + 1..].to_vec();
break;
}
"get" | "head" | "trace" | "post" | "put" | "delete" | "custom" | "burst" => {
subcmd = Some(arg.clone());
subcmd_args = args_vec[i + 1..].to_vec();
break;
}
s if s.starts_with("http://") || s.starts_with("https://") || s.starts_with("file://") => {
subcmd = Some("get".to_string());
subcmd_args = args_vec[i..].to_vec();
break;
}
_ => {}
}
i += 1;
}
let subcmd = subcmd.ok_or_else(|| IskraError::Config { msg: "Missing subcommand or URL. See --help.".to_string() })?;
let subcommand = match subcmd.as_str() {
"connect" => {
let mut url = None;
let header = parse_kv_pairs(&subcmd_args, "--header");
let query = parse_query_pairs(&subcmd_args, "--query");
let mut tunnel = false;
let mut stats: Option<StatsMode> = None;
let mut hexdump = false;
let mut log = None;
let mut mask_host = false;
let mut prompt = false;
let mut tunnel_timeout = None;
let mut keepalive = false;
let mut banner = false;
let mut protocol_detect = false;
let mut i = 0;
while i < subcmd_args.len() {
match subcmd_args[i].as_str() {
s if s.starts_with("http://") || s.starts_with("https://") => url = Some(s.to_string()),
"--header" | "--query" => i += 1,
"--tunnel" => tunnel = true,
s if s.starts_with("--stats") => {
if let Some(eq_idx) = s.find('=') {
let val = &s[eq_idx+1..];
stats = match val.to_ascii_lowercase().as_str() {
"always" => Some(StatsMode::Always),
"onchange" => Some(StatsMode::OnChange),
_ => Some(StatsMode::OnChange),
};
} else {
stats = Some(StatsMode::OnChange);
}
},
"--hexdump" => hexdump = true,
"--mask-host" => mask_host = true,
"--log" => {
if let Some(val) = subcmd_args.get(i + 1) {
log = Some(val.clone());
i += 1;
}
},
"--prompt" => prompt = true,
"--tunnel-timeout" => {
if let Some(val) = subcmd_args.get(i + 1) {
tunnel_timeout = val.parse().ok();
i += 1;
}
},
"--keepalive" => keepalive = true,
"--banner" => banner = true,
"--protocol-detect" => protocol_detect = true,
_ => {}
}
i += 1;
}
IskraSubcommand::Connect {
url: url.ok_or_else(|| IskraError::Config { msg: "Missing URL for connect command. Usage: iskra connect <host:port> [options]".to_string() })?,
header,
query,
tunnel,
stats,
hexdump,
log,
mask_host,
prompt,
tunnel_timeout,
keepalive,
banner,
protocol_detect,
}
},
"trace" => {
let mut url = None;
let header = parse_kv_pairs(&subcmd_args, "--header");
let query = parse_query_pairs(&subcmd_args, "--query");
let mut i = 0;
while i < subcmd_args.len() {
match subcmd_args[i].as_str() {
s if s.starts_with("http://") || s.starts_with("https://") || s.starts_with("file://") => url = Some(s.to_string()),
"--header" | "--query" => i += 1,
_ => {}
}
i += 1;
}
IskraSubcommand::Trace {
url: url.ok_or_else(|| IskraError::Config { msg: "Missing URL for trace command. Usage: iskra trace <url> [options]".to_string() })?,
header,
query,
}
}
"get" => {
let mut url = None;
let header = parse_kv_pairs(&subcmd_args, "--header");
let query = parse_query_pairs(&subcmd_args, "--query");
let mut output = None;
let mut range = None;
let mut resume = false;
let mut i = 0;
while i < subcmd_args.len() {
match subcmd_args[i].as_str() {
s if s.starts_with("http://") || s.starts_with("https://") || s.starts_with("file://") => url = Some(s.to_string()),
"--header" | "--query" => i += 1, "--output" => {
if let Some(val) = subcmd_args.get(i + 1) {
output = Some(val.clone());
i += 1;
}
}
"--range" => {
if let Some(r) = subcmd_args.get(i + 1) {
let parts: Vec<&str> = r.split('-').collect();
let start = parts.get(0).and_then(|s| s.trim().parse::<u64>().ok());
let end = if let Some(e) = parts.get(1) { if !e.trim().is_empty() { e.trim().parse::<u64>().ok() } else { None } } else { None };
if let Some(start) = start {
range = Some((start, end));
}
i += 1;
}
}
"--resume" => resume = true,
_ => {}
}
i += 1;
}
IskraSubcommand::Get {
url: url.ok_or_else(|| IskraError::Config { msg: "Missing URL for get command. Usage: iskra get <url> [options]".to_string() })?,
header,
query,
output,
range,
resume,
}
}
"head" => {
let mut url = None;
let header = parse_kv_pairs(&subcmd_args, "--header");
let query = parse_query_pairs(&subcmd_args, "--query");
let mut compare_file = None;
let mut save_headers = None;
let mut i = 0;
while i < subcmd_args.len() {
match subcmd_args[i].as_str() {
s if s.starts_with("http://") || s.starts_with("https://") || s.starts_with("file://") => url = Some(s.to_string()),
"--header" | "--query" => i += 1,
"--compare" => {
if let Some(val) = subcmd_args.get(i + 1) {
compare_file = Some(val.clone());
i += 1;
}
},
"--save-headers" => {
if let Some(val) = subcmd_args.get(i + 1) {
save_headers = Some(val.clone());
i += 1;
}
},
_ => {}
}
i += 1;
}
IskraSubcommand::Head {
url: url.ok_or_else(|| IskraError::Config { msg: "Missing URL for head command. Usage: iskra head <url> [options]".to_string() })?,
header,
query,
compare_file,
save_headers,
}
}
"post" => {
let mut url = None;
let mut body = None;
let header = parse_kv_pairs(&subcmd_args, "--header");
let query = parse_query_pairs(&subcmd_args, "--query");
let mut output = None;
let mut range = None;
let mut resume = false;
let mut i = 0;
while i < subcmd_args.len() {
match subcmd_args[i].as_str() {
s if s.starts_with("http://") || s.starts_with("https://") || s.starts_with("file://") => url = Some(s.to_string()),
s if url.is_some() && body.is_none() && !s.starts_with('-') => body = Some(s.to_string()),
"--header" | "--query" => i += 1,
"--output" => {
if let Some(val) = subcmd_args.get(i + 1) {
output = Some(val.clone());
i += 1;
}
}
"--range" => {
if let Some(r) = subcmd_args.get(i + 1) {
let parts: Vec<&str> = r.split('-').collect();
let start = parts.get(0).and_then(|s| s.trim().parse::<u64>().ok());
let end = if let Some(e) = parts.get(1) { if !e.trim().is_empty() { e.trim().parse::<u64>().ok() } else { None } } else { None };
if let Some(start) = start {
range = Some((start, end));
}
i += 1;
}
}
"--resume" => resume = true,
_ => {}
}
i += 1;
}
IskraSubcommand::Post {
url: url.ok_or_else(|| IskraError::Config { msg: "Missing URL for post command. Usage: iskra post <url> <body> [options]".to_string() })?,
body: body.ok_or_else(|| IskraError::Config { msg: "Missing body for post command. Usage: iskra post <url> <body> [options]".to_string() })?,
header,
query,
output,
range,
resume,
}
}
"put" => {
let mut url = None;
let mut body = None;
let header = parse_kv_pairs(&subcmd_args, "--header");
let query = parse_query_pairs(&subcmd_args, "--query");
let mut output = None;
let mut range = None;
let mut resume = false;
let mut i = 0;
while i < subcmd_args.len() {
match subcmd_args[i].as_str() {
s if s.starts_with("http://") || s.starts_with("https://") || s.starts_with("file://") => url = Some(s.to_string()),
s if url.is_some() && body.is_none() && !s.starts_with('-') => body = Some(s.to_string()),
"--header" | "--query" => i += 1,
"--output" => {
if let Some(val) = subcmd_args.get(i + 1) {
output = Some(val.clone());
i += 1;
}
}
"--range" => {
if let Some(r) = subcmd_args.get(i + 1) {
let parts: Vec<&str> = r.split('-').collect();
let start = parts.get(0).and_then(|s| s.trim().parse::<u64>().ok());
let end = if let Some(e) = parts.get(1) { if !e.trim().is_empty() { e.trim().parse::<u64>().ok() } else { None } } else { None };
if let Some(start) = start {
range = Some((start, end));
}
i += 1;
}
}
"--resume" => resume = true,
_ => {}
}
i += 1;
}
IskraSubcommand::Put {
url: url.ok_or_else(|| IskraError::Config { msg: "Missing URL for put command. Usage: iskra put <url> <body> [options]".to_string() })?,
body: body.ok_or_else(|| IskraError::Config { msg: "Missing body for put command. Usage: iskra put <url> <body> [options]".to_string() })?,
header,
query,
output,
range,
resume,
}
}
"delete" => {
let mut url = None;
let header = parse_kv_pairs(&subcmd_args, "--header");
let query = parse_query_pairs(&subcmd_args, "--query");
let mut output = None;
let mut range = None;
let mut resume = false;
let mut i = 0;
while i < subcmd_args.len() {
match subcmd_args[i].as_str() {
s if s.starts_with("http://") || s.starts_with("https://") || s.starts_with("file://") => url = Some(s.to_string()),
"--header" => i += 1,
"--query" => i += 1,
"--output" => {
if i + 1 < subcmd_args.len() {
output = Some(subcmd_args[i + 1].clone());
i += 1;
}
}
"--range" => {
if i + 1 < subcmd_args.len() {
let r = &subcmd_args[i + 1];
let parts: Vec<&str> = r.split('-').collect();
if !parts.is_empty() {
let start = parts[0].trim().parse::<u64>().ok();
let end = if parts.len() > 1 && !parts[1].trim().is_empty() {
parts[1].trim().parse::<u64>().ok()
} else {
None
};
if let Some(start) = start {
range = Some((start, end));
}
}
i += 1;
}
}
"--resume" => resume = true,
_ => {}
}
i += 1;
}
IskraSubcommand::Delete {
url: url.ok_or_else(|| IskraError::Config { msg: "Missing URL for delete command".to_string() })?,
header,
query,
output,
range,
resume,
}
}
"custom" => {
let mut method = None;
let mut url = None;
let header = parse_kv_pairs(&subcmd_args, "--header");
let query = parse_query_pairs(&subcmd_args, "--query");
let mut output = None;
let mut range = None;
let mut resume = false;
let mut body = None;
let mut trailing_body = Vec::new();
let mut i = 0;
while i < subcmd_args.len() {
match subcmd_args[i].as_str() {
s if method.is_none() => { method = Some(s.to_string()); },
s if url.is_none() && method.is_some() && (s.starts_with("http://") || s.starts_with("https://") || s.starts_with("file://")) => { url = Some(s.to_string()); },
"--header" | "--query" => i += 1,
"--output" => {
if let Some(val) = subcmd_args.get(i + 1) {
output = Some(val.clone());
i += 1;
}
}
"--range" => {
if let Some(r) = subcmd_args.get(i + 1) {
let parts: Vec<&str> = r.split('-').collect();
let start = parts.get(0).and_then(|s| s.trim().parse::<u64>().ok());
let end = if let Some(e) = parts.get(1) { if !e.trim().is_empty() { e.trim().parse::<u64>().ok() } else { None } } else { None };
if let Some(start) = start {
range = Some((start, end));
}
i += 1;
}
}
"--resume" => resume = true,
"--body" => {
if let Some(val) = subcmd_args.get(i + 1) {
body = Some(val.clone());
i += 1;
}
}
s if method.is_some() && url.is_some() && !s.starts_with('-') => {
trailing_body.push(s.to_string());
}
_ => {}
}
i += 1;
}
IskraSubcommand::Custom {
method: method.ok_or_else(|| IskraError::Config { msg: "Missing method for custom command. Usage: iskra custom <METHOD> <url> [options] [body]".to_string() })?,
url: url.ok_or_else(|| IskraError::Config { msg: "Missing URL for custom command. Usage: iskra custom <METHOD> <url> [options] [body]".to_string() })?,
header,
query,
output,
body,
trailing_body,
range,
resume,
}
}
"burst" => {
use std::path::Path;
if subcmd_args.iter().any(|a| a == "--help" || a == "-h") {
print_iskra_help();
std::process::exit(0);
}
let mut input = None;
let mut output_dir = None;
let mut concurrency = 4;
let mut throttle = None;
let mut retries = 0;
let mut backoff = 0u64;
let mut summary = false;
let mut i = 0;
while i < subcmd_args.len() {
match subcmd_args[i].as_str() {
"-i" | "--input" => {
if i + 1 < subcmd_args.len() {
input = Some(subcmd_args[i + 1].clone());
i += 1;
} else {
return Err(IskraError::Config { msg: "Missing value for --input (-i) in burst command. Usage: iskra burst -i <file> [options]".to_string() });
}
}
"-o" | "--output-dir" => {
if i + 1 < subcmd_args.len() {
output_dir = Some(subcmd_args[i + 1].clone());
i += 1;
} else {
return Err(IskraError::Config { msg: "Missing value for --output-dir (-o) in burst command.".to_string() });
}
}
"-c" | "--concurrency" => {
if i + 1 < subcmd_args.len() {
concurrency = subcmd_args[i + 1].parse().map_err(|_| IskraError::Config { msg: "Invalid value for --concurrency (must be a positive integer)".to_string() })?;
if concurrency == 0 {
return Err(IskraError::Config { msg: "Concurrency must be greater than 0 for burst command.".to_string() });
}
i += 1;
} else {
return Err(IskraError::Config { msg: "Missing value for --concurrency (-c) in burst command.".to_string() });
}
}
"--throttle" => {
if i + 1 < subcmd_args.len() {
throttle = Some(subcmd_args[i + 1].parse().map_err(|_| IskraError::Config { msg: "Invalid value for --throttle (must be a positive integer)".to_string() })?);
i += 1;
} else {
return Err(IskraError::Config { msg: "Missing value for --throttle in burst command.".to_string() });
}
}
"--retries" => {
if i + 1 < subcmd_args.len() {
retries = subcmd_args[i + 1].parse().map_err(|_| IskraError::Config { msg: "Invalid value for --retries (must be a non-negative integer)".to_string() })?;
i += 1;
} else {
return Err(IskraError::Config { msg: "Missing value for --retries in burst command.".to_string() });
}
}
"--backoff" => {
if i + 1 < subcmd_args.len() {
backoff = subcmd_args[i + 1].parse().map_err(|_| IskraError::Config { msg: "Invalid value for --backoff (must be a non-negative integer)".to_string() })?;
i += 1;
} else {
return Err(IskraError::Config { msg: "Missing value for --backoff in burst command.".to_string() });
}
}
"--summary" => summary = true,
_ => {}
}
i += 1;
}
let input_file = input.ok_or_else(|| IskraError::Config { msg: "Missing input for burst command. Usage: iskra burst -i <file> [options]".to_string() })?;
if !Path::new(&input_file).exists() {
return Err(IskraError::Config { msg: format!("Input file '{}' does not exist for burst command. Please provide a valid file.", input_file) });
}
IskraSubcommand::Burst {
input: input_file,
output_dir,
concurrency,
throttle,
retries,
backoff,
summary,
}
}
_ => return Err(IskraError::Config { msg: format!("Unsupported subcommand: {}", subcmd) }),
};
Ok(IskraCli {
timeout,
no_decompress,
fail,
show_headers,
verbose_errors,
verbose,
quiet,
subcommand,
})
}
pub fn print_iskra_help() {
println!(r#"Iskra: Modern, secure, and flexible CLI for HTTP(S) data transfer.
USAGE:
iskra [GLOBAL OPTIONS] <COMMAND> [ARGS]
COMMANDS:
get HTTP GET request
head HTTP HEAD request (show metadata only, smart header display, colorized diff, --compare <file>, --save-headers <file>, file:// support)
trace HTTP TRACE request (echo server request, show round-trip headers)
connect HTTP CONNECT request (establish tunnel, show headers, timing, errors, advanced tunnel options)
post HTTP POST request
put HTTP PUT request
delete HTTP DELETE request
custom HTTP request with arbitrary method (e.g. PATCH, OPTIONS, TRACE, CONNECT)
burst High-performance parallel/batch HTTP requests
CONNECT OPTIONS:
--tunnel Enable full tunnel mode (bidirectional stdin/stdout, like netcat)
--stats Show live traffic statistics during tunnel
--hexdump Show hexdump of received data
--log <file> Log all tunnel traffic to <file>
--prompt Show interactive prompt with commands (/exit, /stats, /hex, etc.)
--tunnel-timeout <sec> Set idle timeout for tunnel (seconds)
--keepalive Enable TCP keepalive pings
--banner Show banner and require confirmation before tunnel
--protocol-detect Try to detect protocol on tunnel and print summary
GLOBAL OPTIONS (can be placed anywhere):
--timeout <seconds> Timeout in seconds (default: 30)
--no-decompress Disable automatic response decompression (gzip, deflate, brotli)
--fail Exit with non-zero code if response is not 2xx
--show-headers Always print response headers in output
--verbose-errors Show detailed error reports (same as ISKRA_ERROR_VERBOSE=1)
--verbose, -v Show verbose output (per-request details, progress, etc.)
--quiet, -q Suppress all output except errors (quiet mode)
HEAD OPTIONS:
--compare <file> Compare response headers to those in <file> (JSON or key:value text)
--save-headers <file> Save response headers as pretty JSON to <file>
--show-headers Show all headers (not just important ones)
--verbose Show all headers and extra details
BURST OPTIONS:
-i, --input <file> Input file with batch requests (required)
-o, --output-dir <dir> Output directory for results (optional)
-c, --concurrency <n> Number of concurrent requests (default: 4, must be > 0)
--throttle <n> Throttle requests per second (optional)
--retries <n> Number of retries per request (default: 0)
--backoff <ms> Backoff in milliseconds between retries (default: 0)
--summary Print summary after completion
EXAMPLES:
iskra get https://example.com --timeout 10
iskra head https://example.com
iskra head https://example.com --compare headers.json
iskra head https://example.com --save-headers out.json
iskra head file://README.md
iskra trace https://httpbin.org/trace
iskra connect https://example.com:443 --tunnel --stats --hexdump --log tunnel.log --prompt --tunnel-timeout 60 --keepalive --banner --protocol-detect
iskra --timeout 10 get https://example.com
iskra post https://httpbin.org/post 'hello world' --fail --no-decompress
iskra custom PATCH https://httpbin.org/patch --timeout 5 --verbose-errors
iskra delete https://example.com/resource --fail
iskra burst -i batch.txt --concurrency 8 --summary
iskra https://example.com (GET shortcut)
TIPS:
- All global options can be placed before or after the subcommand.
- For burst, input file must exist and concurrency must be > 0.
- Use --fail to ensure non-2xx responses cause errors.
- Use --show-headers and --verbose-errors for debugging.
- HEAD supports file:// URLs for local file metadata.
- HEAD --compare works with both JSON and key:value text files.
For more details, see: https://github.com/ParkBlake/iskra
"#);
}
fn parse_kv_pairs(args: &[String], flag: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
let mut i = 0;
while i < args.len() {
if args[i] == flag && i + 1 < args.len() {
if let Some((k, v)) = args[i + 1].split_once('=') {
out.push((k.trim().to_string(), v.trim().to_string()));
}
i += 2;
} else {
i += 1;
}
}
out
}
fn parse_query_pairs(args: &[String], flag: &str) -> Vec<(String, String)> {
parse_kv_pairs(args, flag)
}