use crate::config::CliConfig;
use crate::core::constants::{output_formats, timeouts};
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(
author,
version,
about,
long_about = None,
help_template = "\
{before-help}{name} - CLI to validate URLs in files [version {version}]
{usage-heading} {usage}
Example:
$ urlsup . --recursive --include md,txt
Commands:
completion-generate Generate shell completions
completion-install Install shell completions to standard location
config-wizard Run interactive configuration wizard
help Print this message or the help of the given subcommand(s)
Core Options:
-r, --recursive Recursively process directories. Will skip files/directories listed in .gitignore
-t, --timeout <SECONDS> Connection timeout in seconds (default: 5)
--concurrency <COUNT> Concurrent requests (default: CPU cores)
Filtering & Content:
--include <EXTENSIONS> File extensions to process (e.g., md,html,txt)
--allowlist <URLS> URLs to allow (comma-separated)
--allow-status <CODES> Status codes to allow (comma-separated)
--exclude-pattern <REGEX> URL patterns to exclude (regex)
Retry & Rate Limiting:
--retry <COUNT> Retry attempts for failed requests (default: 0)
--retry-delay <MS> Delay between retries in ms (default: 1000)
--rate-limit <MS> Delay between requests in ms (default: 0)
--allow-timeout Allow URLs that timeout
--failure-threshold <PERCENT> Fail only if more than X% URLs are broken (0-100)
Output & Verbosity:
-q, --quiet Suppress progress output
-v, --verbose Enable verbose logging
--format <FORMAT> Output format [default: text] [text|json|minimal]
--no-progress Disable progress bars
Network & Security:
--user-agent <AGENT> Custom User-Agent header
--proxy <URL> HTTP/HTTPS proxy URL
--insecure Skip SSL certificate verification
Configuration:
--config <FILE> Use specific config file
--no-config Ignore config files
Performance Analysis:
--show-performance Show memory usage and optimization suggestions
--html-dashboard <PATH> Generate HTML dashboard report
Options:
-h, --help Print help
-V, --version Print version
{after-help}
"
)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Commands>,
pub files: Vec<String>,
#[arg(short = 'r', long, help_heading = "Core Options")]
pub recursive: bool,
#[arg(
short = 't',
long,
value_name = "SECONDS",
help_heading = "Core Options"
)]
pub timeout: Option<u64>,
#[arg(long, value_name = "COUNT", help_heading = "Core Options")]
pub concurrency: Option<usize>,
#[arg(long, value_name = "EXTENSIONS", help_heading = "Filtering & Content")]
pub include: Option<String>,
#[arg(long, value_name = "URLS", help_heading = "Filtering & Content")]
pub allowlist: Option<String>,
#[arg(long, value_name = "CODES", help_heading = "Filtering & Content")]
pub allow_status: Option<String>,
#[arg(long, value_name = "REGEX", help_heading = "Filtering & Content")]
pub exclude_pattern: Vec<String>,
#[arg(long, value_name = "COUNT", help_heading = "Retry & Rate Limiting")]
pub retry: Option<u8>,
#[arg(long, value_name = "MS", help_heading = "Retry & Rate Limiting")]
pub retry_delay: Option<u64>,
#[arg(long, value_name = "MS", help_heading = "Retry & Rate Limiting")]
pub rate_limit: Option<u64>,
#[arg(long, help_heading = "Retry & Rate Limiting")]
pub allow_timeout: bool,
#[arg(long, value_name = "PERCENT", help_heading = "Retry & Rate Limiting")]
pub failure_threshold: Option<f64>,
#[arg(short = 'q', long, help_heading = "Output & Verbosity")]
pub quiet: bool,
#[arg(short = 'v', long, help_heading = "Output & Verbosity")]
pub verbose: bool,
#[arg(long, value_name = "FORMAT", value_parser = output_formats::ALL, default_value = output_formats::DEFAULT, help_heading = "Output & Verbosity")]
pub format: String,
#[arg(long, help_heading = "Output & Verbosity")]
pub no_progress: bool,
#[arg(long, value_name = "AGENT", help_heading = "Network & Security")]
pub user_agent: Option<String>,
#[arg(long, value_name = "URL", help_heading = "Network & Security")]
pub proxy: Option<String>,
#[arg(long, help_heading = "Network & Security")]
pub insecure: bool,
#[arg(long, value_name = "FILE", help_heading = "Configuration")]
pub config: Option<String>,
#[arg(long, help_heading = "Configuration")]
pub no_config: bool,
#[arg(long, help_heading = "Performance Analysis")]
pub show_performance: bool,
#[arg(long, value_name = "PATH", help_heading = "Performance Analysis")]
pub html_dashboard: Option<String>,
}
#[derive(Subcommand)]
pub enum Commands {
#[command(name = "completion-generate", arg_required_else_help = true)]
CompletionGenerate {
#[arg(value_enum)]
shell: clap_complete::Shell,
},
#[command(name = "completion-install", arg_required_else_help = true)]
CompletionInstall {
#[arg(value_enum)]
shell: clap_complete::Shell,
},
#[command(name = "config-wizard")]
ConfigWizard,
}
pub fn parse_cli_args(matches: &clap::ArgMatches) -> CliConfig {
let mut cli_config = CliConfig::default();
if let Some(timeout_str) = matches.get_one::<String>("timeout") {
let timeout: u64 = timeout_str.parse().unwrap_or_else(|_| {
eprintln!("Error: Timeout '{timeout_str}' is not a valid number. Expected a positive integer representing seconds.");
std::process::exit(1);
});
if timeout == 0 {
eprintln!(
"Error: Timeout cannot be 0. Expected a positive integer representing seconds."
);
std::process::exit(1);
}
if timeout > timeouts::MAX_TIMEOUT_SECONDS {
eprintln!(
"Warning: Timeout of {timeout} seconds is quite large. Consider using a smaller value for better user experience."
);
}
cli_config.timeout = Some(timeout);
}
if let Some(include_str) = matches.get_one::<String>("include") {
cli_config.file_types = Some(
include_str
.split(',')
.map(|s| s.trim().to_string())
.collect(),
);
}
if let Some(allowlist_str) = matches.get_one::<String>("allowlist") {
cli_config.allowlist = Some(
allowlist_str
.split(',')
.filter_map(|s| {
if s.trim().is_empty() {
None
} else {
Some(s.trim().to_string())
}
})
.collect(),
);
}
if let Some(status_str) = matches.get_one::<String>("allow-status") {
cli_config.allowed_status_codes = Some(
status_str
.split(',')
.filter_map(|s| {
if s.trim().is_empty() {
None
} else {
s.trim()
.parse::<u16>()
.map_err(|_| {
eprintln!(
"Error: Status code '{s}' is not a valid HTTP status code. Expected a number between 100-599."
);
std::process::exit(1);
})
.inspect(|&code| {
if !(100..=599).contains(&code) {
eprintln!(
"Error: Status code '{code}' is not a valid HTTP status code. Expected a number between 100-599."
);
std::process::exit(1);
}
})
.ok()
}
})
.collect(),
);
}
if let Some(patterns) = matches.get_many::<String>("exclude-pattern") {
cli_config.exclude_patterns = Some(patterns.cloned().collect());
}
if let Some(concurrency_str) = matches.get_one::<String>("concurrency") {
let concurrency: usize = concurrency_str.parse().unwrap_or_else(|_| {
eprintln!("Error: Concurrency '{concurrency_str}' is not a valid number. Expected a positive integer representing the number of concurrent requests.");
std::process::exit(1);
});
if concurrency == 0 {
eprintln!(
"Error: Concurrency cannot be 0. Expected a positive integer representing the number of concurrent requests."
);
std::process::exit(1);
}
if concurrency > 100 {
eprintln!(
"Warning: Concurrency of {concurrency} is quite high and may overwhelm servers. Consider using a smaller value."
);
}
cli_config.threads = Some(concurrency);
}
if let Some(retry_str) = matches.get_one::<String>("retry") {
cli_config.retry_attempts = Some(retry_str.parse().unwrap_or_else(|_| {
eprintln!("Error: Retry count '{retry_str}' is not a valid number. Expected a non-negative integer representing the number of retry attempts.");
std::process::exit(1);
}));
}
if let Some(retry_delay_str) = matches.get_one::<String>("retry-delay") {
cli_config.retry_delay = Some(retry_delay_str.parse().unwrap_or_else(|_| {
eprintln!("Error: Retry delay '{retry_delay_str}' is not a valid number. Expected a non-negative integer representing milliseconds.");
std::process::exit(1);
}));
}
if let Some(rate_limit_str) = matches.get_one::<String>("rate-limit") {
cli_config.rate_limit_delay = Some(rate_limit_str.parse().unwrap_or_else(|_| {
eprintln!("Error: Rate limit '{rate_limit_str}' is not a valid number. Expected a non-negative integer representing milliseconds between requests.");
std::process::exit(1);
}));
}
cli_config.allow_timeout = matches.get_flag("allow-timeout");
if let Some(threshold_str) = matches.get_one::<String>("failure-threshold") {
let threshold: f64 = threshold_str.parse().unwrap_or_else(|_| {
eprintln!("Error: Failure threshold '{threshold_str}' is not a valid number. Expected a value between 0-100.");
std::process::exit(1);
});
if !(0.0..=100.0).contains(&threshold) {
eprintln!(
"Error: Failure threshold {threshold}% is invalid. Expected a value between 0-100."
);
std::process::exit(1);
}
cli_config.failure_threshold = Some(threshold);
}
cli_config.quiet = matches.get_flag("quiet");
cli_config.verbose = matches.get_flag("verbose");
cli_config.no_progress = matches.get_flag("no-progress");
if let Some(format_str) = matches.get_one::<String>("format") {
cli_config.output_format = Some(format_str.clone());
}
if let Some(user_agent_str) = matches.get_one::<String>("user-agent") {
cli_config.user_agent = Some(user_agent_str.clone());
}
if let Some(proxy_str) = matches.get_one::<String>("proxy") {
cli_config.proxy = Some(proxy_str.clone());
}
cli_config.skip_ssl_verification = matches.get_flag("insecure");
if let Some(config_file) = matches.get_one::<String>("config") {
cli_config.config_file = Some(config_file.clone());
}
cli_config.no_config = matches.get_flag("no-config");
cli_config.show_performance = matches.get_flag("show-performance");
if let Some(dashboard_path) = matches.get_one::<String>("html-dashboard") {
cli_config.html_dashboard_path = Some(dashboard_path.clone());
}
cli_config
}
pub fn cli_to_config(cli: &Cli) -> CliConfig {
let mut cli_config = CliConfig::default();
if let Some(timeout) = cli.timeout {
if timeout == 0 {
eprintln!(
"Error: Timeout cannot be 0. Expected a positive integer representing seconds."
);
std::process::exit(1);
}
if timeout > timeouts::MAX_TIMEOUT_SECONDS {
eprintln!(
"Warning: Timeout of {timeout} seconds is quite large. Consider using a smaller value for better user experience."
);
}
cli_config.timeout = Some(timeout);
}
if let Some(ref include_str) = cli.include {
cli_config.file_types = Some(
include_str
.split(',')
.map(|s| s.trim().to_string())
.collect(),
);
}
if let Some(ref allowlist_str) = cli.allowlist {
cli_config.allowlist = Some(
allowlist_str
.split(',')
.filter_map(|s| {
if s.trim().is_empty() {
None
} else {
Some(s.trim().to_string())
}
})
.collect(),
);
}
if let Some(ref status_str) = cli.allow_status {
cli_config.allowed_status_codes = Some(
status_str
.split(',')
.filter_map(|s| {
if s.trim().is_empty() {
None
} else {
s.trim()
.parse::<u16>()
.map_err(|_| {
eprintln!(
"Error: Status code '{s}' is not a valid HTTP status code. Expected a number between 100-599."
);
std::process::exit(1);
})
.inspect(|&code| {
if !(100..=599).contains(&code) {
eprintln!(
"Error: Status code '{code}' is not a valid HTTP status code. Expected a number between 100-599."
);
std::process::exit(1);
}
})
.ok()
}
})
.collect(),
);
}
if !cli.exclude_pattern.is_empty() {
cli_config.exclude_patterns = Some(cli.exclude_pattern.clone());
}
if let Some(concurrency) = cli.concurrency {
if concurrency == 0 {
eprintln!(
"Error: Concurrency cannot be 0. Expected a positive integer representing the number of concurrent requests."
);
std::process::exit(1);
}
if concurrency > 100 {
eprintln!(
"Warning: Concurrency of {concurrency} is quite high and may overwhelm servers. Consider using a smaller value."
);
}
cli_config.threads = Some(concurrency);
}
if let Some(retry) = cli.retry {
cli_config.retry_attempts = Some(retry);
}
if let Some(retry_delay) = cli.retry_delay {
cli_config.retry_delay = Some(retry_delay);
}
if let Some(rate_limit) = cli.rate_limit {
cli_config.rate_limit_delay = Some(rate_limit);
}
cli_config.allow_timeout = cli.allow_timeout;
if let Some(threshold) = cli.failure_threshold {
if !(0.0..=100.0).contains(&threshold) {
eprintln!(
"Error: Failure threshold {threshold}% is invalid. Expected a value between 0-100."
);
std::process::exit(1);
}
cli_config.failure_threshold = Some(threshold);
}
cli_config.quiet = cli.quiet;
cli_config.verbose = cli.verbose;
cli_config.no_progress = cli.no_progress;
cli_config.output_format = Some(cli.format.clone());
cli_config.user_agent = cli.user_agent.clone();
cli_config.proxy = cli.proxy.clone();
cli_config.skip_ssl_verification = cli.insecure;
cli_config.config_file = cli.config.clone();
cli_config.no_config = cli.no_config;
cli_config.show_performance = cli.show_performance;
cli_config.html_dashboard_path = cli.html_dashboard.clone();
cli_config
}
pub fn validate_cli_args(cli: &Cli) {
if let Some(timeout) = cli.timeout {
if timeout == 0 {
eprintln!(
"Error: Timeout cannot be 0. Expected a positive integer representing seconds."
);
std::process::exit(1);
}
if timeout > timeouts::MAX_TIMEOUT_SECONDS {
eprintln!(
"Warning: Timeout of {timeout} seconds is quite large. Consider using a smaller value for better user experience."
);
}
}
if let Some(concurrency) = cli.concurrency {
if concurrency == 0 {
eprintln!(
"Error: Concurrency cannot be 0. Expected a positive integer representing the number of concurrent requests."
);
std::process::exit(1);
}
if concurrency > 100 {
eprintln!(
"Warning: Concurrency of {concurrency} is quite high and may overwhelm servers. Consider using a smaller value."
);
}
}
if let Some(ref status_str) = cli.allow_status {
for code_str in status_str.split(',') {
if let Ok(code) = code_str.trim().parse::<u16>() {
if !(100..=599).contains(&code) {
eprintln!(
"Error: Status code '{code}' is not a valid HTTP status code. Expected a number between 100-599."
);
std::process::exit(1);
}
} else if !code_str.trim().is_empty() {
eprintln!(
"Error: Status code '{}' is not a valid number. Expected a number between 100-599.",
code_str.trim()
);
std::process::exit(1);
}
}
}
if let Some(threshold) = cli.failure_threshold
&& !(0.0..=100.0).contains(&threshold)
{
eprintln!(
"Error: Failure threshold {threshold}% is invalid. Expected a value between 0-100."
);
std::process::exit(1);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::constants::output_formats;
fn create_default_cli() -> Cli {
Cli {
command: None,
files: vec![],
recursive: false,
timeout: None,
concurrency: None,
include: None,
allowlist: None,
allow_status: None,
exclude_pattern: vec![],
retry: None,
retry_delay: None,
rate_limit: None,
allow_timeout: false,
failure_threshold: None,
quiet: false,
verbose: false,
format: output_formats::DEFAULT.to_string(),
no_progress: false,
user_agent: None,
proxy: None,
insecure: false,
config: None,
no_config: false,
show_performance: false,
html_dashboard: None,
}
}
#[test]
fn test_cli_to_config_default() {
let cli = create_default_cli();
let config = cli_to_config(&cli);
assert_eq!(config.timeout, None);
assert_eq!(config.threads, None);
assert_eq!(config.file_types, None);
assert_eq!(config.allowlist, None);
assert_eq!(config.allowed_status_codes, None);
assert_eq!(config.exclude_patterns, None);
assert_eq!(config.retry_attempts, None);
assert_eq!(config.retry_delay, None);
assert_eq!(config.rate_limit_delay, None);
assert!(!config.allow_timeout);
assert_eq!(config.failure_threshold, None);
assert!(!config.quiet);
assert!(!config.verbose);
assert!(!config.no_progress);
assert_eq!(
config.output_format,
Some(output_formats::DEFAULT.to_string())
);
assert_eq!(config.user_agent, None);
assert_eq!(config.proxy, None);
assert!(!config.skip_ssl_verification);
assert_eq!(config.config_file, None);
assert!(!config.no_config);
}
#[test]
fn test_cli_to_config_all_options() {
let mut cli = create_default_cli();
cli.files = vec!["test.md".to_string()];
cli.recursive = true;
cli.timeout = Some(60);
cli.concurrency = Some(8);
cli.include = Some("md,txt".to_string());
cli.allowlist = Some("example.com,google.com".to_string());
cli.allow_status = Some("200,404".to_string());
cli.exclude_pattern = vec![".*test.*".to_string(), ".*debug.*".to_string()];
cli.retry = Some(3);
cli.retry_delay = Some(2000);
cli.rate_limit = Some(100);
cli.allow_timeout = true;
cli.failure_threshold = Some(10.5);
cli.quiet = true;
cli.verbose = true;
cli.format = output_formats::JSON.to_string();
cli.no_progress = true;
cli.user_agent = Some("CustomAgent/1.0".to_string());
cli.proxy = Some("http://proxy:8080".to_string());
cli.insecure = true;
cli.config = Some("config.toml".to_string());
cli.no_config = true;
let config = cli_to_config(&cli);
assert_eq!(config.timeout, Some(60));
assert_eq!(config.threads, Some(8));
assert_eq!(
config.file_types,
Some(vec!["md".to_string(), "txt".to_string()])
);
assert_eq!(
config.allowlist,
Some(vec!["example.com".to_string(), "google.com".to_string()])
);
assert_eq!(config.allowed_status_codes, Some(vec![200, 404]));
assert_eq!(
config.exclude_patterns,
Some(vec![".*test.*".to_string(), ".*debug.*".to_string()])
);
assert_eq!(config.retry_attempts, Some(3));
assert_eq!(config.retry_delay, Some(2000));
assert_eq!(config.rate_limit_delay, Some(100));
assert!(config.allow_timeout);
assert_eq!(config.failure_threshold, Some(10.5));
assert!(config.quiet);
assert!(config.verbose);
assert!(config.no_progress);
assert_eq!(config.output_format, Some(output_formats::JSON.to_string()));
assert_eq!(config.user_agent, Some("CustomAgent/1.0".to_string()));
assert_eq!(config.proxy, Some("http://proxy:8080".to_string()));
assert!(config.skip_ssl_verification);
assert_eq!(config.config_file, Some("config.toml".to_string()));
assert!(config.no_config);
}
#[test]
fn test_cli_to_config_empty_strings() {
let mut cli = create_default_cli();
cli.include = Some("".to_string());
cli.allowlist = Some("".to_string());
cli.allow_status = Some("".to_string());
cli.format = output_formats::MINIMAL.to_string();
cli.user_agent = Some("".to_string());
cli.proxy = Some("".to_string());
cli.config = Some("".to_string());
let config = cli_to_config(&cli);
assert_eq!(config.file_types, Some(vec!["".to_string()]));
assert_eq!(config.allowlist, Some(vec![])); assert_eq!(config.allowed_status_codes, Some(vec![])); assert_eq!(
config.output_format,
Some(output_formats::MINIMAL.to_string())
);
assert_eq!(config.user_agent, Some("".to_string()));
assert_eq!(config.proxy, Some("".to_string()));
assert_eq!(config.config_file, Some("".to_string()));
}
#[test]
fn test_cli_to_config_whitespace_trimming() {
let mut cli = create_default_cli();
cli.include = Some(" md , txt ".to_string());
cli.allowlist = Some(" example.com , google.com ".to_string());
cli.allow_status = Some(" 200 , 404 ".to_string());
let config = cli_to_config(&cli);
assert_eq!(
config.file_types,
Some(vec!["md".to_string(), "txt".to_string()])
);
assert_eq!(
config.allowlist,
Some(vec!["example.com".to_string(), "google.com".to_string()])
);
assert_eq!(config.allowed_status_codes, Some(vec![200, 404]));
}
#[test]
fn test_cli_to_config_mixed_empty_values() {
let mut cli = create_default_cli();
cli.allowlist = Some("example.com, , google.com".to_string());
cli.allow_status = Some("200, , 404".to_string());
let config = cli_to_config(&cli);
assert_eq!(
config.allowlist,
Some(vec!["example.com".to_string(), "google.com".to_string()])
);
assert_eq!(config.allowed_status_codes, Some(vec![200, 404]));
}
#[test]
fn test_cli_to_config_boundary_values() {
let mut cli = create_default_cli();
cli.timeout = Some(1);
cli.concurrency = Some(1);
cli.allow_status = Some("100,599".to_string());
cli.retry = Some(0);
cli.retry_delay = Some(0);
cli.rate_limit = Some(0);
cli.failure_threshold = Some(0.0);
let config = cli_to_config(&cli);
assert_eq!(config.timeout, Some(1));
assert_eq!(config.threads, Some(1));
assert_eq!(config.allowed_status_codes, Some(vec![100, 599]));
assert_eq!(config.retry_attempts, Some(0));
assert_eq!(config.retry_delay, Some(0));
assert_eq!(config.rate_limit_delay, Some(0));
assert_eq!(config.failure_threshold, Some(0.0));
}
#[test]
fn test_cli_to_config_edge_case_failure_threshold() {
let mut cli = create_default_cli();
cli.failure_threshold = Some(100.0);
let config = cli_to_config(&cli);
assert_eq!(config.failure_threshold, Some(100.0));
}
#[test]
fn test_validate_cli_args_valid() {
let mut cli = create_default_cli();
cli.files = vec!["test.md".to_string()];
cli.timeout = Some(5);
cli.concurrency = Some(4);
cli.allow_status = Some("200,404".to_string());
cli.failure_threshold = Some(10.0);
validate_cli_args(&cli);
}
#[test]
fn test_validate_cli_args_high_timeout_warning() {
let mut cli = create_default_cli();
cli.files = vec!["test.md".to_string()];
cli.timeout = Some(3700);
validate_cli_args(&cli);
}
#[test]
fn test_validate_cli_args_high_concurrency_warning() {
let mut cli = create_default_cli();
cli.files = vec!["test.md".to_string()];
cli.concurrency = Some(150);
validate_cli_args(&cli);
}
#[test]
fn test_validate_cli_args_valid_status_codes() {
let mut cli = create_default_cli();
cli.files = vec!["test.md".to_string()];
cli.allow_status = Some("100,200,300,400,500,599".to_string());
validate_cli_args(&cli);
}
#[test]
fn test_validate_cli_args_empty_status_codes() {
let mut cli = create_default_cli();
cli.files = vec!["test.md".to_string()];
cli.allow_status = Some("200, , 404".to_string());
validate_cli_args(&cli);
}
#[test]
fn test_validate_cli_args_valid_failure_threshold_boundaries() {
let mut cli = create_default_cli();
cli.files = vec!["test.md".to_string()];
cli.failure_threshold = Some(0.0);
validate_cli_args(&cli);
let mut cli2 = create_default_cli();
cli2.files = vec!["test.md".to_string()];
cli2.failure_threshold = Some(100.0);
validate_cli_args(&cli2);
}
#[test]
fn test_parse_cli_args_string_parsing() {
let include_str = "md,html,txt";
let result: Vec<String> = include_str
.split(',')
.map(|s| s.trim().to_string())
.collect();
assert_eq!(
result,
vec!["md".to_string(), "html".to_string(), "txt".to_string()]
);
let allowlist_str = "https://example.com,,https://test.com,";
let result: Vec<String> = allowlist_str
.split(',')
.filter_map(|s| {
if s.trim().is_empty() {
None
} else {
Some(s.trim().to_string())
}
})
.collect();
assert_eq!(
result,
vec![
"https://example.com".to_string(),
"https://test.com".to_string()
]
);
let status_str = "200,,301,302";
let result: Vec<u16> = status_str
.split(',')
.filter_map(|s| {
if s.trim().is_empty() {
None
} else {
s.trim().parse::<u16>().ok()
}
})
.collect();
assert_eq!(result, vec![200, 301, 302]);
}
}