use crate::{
core::error::TookaError,
rules::rule::{self, Conditions, DateRange, Range},
utils::date_parser::parse_date,
};
use chrono::{NaiveDate, Utc};
use exif::Reader;
use glob::{self, Pattern};
use std::fs;
use std::io::BufReader;
use std::path::Path;
use std::sync::LazyLock;
const MIN_DATE: (i32, u32, u32) = (1970, 1, 1);
const MAX_DATE: (i32, u32, u32) = (9999, 12, 31);
static MIN_DATE_NAIVE: LazyLock<NaiveDate> = LazyLock::new(|| {
NaiveDate::from_ymd_opt(MIN_DATE.0, MIN_DATE.1, MIN_DATE.2)
.expect("MIN_DATE should be valid")
});
static MAX_DATE_NAIVE: LazyLock<NaiveDate> = LazyLock::new(|| {
NaiveDate::from_ymd_opt(MAX_DATE.0, MAX_DATE.1, MAX_DATE.2)
.expect("MAX_DATE should be valid")
});
pub(crate) fn match_filename_regex(file_path: &Path, pattern: &str) -> Result<bool, TookaError> {
log::debug!(
"Matching file: {} against pattern: {}",
file_path.display(),
pattern
);
let file_name = file_path.file_name().and_then(|s| s.to_str()).unwrap_or("");
let regex = regex::Regex::new(pattern)?;
Ok(regex.is_match(file_name))
}
pub(crate) fn match_extensions(file_path: &Path, extensions: &[String]) -> bool {
log::debug!(
"Matching file: {} against extensions: {:?}",
file_path.display(),
extensions
);
file_path
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext_str| extensions.iter().any(|ext| ext.as_str() == ext_str))
}
pub(crate) fn match_path(file_path: &Path, pattern: &str) -> Result<bool, TookaError> {
log::debug!(
"Matching file: {} against glob pattern: {}",
file_path.display(),
pattern
);
let file_path_str = file_path.to_string_lossy();
let glob_pattern = glob::Pattern::new(pattern)?;
Ok(glob_pattern.matches(&file_path_str))
}
pub(crate) fn match_size_kb(metadata: &fs::Metadata, size_kb: &Range) -> bool {
log::debug!(
"Matching file size: {} against range: {:?}",
metadata.len(),
size_kb
);
let size = metadata.len();
let min = match size_kb.min {
Some(m) => m.saturating_mul(1024),
None => 0,
};
let max = match size_kb.max {
Some(m) => m.saturating_mul(1024),
None => u64::MAX,
};
size >= min && size <= max
}
pub(crate) fn match_mime_type(file_path: &Path, mime_type: &str) -> bool {
log::debug!(
"Matching file: {} against MIME type: {}",
file_path.display(),
mime_type
);
mime_guess::from_path(file_path)
.first()
.is_some_and(|mime| {
let mime_essence = mime.essence_str();
mime_type
.strip_suffix("/*")
.map_or(mime_essence == mime_type, |prefix| {
mime_essence.starts_with(prefix)
})
})
}
fn parse_date_with_fallback(date_str: &str, fallback: NaiveDate) -> NaiveDate {
parse_date(date_str).map_or_else(
|_| {
log::warn!("Invalid date format: {date_str}, using fallback");
fallback
},
|dt| dt.date_naive(),
)
}
fn is_date_in_range(date: NaiveDate, date_range: &DateRange) -> bool {
let from = date_range
.from
.as_ref()
.map_or(*MIN_DATE_NAIVE, |from_str| {
parse_date_with_fallback(from_str, *MIN_DATE_NAIVE)
});
let to = date_range
.to
.as_ref()
.map_or(*MAX_DATE_NAIVE, |to_str| {
parse_date_with_fallback(to_str, *MAX_DATE_NAIVE)
});
date >= from && date <= to
}
pub(crate) fn match_date_range_created(metadata: &fs::Metadata, date_range: &DateRange) -> bool {
log::debug!("Matching against created date range: {date_range:?}");
metadata.created().is_ok_and(|created| {
let created_datetime: chrono::DateTime<Utc> = created.into();
let created_date = created_datetime.date_naive();
is_date_in_range(created_date, date_range)
})
}
pub(crate) fn match_date_range_mod(metadata: &fs::Metadata, date_range: &DateRange) -> bool {
log::debug!("Matching against modified date range: {date_range:?}");
metadata.modified().is_ok_and(|modified| {
let modified_datetime: chrono::DateTime<Utc> = modified.into();
let modified_date = modified_datetime.date_naive();
is_date_in_range(modified_date, date_range)
})
}
pub(crate) fn match_is_symlink(metadata: &fs::Metadata, is_symlink: bool) -> bool {
log::debug!(
"Matching symlink status: {} against expected: {}",
metadata.file_type().is_symlink(),
is_symlink
);
metadata.file_type().is_symlink() == is_symlink
}
pub(crate) fn match_metadata_field(file_path: &Path, field: &rule::MetadataField) -> bool {
log::debug!(
"Checking metadata field match for key '{}' on file '{}'",
field.key,
file_path.display()
);
let file = match fs::File::open(file_path) {
Ok(f) => f,
Err(e) => {
log::warn!("Failed to open file '{}': {}", file_path.display(), e);
return false;
}
};
let mut reader = BufReader::new(file);
let exif = match Reader::new().read_from_container(&mut reader) {
Ok(r) => r,
Err(e) => {
log::debug!("No EXIF data found in '{}': {}", file_path.display(), e);
return false;
}
};
let requested_key = field.key.to_lowercase();
for f in exif.fields() {
let exif_key = format!("EXIF:{:?}", f.tag).to_lowercase();
let value_str = f.display_value().with_unit(&exif).to_string();
if exif_key == requested_key {
log::debug!("Found EXIF key match: '{exif_key}'");
if let Some(pattern_str) = &field.value {
match Pattern::new(pattern_str) {
Ok(pattern) => {
let is_match = pattern.matches(&value_str);
log::debug!(
"Comparing EXIF value '{value_str}' with pattern '{pattern_str}': {is_match}"
);
return is_match;
}
Err(e) => {
log::warn!("Invalid glob pattern '{pattern_str}': {e}");
return false;
}
}
}
log::debug!("EXIF key '{exif_key}' matched without value filter");
return true;
}
}
log::debug!(
"No matching EXIF key '{}' found in file '{}'",
field.key,
file_path.display()
);
false
}
pub fn match_rule_matcher(file_path: &Path, conditions: &Conditions) -> bool {
log::debug!(
"Matching file: {} against conditions: {:?}",
file_path.display(),
conditions
);
let metadata = match fs::symlink_metadata(file_path) {
Ok(m) => m,
Err(e) => {
log::warn!("Failed to read metadata for {}: {}", file_path.display(), e);
return false;
}
};
log::debug!("File metadata: {metadata:?}");
let matches = [
conditions
.filename
.as_ref()
.map_or(Ok(true), |pattern| match_filename_regex(file_path, pattern)),
conditions
.extensions
.as_ref()
.map_or(Ok(true), |exts| Ok(match_extensions(file_path, exts))),
conditions
.path
.as_ref()
.map_or(Ok(true), |pattern| match_path(file_path, pattern)),
conditions
.size_kb
.as_ref()
.map_or(Ok(true), |size| Ok(match_size_kb(&metadata, size))),
conditions
.mime_type
.as_ref()
.map_or(Ok(true), |m| Ok(match_mime_type(file_path, m))),
conditions
.created_date
.as_ref()
.map_or(Ok(true), |date_range| {
Ok(match_date_range_created(&metadata, date_range))
}),
conditions
.modified_date
.as_ref()
.map_or(Ok(true), |date_range| {
Ok(match_date_range_mod(&metadata, date_range))
}),
conditions
.is_symlink
.map_or(Ok(true), |b| Ok(match_is_symlink(&metadata, b))),
conditions
.metadata
.as_ref()
.map_or(Ok(true), |metadata_fields| {
Ok(metadata_fields
.iter()
.all(|field| match_metadata_field(file_path, field)))
}),
];
let any_conditions = conditions.any.unwrap_or(false);
log::debug!("Conditions any: {any_conditions}, matches: {matches:?}");
if any_conditions {
log::debug!("Using OR logic for conditions");
matches.into_iter().any(|m| m.unwrap_or(false))
} else {
log::debug!("Using AND logic for conditions");
matches.into_iter().all(|m| m.unwrap_or(false))
}
}