use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs;
use std::path::Path;
use std::time::Duration;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub timeout: Option<u64>,
pub threads: Option<usize>,
pub allow_timeout: Option<bool>,
pub file_types: Option<Vec<String>>,
pub exclude_patterns: Option<Vec<String>>,
pub allowlist: Option<Vec<String>>,
pub allowed_status_codes: Option<Vec<u16>>,
pub user_agent: Option<String>,
pub retry_attempts: Option<u8>,
pub retry_delay: Option<u64>,
pub skip_ssl_verification: Option<bool>,
pub proxy: Option<String>,
pub rate_limit_delay: Option<u64>,
pub output_format: Option<String>,
pub verbose: Option<bool>,
pub use_head_requests: Option<bool>,
}
impl Default for Config {
fn default() -> Self {
Self {
timeout: Some(30),
threads: None, allow_timeout: Some(false),
file_types: None,
exclude_patterns: None,
allowlist: None,
allowed_status_codes: None,
user_agent: None,
retry_attempts: Some(0),
retry_delay: Some(1000),
skip_ssl_verification: Some(false),
proxy: None,
rate_limit_delay: Some(0),
output_format: Some("text".to_string()),
verbose: Some(false),
use_head_requests: Some(false), }
}
}
impl Config {
pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self, Box<dyn std::error::Error>> {
let content = fs::read_to_string(path)?;
let config: Config = toml::from_str(&content)?;
Ok(config)
}
pub fn load_from_standard_locations() -> Self {
if let Ok(config) = Self::load_from_file(".urlsup.toml") {
return config;
}
for i in 1..=3 {
let path = format!("{}.urlsup.toml", "../".repeat(i));
if let Ok(config) = Self::load_from_file(&path) {
return config;
}
}
Self::default()
}
pub fn merge_with_cli(&mut self, cli_config: &CliConfig) {
if let Some(timeout) = cli_config.timeout {
self.timeout = Some(timeout);
}
if let Some(ref file_types) = cli_config.file_types {
self.file_types = Some(file_types.clone());
}
if let Some(ref allowlist) = cli_config.allowlist {
self.allowlist = Some(allowlist.clone());
}
if let Some(ref allowed_status_codes) = cli_config.allowed_status_codes {
self.allowed_status_codes = Some(allowed_status_codes.clone());
}
if let Some(ref exclude_patterns) = cli_config.exclude_patterns {
self.exclude_patterns = Some(exclude_patterns.clone());
}
if let Some(threads) = cli_config.threads {
self.threads = Some(threads);
}
if let Some(retry_attempts) = cli_config.retry_attempts {
self.retry_attempts = Some(retry_attempts);
}
if let Some(retry_delay) = cli_config.retry_delay {
self.retry_delay = Some(retry_delay);
}
if let Some(rate_limit_delay) = cli_config.rate_limit_delay {
self.rate_limit_delay = Some(rate_limit_delay);
}
if cli_config.allow_timeout {
self.allow_timeout = Some(true);
}
if cli_config.verbose {
self.verbose = Some(true);
}
if let Some(ref output_format) = cli_config.output_format {
self.output_format = Some(output_format.clone());
}
if let Some(ref user_agent) = cli_config.user_agent {
self.user_agent = Some(user_agent.clone());
}
if let Some(ref proxy) = cli_config.proxy {
self.proxy = Some(proxy.clone());
}
if cli_config.skip_ssl_verification {
self.skip_ssl_verification = Some(true);
}
}
pub fn compile_exclude_patterns(&self) -> Result<Vec<Regex>, Box<dyn std::error::Error>> {
let mut compiled = Vec::new();
if let Some(ref patterns) = self.exclude_patterns {
for pattern in patterns {
compiled.push(Regex::new(pattern)?);
}
}
Ok(compiled)
}
pub fn file_types_as_set(&self) -> Option<HashSet<String>> {
self.file_types
.as_ref()
.map(|types| types.iter().cloned().collect())
}
pub fn timeout_duration(&self) -> Duration {
Duration::from_secs(self.timeout.unwrap_or(30))
}
pub fn retry_delay_duration(&self) -> Duration {
Duration::from_millis(self.retry_delay.unwrap_or(1000))
}
pub fn rate_limit_delay_duration(&self) -> Duration {
Duration::from_millis(self.rate_limit_delay.unwrap_or(0))
}
}
#[derive(Debug, Default)]
pub struct CliConfig {
pub timeout: Option<u64>,
pub file_types: Option<Vec<String>>, pub allowlist: Option<Vec<String>>, pub allowed_status_codes: Option<Vec<u16>>, pub exclude_patterns: Option<Vec<String>>,
pub threads: Option<usize>, pub retry_attempts: Option<u8>, pub retry_delay: Option<u64>, pub rate_limit_delay: Option<u64>, pub allow_timeout: bool,
pub quiet: bool, pub verbose: bool, pub output_format: Option<String>, pub no_progress: bool,
pub user_agent: Option<String>, pub proxy: Option<String>, pub skip_ssl_verification: bool,
pub config_file: Option<String>, pub no_config: bool, }
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn test_config_default() {
let config = Config::default();
assert_eq!(config.timeout, Some(30));
assert_eq!(config.allow_timeout, Some(false));
assert_eq!(config.retry_attempts, Some(0));
assert_eq!(config.output_format, Some("text".to_string()));
}
#[test]
fn test_config_load_from_file() -> Result<(), Box<dyn std::error::Error>> {
let mut file = tempfile::NamedTempFile::new()?;
file.write_all(b"timeout = 60\nallow_timeout = true\nuser_agent = \"test-agent\"")?;
let config = Config::load_from_file(file.path())?;
assert_eq!(config.timeout, Some(60));
assert_eq!(config.allow_timeout, Some(true));
assert_eq!(config.user_agent, Some("test-agent".to_string()));
Ok(())
}
#[test]
fn test_config_merge_with_cli() {
let mut config = Config::default();
let cli_config = CliConfig {
timeout: Some(45),
allow_timeout: true,
verbose: true,
..Default::default()
};
config.merge_with_cli(&cli_config);
assert_eq!(config.timeout, Some(45));
assert_eq!(config.allow_timeout, Some(true));
assert_eq!(config.verbose, Some(true));
}
#[test]
fn test_compile_exclude_patterns() -> Result<(), Box<dyn std::error::Error>> {
let config = Config {
exclude_patterns: Some(vec![
r"^https://example\.com/.*".to_string(),
r".*\.local$".to_string(),
]),
..Default::default()
};
let patterns = config.compile_exclude_patterns()?;
assert_eq!(patterns.len(), 2);
assert!(patterns[0].is_match("https://example.com/test"));
assert!(!patterns[0].is_match("https://other.com/test"));
assert!(patterns[1].is_match("http://test.local"));
assert!(!patterns[1].is_match("http://test.com"));
Ok(())
}
#[test]
fn test_compile_exclude_patterns_empty() -> Result<(), Box<dyn std::error::Error>> {
let config = Config {
exclude_patterns: None,
..Default::default()
};
let patterns = config.compile_exclude_patterns()?;
assert_eq!(patterns.len(), 0);
Ok(())
}
#[test]
fn test_compile_exclude_patterns_invalid_regex() {
let config = Config {
exclude_patterns: Some(vec![r"[invalid regex".to_string()]),
..Default::default()
};
assert!(config.compile_exclude_patterns().is_err());
}
#[test]
fn test_file_types_as_set() {
let config = Config {
file_types: Some(vec![
"md".to_string(),
"txt".to_string(),
"html".to_string(),
]),
..Default::default()
};
let set = config.file_types_as_set().unwrap();
assert_eq!(set.len(), 3);
assert!(set.contains("md"));
assert!(set.contains("txt"));
assert!(set.contains("html"));
assert!(!set.contains("py"));
}
#[test]
fn test_file_types_as_set_none() {
let config = Config {
file_types: None,
..Default::default()
};
assert!(config.file_types_as_set().is_none());
}
#[test]
fn test_timeout_duration() {
let config = Config {
timeout: Some(45),
..Default::default()
};
assert_eq!(config.timeout_duration(), Duration::from_secs(45));
let default_config = Config {
timeout: None,
..Default::default()
};
assert_eq!(default_config.timeout_duration(), Duration::from_secs(30));
}
#[test]
fn test_retry_delay_duration() {
let config = Config {
retry_delay: Some(2500),
..Default::default()
};
assert_eq!(config.retry_delay_duration(), Duration::from_millis(2500));
let default_config = Config {
retry_delay: None,
..Default::default()
};
assert_eq!(
default_config.retry_delay_duration(),
Duration::from_millis(1000)
);
}
#[test]
fn test_rate_limit_delay_duration() {
let config = Config {
rate_limit_delay: Some(500),
..Default::default()
};
assert_eq!(
config.rate_limit_delay_duration(),
Duration::from_millis(500)
);
let default_config = Config {
rate_limit_delay: None,
..Default::default()
};
assert_eq!(
default_config.rate_limit_delay_duration(),
Duration::from_millis(0)
);
}
#[test]
fn test_config_load_from_standard_locations() {
let config = Config::load_from_standard_locations();
assert_eq!(config.timeout, Some(30));
assert_eq!(config.allow_timeout, Some(false));
}
#[test]
fn test_config_merge_with_cli_all_fields() {
let mut config = Config::default();
let cli_config = CliConfig {
timeout: Some(60),
file_types: Some(vec!["md".to_string(), "html".to_string()]),
allowlist: Some(vec!["example.com".to_string()]),
allowed_status_codes: Some(vec![404, 429]),
exclude_patterns: Some(vec![r".*\.local$".to_string()]),
threads: Some(8),
retry_attempts: Some(3),
retry_delay: Some(2000),
rate_limit_delay: Some(100),
allow_timeout: true,
quiet: true,
verbose: true,
output_format: Some("json".to_string()),
no_progress: true,
user_agent: Some("test-agent".to_string()),
proxy: Some("http://proxy.test:8080".to_string()),
skip_ssl_verification: true,
config_file: Some("/path/to/config".to_string()),
no_config: true,
};
config.merge_with_cli(&cli_config);
assert_eq!(config.timeout, Some(60));
assert_eq!(
config.file_types,
Some(vec!["md".to_string(), "html".to_string()])
);
assert_eq!(config.allowlist, Some(vec!["example.com".to_string()]));
assert_eq!(config.allowed_status_codes, Some(vec![404, 429]));
assert_eq!(
config.exclude_patterns,
Some(vec![r".*\.local$".to_string()])
);
assert_eq!(config.threads, Some(8));
assert_eq!(config.retry_attempts, Some(3));
assert_eq!(config.retry_delay, Some(2000));
assert_eq!(config.rate_limit_delay, Some(100));
assert_eq!(config.allow_timeout, Some(true));
assert_eq!(config.verbose, Some(true));
assert_eq!(config.output_format, Some("json".to_string()));
assert_eq!(config.user_agent, Some("test-agent".to_string()));
assert_eq!(config.proxy, Some("http://proxy.test:8080".to_string()));
assert_eq!(config.skip_ssl_verification, Some(true));
}
#[test]
fn test_config_load_from_file_invalid_toml() {
let mut file = tempfile::NamedTempFile::new().unwrap();
file.write_all(b"invalid toml content [").unwrap();
let result = Config::load_from_file(file.path());
assert!(result.is_err());
}
#[test]
fn test_config_load_from_file_nonexistent() {
let result = Config::load_from_file("/path/that/does/not/exist.toml");
assert!(result.is_err());
}
#[test]
fn test_cli_config_default() {
let cli_config = CliConfig::default();
assert_eq!(cli_config.timeout, None);
assert_eq!(cli_config.file_types, None);
assert_eq!(cli_config.allowlist, None);
assert_eq!(cli_config.allowed_status_codes, None);
assert_eq!(cli_config.exclude_patterns, None);
assert_eq!(cli_config.threads, None);
assert_eq!(cli_config.retry_attempts, None);
assert_eq!(cli_config.retry_delay, None);
assert_eq!(cli_config.rate_limit_delay, None);
assert!(!cli_config.allow_timeout);
assert!(!cli_config.quiet);
assert!(!cli_config.verbose);
assert_eq!(cli_config.output_format, None);
assert!(!cli_config.no_progress);
assert_eq!(cli_config.user_agent, None);
assert_eq!(cli_config.proxy, None);
assert!(!cli_config.skip_ssl_verification);
assert_eq!(cli_config.config_file, None);
assert!(!cli_config.no_config);
}
#[test]
fn test_config_empty_compile_exclude_patterns() -> Result<(), Box<dyn std::error::Error>> {
let config = Config {
exclude_patterns: Some(vec![]),
..Default::default()
};
let patterns = config.compile_exclude_patterns()?;
assert_eq!(patterns.len(), 0);
Ok(())
}
}