use std::sync::Mutex;
use regex::Regex;
use crate::error::CrawlerError;
use crate::utils;
use super::option_type::OptionType;
static EXTRAS_DOMAIN: Mutex<Option<String>> = Mutex::new(None);
#[derive(Debug, Clone)]
pub enum OptionValue {
None,
Bool(bool),
Int(i64),
Float(f64),
Str(String),
Array(Vec<String>),
}
impl OptionValue {
pub fn as_bool(&self) -> Option<bool> {
match self {
OptionValue::Bool(v) => Some(*v),
_ => None,
}
}
pub fn as_int(&self) -> Option<i64> {
match self {
OptionValue::Int(v) => Some(*v),
_ => None,
}
}
pub fn as_float(&self) -> Option<f64> {
match self {
OptionValue::Float(v) => Some(*v),
_ => None,
}
}
pub fn as_str(&self) -> Option<&str> {
match self {
OptionValue::Str(v) => Some(v.as_str()),
_ => None,
}
}
pub fn as_array(&self) -> Option<&Vec<String>> {
match self {
OptionValue::Array(v) => Some(v),
_ => None,
}
}
pub fn is_none(&self) -> bool {
matches!(self, OptionValue::None)
}
}
#[derive(Debug, Clone)]
pub struct CrawlerOption {
pub name: String,
pub alt_name: Option<String>,
pub property_to_fill: String,
pub option_type: OptionType,
pub is_array: bool,
pub description: String,
pub default_value: Option<String>,
pub is_nullable: bool,
pub callable_multiple_times: bool,
pub extras: Option<Vec<String>>,
value: Option<OptionValue>,
is_value_set: bool,
is_explicitly_set: bool,
}
impl CrawlerOption {
#[allow(clippy::too_many_arguments)]
pub fn new(
name: &str,
alt_name: Option<&str>,
property_to_fill: &str,
option_type: OptionType,
is_array: bool,
description: &str,
default_value: Option<&str>,
is_nullable: bool,
callable_multiple_times: bool,
extras: Option<Vec<String>>,
) -> Self {
Self {
name: name.to_string(),
alt_name: alt_name.map(|s| s.to_string()),
property_to_fill: property_to_fill.to_string(),
option_type,
is_array,
description: description.to_string(),
default_value: default_value.map(|s| s.to_string()),
is_nullable,
callable_multiple_times,
extras,
value: None,
is_value_set: false,
is_explicitly_set: false,
}
}
pub fn set_value_from_argv(&mut self, argv: &[String]) -> Result<(), CrawlerError> {
if self.is_value_set {
return Err(CrawlerError::Config(format!(
"Value for option {} is already set. Did you call set_value_from_argv() twice?",
self.name
)));
}
let mut value: Option<String> = self.default_value.clone();
let mut array_values: Vec<String> = if self.is_array {
if let Some(ref dv) = self.default_value {
if dv.is_empty() { Vec::new() } else { vec![dv.clone()] }
} else {
Vec::new()
}
} else {
Vec::new()
};
let mut has_default_been_replaced = false;
let mut defined_by_alt_name = false;
let mut i = 0;
while i < argv.len() {
let arg = &argv[i];
let mut arg_value: Option<String> = None;
if arg == &self.name || self.alt_name.as_deref() == Some(arg.as_str()) {
if self.option_type == OptionType::Bool {
arg_value = Some("true".to_string());
} else {
if i + 1 < argv.len() && !argv[i + 1].starts_with('-') {
i += 1;
arg_value = Some(argv[i].clone());
} else {
arg_value = Some(String::new());
}
}
} else if let Some(rest) = arg.strip_prefix(&format!("{}=", self.name)) {
arg_value = Some(rest.to_string());
} else if let Some(ref alt) = self.alt_name
&& let Some(rest) = arg.strip_prefix(&format!("{}=", alt))
{
arg_value = Some(rest.to_string());
defined_by_alt_name = true;
}
if let Some(ref mut av) = arg_value {
self.is_explicitly_set = true;
unquote_value(av);
if self.is_array {
if !has_default_been_replaced {
array_values.clear();
has_default_been_replaced = true;
}
if av.contains(',') {
let parts: Vec<String> = av
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.map(|mut s| {
unquote_value(&mut s);
s
})
.collect();
array_values.extend(parts);
} else {
array_values.push(av.clone());
}
} else {
value = Some(av.clone());
}
}
i += 1;
}
if self.is_array
&& let Some(ref v) = value
&& !v.is_empty()
&& array_values.is_empty()
{
let mut unquoted = v.clone();
unquote_value(&mut unquoted);
let parts: Vec<String> = unquoted
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.map(|mut s| {
unquote_value(&mut s);
s
})
.collect();
array_values = parts;
}
if self.is_array {
for item in &array_values {
self.validate_value(Some(item), defined_by_alt_name)?;
}
let filtered: Vec<String> = array_values.into_iter().filter(|s| !s.trim().is_empty()).collect();
self.value = Some(OptionValue::Array(filtered));
} else {
self.validate_value(value.as_deref(), defined_by_alt_name)?;
self.value = Some(self.correct_value_type(value.as_deref())?);
}
self.is_value_set = true;
Ok(())
}
pub fn is_explicitly_set(&self) -> bool {
self.is_explicitly_set
}
pub fn get_value(&self) -> Result<&OptionValue, CrawlerError> {
if !self.is_value_set {
return Err(CrawlerError::Config(format!(
"Value for option {} is not set. Did you call set_value_from_argv()?",
self.name
)));
}
match &self.value {
Some(v) => Ok(v),
None => Err(CrawlerError::Config(format!(
"Value for option {} is not set",
self.name
))),
}
}
fn validate_value(&self, value: Option<&str>, _defined_by_alt_name: bool) -> Result<(), CrawlerError> {
let display_name = &self.name;
if self.is_nullable && (value.is_none() || value == Some("")) {
return Ok(());
}
let val = match value {
Some(v) => v,
None => {
if !self.is_nullable {
if self.option_type == OptionType::Url {
return Err(CrawlerError::Config(format!(
"Option {} must be valid URL (starting with http:// or https://)",
display_name
)));
}
return Err(CrawlerError::Config(format!("Option {} is required", display_name)));
}
return Ok(());
}
};
match self.option_type {
OptionType::Int => {
let parsed: Result<i64, _> = val.parse();
match parsed {
Ok(n) if n < 0 => {
return Err(CrawlerError::Config(format!(
"Option {} ({}) must be positive integer",
display_name, val
)));
}
Err(_) => {
return Err(CrawlerError::Config(format!(
"Option {} ({}) must be positive integer",
display_name, val
)));
}
_ => {}
}
}
OptionType::Float => {
if val.parse::<f64>().is_err() {
return Err(CrawlerError::Config(format!(
"Option {} ({}) must be float",
display_name, val
)));
}
}
OptionType::Bool => {
if !["1", "0", "yes", "no", "true", "false"].contains(&val) {
return Err(CrawlerError::Config(format!(
"Option {} ({}) must be boolean (1/0, yes/no, true/false)",
display_name, val
)));
}
}
OptionType::String => {
}
OptionType::SizeMG => {
let re = Regex::new(r"^\d+(\.\d+)?[MG]$").map_err(|e| CrawlerError::Config(e.to_string()))?;
if !re.is_match(val) {
return Err(CrawlerError::Config(format!(
"Option {} ({}) must be string with M/G suffix (for example 512M or 1.5G)",
display_name, val
)));
}
}
OptionType::Regex => {
if fancy_regex::Regex::new(val).is_err() {
return Err(CrawlerError::Config(format!(
"Option {} ({}) must be valid PCRE regular expression",
display_name, val
)));
}
}
OptionType::Url => {
let corrected = correct_url(val);
if corrected.is_empty() {
return Err(CrawlerError::Config(format!(
"Option {} must be valid URL (starting with http:// or https://)",
display_name
)));
}
if url::Url::parse(&corrected).is_err() {
let encoded: String = corrected
.chars()
.map(|c| {
if c.is_ascii_graphic() || c == ' ' {
c.to_string()
} else {
percent_encoding::utf8_percent_encode(
&c.to_string(),
percent_encoding::NON_ALPHANUMERIC,
)
.to_string()
}
})
.collect();
if url::Url::parse(&encoded).is_err() {
return Err(CrawlerError::Config(format!(
"Option {} ({}) must be valid URL",
display_name, val
)));
}
}
}
OptionType::Email => {
if !val.contains('@') || !val.contains('.') {
return Err(CrawlerError::Config(format!(
"Option {} ({}) must be valid email '{}'",
display_name, val, val
)));
}
}
OptionType::File => {
}
OptionType::Dir => {
if val == "off" || val.is_empty() {
return Ok(());
}
let mut path = val.to_string();
replace_placeholders(&mut path);
let abs_path = utils::get_absolute_path(&path);
if abs_path.trim().is_empty() {
return Err(CrawlerError::Config(format!(
"Option {} ({}) must be string",
display_name, val
)));
}
let dir_path = std::path::Path::new(&abs_path);
if !dir_path.exists() && std::fs::create_dir_all(dir_path).is_err() {
return Err(CrawlerError::Config(format!(
"Option {} ({}) must be valid and writable directory. Check permissions.",
display_name, abs_path
)));
}
}
OptionType::HostAndPort => {
let re = Regex::new(r"^[a-zA-Z0-9\-.:]{1,100}:[0-9]{1,5}$")
.map_err(|e| CrawlerError::Config(e.to_string()))?;
if !re.is_match(val) {
return Err(CrawlerError::Config(format!(
"Option {} ({}) must be in format host:port",
display_name, val
)));
}
}
OptionType::ReplaceContent => {
let re = Regex::new(r"^.+->").map_err(|e| CrawlerError::Config(e.to_string()))?;
if !re.is_match(val) {
return Err(CrawlerError::Config(format!(
"Option {} ({}) must be in format `foo -> bar` or `/preg-regexp/ -> bar`)",
display_name, val
)));
}
let parts: Vec<&str> = val.splitn(2, "->").collect();
let replace_from = parts[0].trim();
let is_regex = crate::utils::is_regex_pattern(replace_from);
if is_regex && Regex::new(replace_from).is_err() {
return Err(CrawlerError::Config(format!(
"Option {} and its first part ({}) must be valid PCRE regular expression",
display_name, replace_from
)));
}
}
OptionType::Resolve => {
let re = Regex::new(r"^[a-zA-Z0-9\-.]{1,200}:[0-9]{1,5}:[a-fA-F0-9\-.:]{1,100}$")
.map_err(|e| CrawlerError::Config(e.to_string()))?;
if !re.is_match(val) {
return Err(CrawlerError::Config(format!(
"Option {} ({}) must be in format `domain:port:ip`",
display_name, val
)));
}
}
}
if (self.option_type == OptionType::Int || self.option_type == OptionType::Float)
&& self.extras.as_ref().map(|e| e.len()) == Some(2)
&& let Ok(num) = val.parse::<f64>()
{
let extras = self.extras.as_ref().map(|e| {
let min = e[0].parse::<f64>().unwrap_or(f64::MIN);
let max = e[1].parse::<f64>().unwrap_or(f64::MAX);
(min, max)
});
if let Some((min, max)) = extras
&& (num < min || num > max)
{
return Err(CrawlerError::Config(format!(
"Option {} ({}) must be in range {}-{}",
display_name, val, min, max
)));
}
}
Ok(())
}
fn correct_value_type(&self, value: Option<&str>) -> Result<OptionValue, CrawlerError> {
if self.is_nullable && (value.is_none() || value == Some("")) {
return Ok(OptionValue::None);
}
let val = match value {
Some(v) => v,
None => return Ok(OptionValue::None),
};
match self.option_type {
OptionType::Int => {
let n = val
.parse::<i64>()
.map_err(|_| CrawlerError::Config(format!("Cannot parse '{}' as integer", val)))?;
Ok(OptionValue::Int(n))
}
OptionType::Float => {
let n = val
.parse::<f64>()
.map_err(|_| CrawlerError::Config(format!("Cannot parse '{}' as float", val)))?;
Ok(OptionValue::Float(n))
}
OptionType::Bool => {
let b = ["1", "yes", "true"].contains(&val);
Ok(OptionValue::Bool(b))
}
OptionType::String
| OptionType::SizeMG
| OptionType::Regex
| OptionType::Email
| OptionType::HostAndPort
| OptionType::ReplaceContent
| OptionType::Resolve => Ok(OptionValue::Str(val.to_string())),
OptionType::Url => {
let corrected = correct_url(val);
Ok(OptionValue::Str(corrected))
}
OptionType::File => {
let mut path = val.to_string();
replace_placeholders(&mut path);
Ok(OptionValue::Str(utils::get_absolute_path(&path)))
}
OptionType::Dir => {
if val == "off" || val.is_empty() {
return Ok(OptionValue::Str(val.to_string()));
}
let mut path = val.to_string();
replace_placeholders(&mut path);
Ok(OptionValue::Str(utils::get_absolute_path(&path)))
}
}
}
pub fn set_extras_domain(domain: Option<&str>) {
if let Ok(mut d) = EXTRAS_DOMAIN.lock() {
*d = domain.map(|s| s.to_string());
}
}
}
fn correct_url(url: &str) -> String {
if !url.starts_with("http") {
let re = Regex::new(r"^[a-zA-Z0-9\-.:]{1,100}$").ok();
if re.map(|r| r.is_match(url)).unwrap_or(false) {
let default_protocol = if url.contains('.') { "https" } else { "http" };
return format!("{}://{}", default_protocol, url.trim_start_matches('/'));
}
}
url.to_string()
}
fn unquote_value(value: &mut String) {
let bytes = value.as_bytes();
if bytes.len() >= 2 {
let first = bytes[0];
let last = bytes[bytes.len() - 1];
if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') || (first == b'`' && last == b'`') {
*value = value[1..value.len() - 1].to_string();
}
}
}
fn replace_placeholders(value: &mut String) {
let domain = EXTRAS_DOMAIN.lock().ok().and_then(|d| d.clone()).unwrap_or_default();
let now = chrono::Local::now();
let date = now.format("%Y-%m-%d").to_string();
let datetime = now.format("%Y%m%d-%H%M%S").to_string();
*value = value
.replace("%domain%", &domain)
.replace("%date%", &date)
.replace("%datetime%", &datetime);
}