use std::{fs, path::Path};
use crate::core::error::RuleValidationError;
use crate::utils::date_parser::parse_date;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct Rule {
pub id: String,
pub name: String,
pub enabled: bool,
pub description: Option<String>,
pub priority: u32,
pub when: Conditions,
pub then: Vec<Action>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct Conditions {
#[serde(default)]
pub any: Option<bool>,
pub filename: Option<String>,
#[serde(default)]
pub extensions: Option<Vec<String>>,
pub path: Option<String>,
pub size_kb: Option<Range>,
pub mime_type: Option<String>,
pub created_date: Option<DateRange>,
pub modified_date: Option<DateRange>,
pub is_symlink: Option<bool>,
#[serde(default)]
pub metadata: Option<Vec<MetadataField>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct MetadataField {
pub key: String,
pub value: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct Range {
pub min: Option<u64>,
pub max: Option<u64>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct DateRange {
pub from: Option<String>,
pub to: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(tag = "action", rename_all = "lowercase")]
pub enum Action {
Move(MoveAction),
Copy(CopyAction),
Rename(RenameAction),
Delete(DeleteAction),
Execute(ExecuteAction),
Skip,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct MoveAction {
pub to: String,
#[serde(default)]
pub preserve_structure: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct CopyAction {
pub to: String,
#[serde(default)]
pub preserve_structure: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct RenameAction {
pub to: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct DeleteAction {
#[serde(default)]
pub trash: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct ExecuteAction {
pub command: String,
pub args: Vec<String>,
}
impl Rule {
pub fn new_from_file<P: AsRef<Path>>(path: P) -> Result<Vec<Self>, RuleValidationError> {
let content = fs::read_to_string(path)
.map_err(|e| RuleValidationError::InvalidFormat(format!("Failed to read file: {e}")))?;
if content.trim_start().starts_with("rules:") {
let parsed: Result<RulesWrapper, _> = serde_yaml::from_str(&content);
match parsed {
Ok(wrapper) => Ok(wrapper.rules),
Err(e) => Err(RuleValidationError::InvalidFormat(format!(
"YAML parsing failed: {e}"
))),
}
} else {
let rule: Result<Rule, _> = serde_yaml::from_str(&content);
match rule {
Ok(r) => Ok(vec![r]),
Err(e) => Err(RuleValidationError::InvalidFormat(format!(
"YAML parsing failed: {e}"
))),
}
}
}
pub fn validate(&self, deep: bool) -> Result<(), RuleValidationError> {
if !deep {
return Ok(()); }
if self.id.trim().is_empty() {
return Err(RuleValidationError::MissingId);
}
if self.name.trim().is_empty() {
return Err(RuleValidationError::MissingName(self.id.clone()));
}
if self.then.is_empty() {
return Err(RuleValidationError::NoActions(self.id.clone()));
}
if let Some(metadata) = &self.when.metadata {
let mut keys = std::collections::HashSet::new();
for field in metadata {
if !keys.insert(&field.key) {
return Err(RuleValidationError::InvalidCondition(
self.id.clone(),
format!("Duplicate metadata key '{}'", field.key),
));
}
}
}
if let Some(size) = &self.when.size_kb {
if let (Some(min), Some(max)) = (size.min, size.max) {
if min > max {
return Err(RuleValidationError::InvalidCondition(
self.id.clone(),
"Invalid size_kb range: min > max".into(),
));
}
}
}
for (label, date_range) in [
("created_date", &self.when.created_date),
("modified_date", &self.when.modified_date),
] {
if let Some(range) = date_range {
if let Some(from) = &range.from {
if let Err(e) = parse_date(from) {
return Err(RuleValidationError::InvalidCondition(
self.id.clone(),
format!("Invalid {label} 'from' date: {e}"),
));
}
}
if let Some(to) = &range.to {
if let Err(e) = parse_date(to) {
return Err(RuleValidationError::InvalidCondition(
self.id.clone(),
format!("Invalid {label} 'to' date: {e}"),
));
}
}
}
}
if let Some(value) = self.action_validation() {
return value;
}
Ok(())
}
fn action_validation(&self) -> Option<Result<(), RuleValidationError>> {
for (i, action) in self.then.iter().enumerate() {
match action {
Action::Move(inner) => {
if inner.to.trim().is_empty() {
return Some(Err(RuleValidationError::InvalidAction(
self.id.clone(),
i,
"Missing destination path".into(),
)));
}
}
Action::Copy(inner) => {
if inner.to.trim().is_empty() {
return Some(Err(RuleValidationError::InvalidAction(
self.id.clone(),
i,
"Missing destination path".into(),
)));
}
}
Action::Rename(inner) => {
if inner.to.trim().is_empty() {
return Some(Err(RuleValidationError::InvalidAction(
self.id.clone(),
i,
"Missing rename target path".into(),
)));
}
}
Action::Delete(inner) => {
if inner.trash && !self.when.is_symlink.unwrap_or(false) {
log::warn!(
"Rule {}: Delete action with trash enabled but file is not marked as symlink",
self.id
);
}
}
Action::Execute(inner) => {
if inner.command.trim().is_empty() {
return Some(Err(RuleValidationError::InvalidAction(
self.id.clone(),
i,
"Missing command to execute".into(),
)));
}
}
Action::Skip => {}
}
}
None
}
}
#[derive(Debug, Serialize, Deserialize)]
struct RulesWrapper {
pub rules: Vec<Rule>,
}