use std::path::PathBuf;
use std::str::FromStr;
use std::time::Duration;
use clap::{Parser, Subcommand};
#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
pub enum OutputFormat {
Text,
Json,
}
#[derive(clap::ValueEnum, Clone, Copy, Debug)]
pub enum LogLevel {
Off,
Error,
Warn,
Info,
Debug,
Trace,
}
#[derive(clap::ValueEnum, Clone, Debug)]
pub enum FileChangedAction {
Abort,
Restart,
}
#[derive(clap::ValueEnum, Clone, Debug)]
pub enum NotResumableAction {
Abort,
Restart,
}
#[derive(clap::ValueEnum, Clone, Debug)]
pub enum SameDownloadAction {
Abort,
Resume,
AddNumberToNameAndContinue,
}
#[derive(clap::ValueEnum, Clone, Debug)]
pub enum FinalFileAction {
Abort,
ReplaceAndContinue,
AddNumberToNameAndContinue,
}
fn parse_speed(s: &str) -> Result<u64, String> {
let s = s.trim();
if s.is_empty() {
return Err("empty speed string".to_string());
}
let mut working = s.to_string();
let lower = working.to_lowercase();
if lower.ends_with("/s") {
working.truncate(working.len() - 2);
} else if lower.ends_with("bps") {
working.truncate(working.len() - 3);
}
let working = working.trim();
let mut idx = 0usize;
for (i, ch) in working.char_indices() {
if !(ch.is_ascii_digit() || ch == '.') {
idx = i;
break;
}
idx = i + ch.len_utf8();
}
let (num_part, suf_part) = if idx == 0 {
return Err(format!("invalid speed '{}': missing numeric value", s));
} else if idx >= working.len() {
(working, "")
} else {
(working[..idx].trim(), working[idx..].trim())
};
let value =
f64::from_str(num_part).map_err(|e| format!("invalid number '{}': {}", num_part, e))?;
if value < 0.0 {
return Err("speed must be non-negative".to_string());
}
let suffix_owned = suf_part
.trim()
.trim_start_matches([' ', '\t', '\''])
.to_lowercase();
let multiplier: f64 = match suffix_owned.as_str() {
"" | "b" | "byte" | "bytes" => 1.0,
"k" | "kb" | "kib" | "kibibyte" | "kb/s" => 1024f64,
"m" | "mb" | "mib" | "mibibyte" => 1024f64.powi(2),
"g" | "gb" | "gib" | "gibibyte" => 1024f64.powi(3),
other => {
let o = other.trim();
if o.starts_with('k') {
1024f64
} else if o.starts_with('m') {
1024f64.powi(2)
} else if o.starts_with('g') {
1024f64.powi(3)
} else {
return Err(format!("unknown size suffix '{}'", other));
}
}
};
let bytes_f = value * multiplier;
if !bytes_f.is_finite() || bytes_f < 0.0 {
return Err("resulting speed is out of range".to_string());
}
let bytes = bytes_f as u128; if bytes > (u64::MAX as u128) {
return Err("speed too large".to_string());
}
Ok(bytes as u64)
}
const MACHINE_INTERFACE_HELP: &str = "\
EXIT CODES:
0 success
1 other / internal error
2 usage or invalid input (bad URL, missing input, invalid config/flags)
3 network error (DNS, timeout, HTTP status, connection)
4 conflict (save/server conflict, checksum mismatch)
5 I/O error
6 metadata error (lockfile in use, decode failure)
130 cancelled
JSON OUTPUT (--format json):
Downloads stream newline-delimited JSON (NDJSON) to stdout, one object
per line, each tagged with \"type\" and \"url\":
phase {\"phase\": evaluating|resolving_conflicts|downloading|assembling|flushing|verifying}
filename {\"filename\"}
progress {\"downloaded\", \"total\": <int|null>}
message {\"message\"}
completed {\"path\", \"already_complete\"}
failed {\"message\"}
cancelled {}
One-shot commands emit a single JSON document on stdout:
probe {\"type\":\"probe\", filename, size, resumable, etag, last_modified, checksums, ...}
status/list {\"type\":\"status\", count, downloads:[...]}
config {\"type\":\"config\", path, config} (config_saved on write)
Errors print one JSON object to stderr:
{\"type\":\"error\", \"kind\", \"message\", \"exit_code\"}";
#[derive(Parser, Debug)]
#[command(version, about, long_about = None, after_long_help = MACHINE_INTERFACE_HELP)]
pub struct Args {
pub input: Option<String>,
#[arg(long, default_value_t = false)]
pub remote_list: bool,
#[arg(long, value_name = "COUNT")]
pub max_connections: Option<u64>,
#[arg(long, value_name = "COUNT")]
pub max_concurrent_downloads: Option<usize>,
#[arg(short, long, value_name = "FILE|DIR")]
pub output: Option<PathBuf>,
#[arg(short, long, value_name = "DIR")]
pub download_dir: Option<PathBuf>,
#[arg(short, long, value_name = "FILE")]
pub config_file: Option<PathBuf>,
#[arg(short = 'U', long)]
pub user_agent: Option<String>,
#[arg(long)]
pub randomize_user_agent: Option<bool>,
#[arg(long, value_name = "(http(s)|socks)://")]
pub proxy: Option<String>,
#[arg(short, long = "timeout", value_name = "DURATION", value_parser = humantime::parse_duration)]
pub timeout: Option<Duration>,
#[arg(long, value_name = "COUNT")]
pub max_retries: Option<u32>,
#[arg(long, value_name = "COUNT")]
pub n_fixed_retries: Option<u32>,
#[arg(long, value_name = "DURATION", value_parser = humantime::parse_duration)]
pub wait_between_retries: Option<Duration>,
#[arg(short, long)]
pub use_server_time: Option<bool>,
#[arg(long, value_enum, default_value_t = FileChangedAction::Restart)]
pub on_file_changed: FileChangedAction,
#[arg(long, value_enum, default_value_t = NotResumableAction::Restart)]
pub on_not_resumable: NotResumableAction,
#[arg(long)]
pub accept_invalid_certs: Option<bool>,
#[arg(long)]
pub http2: Option<bool>,
#[arg(long)]
pub dynamic_split: Option<bool>,
#[arg(long = "header", value_name = "KEY:VALUE", num_args = 0.., action = clap::ArgAction::Append)]
pub headers: Vec<String>,
#[arg(long, value_enum, default_value_t = SameDownloadAction::Resume)]
pub on_same_download_exists: SameDownloadAction,
#[arg(long, value_enum, default_value_t = FinalFileAction::ReplaceAndContinue)]
pub on_final_file_exists: FinalFileAction,
#[arg(long, value_name = "USER")]
pub http_user: Option<String>,
#[arg(long, value_name = "PASSWORD")]
pub http_password: Option<String>,
#[arg(short, long, value_name = "BYTES_PER_SEC", value_parser = parse_speed)]
pub speed_limit: Option<u64>,
#[arg(long, value_enum, default_value_t = LogLevel::Warn)]
pub log_level: LogLevel,
#[arg(long, value_enum, default_value_t = OutputFormat::Text, global = true)]
pub format: OutputFormat,
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
Config {
#[arg(long)]
show: bool,
#[arg(long, value_name = "FILE")]
config_file: Option<PathBuf>,
#[arg(long, value_name = "DIR")]
download_dir: Option<PathBuf>,
#[arg(long, value_name = "COUNT")]
max_connections: Option<u64>,
#[arg(long, value_name = "COUNT")]
max_concurrent_downloads: Option<usize>,
#[arg(long, value_name = "COUNT")]
max_retries: Option<u32>,
#[arg(long, value_name = "COUNT")]
n_fixed_retries: Option<u32>,
#[arg(long, value_name = "DURATION", value_parser = humantime::parse_duration)]
wait_between_retries: Option<Duration>,
#[arg(short, long, value_name = "BYTES_PER_SEC", value_parser = parse_speed)]
speed_limit: Option<u64>,
#[arg(long)]
user_agent: Option<String>,
#[arg(long)]
randomize_user_agent: Option<bool>,
#[arg(long)]
proxy: Option<String>,
#[arg(short, long = "timeout", value_name = "DURATION", value_parser = humantime::parse_duration)]
timeout: Option<Duration>,
#[arg(long)]
use_server_time: Option<bool>,
#[arg(long)]
accept_invalid_certs: Option<bool>,
#[arg(long)]
http2: Option<bool>,
#[arg(long)]
dynamic_split: Option<bool>,
},
Probe {
#[arg(value_name = "URL")]
url: String,
},
Status {
#[arg(value_name = "FILTER")]
filter: Option<String>,
},
List {
#[arg(value_name = "FILTER")]
filter: Option<String>,
},
}
#[cfg(test)]
mod tests {
use super::parse_speed;
use std::time::Duration;
#[test]
fn test_simple_bytes() {
assert_eq!(parse_speed("100").unwrap(), 100);
assert_eq!(parse_speed("100B").unwrap(), 100);
}
#[test]
fn test_kilobytes() {
assert_eq!(parse_speed("1K").unwrap(), 1024);
assert_eq!(parse_speed("1KB").unwrap(), 1024);
assert_eq!(parse_speed("100kib").unwrap(), 100 * 1024);
}
#[test]
fn test_megabytes() {
assert_eq!(parse_speed("1M").unwrap(), 1024u64.pow(2));
assert_eq!(
parse_speed("1.5MB").unwrap(),
((1.5f64 * (1024f64.powi(2))) as u64)
);
}
#[test]
fn test_gigabytes() {
assert_eq!(parse_speed("2G").unwrap(), 2 * 1024u64.pow(3));
assert_eq!(parse_speed("2GiB").unwrap(), 2 * 1024u64.pow(3));
}
#[test]
fn test_suffix_with_per_second() {
assert_eq!(parse_speed("100KB/s").unwrap(), 100 * 1024);
assert_eq!(
parse_speed("1.5MiB/s").unwrap(),
((1.5f64 * (1024f64.powi(2))) as u64)
);
}
#[test]
fn test_parse_duration_seconds_and_variants() {
assert_eq!(
humantime::parse_duration("30s").unwrap(),
Duration::from_secs(30)
);
assert_eq!(
humantime::parse_duration("30sec").unwrap(),
Duration::from_secs(30)
);
assert_eq!(
humantime::parse_duration("30seconds").unwrap(),
Duration::from_secs(30)
);
}
#[test]
fn test_parse_duration_minutes_hours_days() {
assert_eq!(
humantime::parse_duration("2m").unwrap(),
Duration::from_secs(120)
);
assert_eq!(
humantime::parse_duration("2min").unwrap(),
Duration::from_secs(120)
);
assert_eq!(
humantime::parse_duration("1h").unwrap(),
Duration::from_secs(3600)
);
assert_eq!(
humantime::parse_duration("1d").unwrap(),
Duration::from_secs(86400)
);
let d = humantime::parse_duration("1.5h").unwrap();
assert!((d.as_secs_f64() - 1.5 * 3600.0).abs() < 1e-6);
}
}