use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FsEventType {
Created,
Modified,
Deleted,
Renamed,
}
impl std::fmt::Display for FsEventType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Created => write!(f, "created"),
Self::Modified => write!(f, "modified"),
Self::Deleted => write!(f, "deleted"),
Self::Renamed => write!(f, "renamed"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum ReactorAction {
ExecuteCommand {
cmd: String,
args: Vec<String>,
working_dir: Option<String>,
},
InvestigateLogError {
log_pattern: String,
},
Notify {
message: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReactorRule {
pub id: String,
pub name: String,
pub watch_paths: Vec<String>,
#[serde(default)]
pub patterns: Vec<String>,
#[serde(default)]
pub exclude_patterns: Vec<String>,
pub event_types: Vec<FsEventType>,
pub debounce_ms: u64,
pub action: ReactorAction,
pub enabled: bool,
}
impl ReactorRule {
pub fn matches_path(&self, path: &str) -> bool {
if self.patterns.is_empty() {
return !self.is_excluded(path);
}
let matches = self
.patterns
.iter()
.any(|pattern| glob_match(pattern, path));
matches && !self.is_excluded(path)
}
fn is_excluded(&self, path: &str) -> bool {
self.exclude_patterns
.iter()
.any(|pattern| glob_match(pattern, path))
}
pub fn matches_event_type(&self, event_type: &FsEventType) -> bool {
self.event_types.is_empty() || self.event_types.contains(event_type)
}
}
fn glob_match(pattern: &str, path: &str) -> bool {
if pattern == "*" {
return true;
}
if let Some(ext) = pattern.strip_prefix("*.") {
return path.ends_with(&format!(".{ext}"));
}
if let Some(prefix) = pattern.strip_suffix("/**") {
return path.starts_with(prefix);
}
pattern == path
}
#[cfg(test)]
mod tests {
use super::*;
fn test_rule(patterns: Vec<&str>, exclude: Vec<&str>) -> ReactorRule {
ReactorRule {
id: "test".to_string(),
name: "Test".to_string(),
watch_paths: vec![".".to_string()],
patterns: patterns.into_iter().map(|s| s.to_string()).collect(),
exclude_patterns: exclude.into_iter().map(|s| s.to_string()).collect(),
event_types: vec![FsEventType::Modified],
debounce_ms: 1000,
action: ReactorAction::Notify {
message: "test".to_string(),
},
enabled: true,
}
}
#[test]
fn matches_extension_pattern() {
let rule = test_rule(vec!["*.log"], vec![]);
assert!(rule.matches_path("app.log"));
assert!(rule.matches_path("/var/log/app.log"));
assert!(!rule.matches_path("app.txt"));
}
#[test]
fn matches_directory_pattern() {
let rule = test_rule(vec!["src/**"], vec![]);
assert!(rule.matches_path("src/main.rs"));
assert!(rule.matches_path("src/lib/utils.rs"));
assert!(!rule.matches_path("tests/main.rs"));
}
#[test]
fn excludes_patterns() {
let rule = test_rule(vec!["*.rs"], vec!["*.generated.rs"]);
assert!(rule.matches_path("main.rs"));
assert!(!rule.matches_path("bindings.generated.rs"));
}
#[test]
fn empty_patterns_match_everything() {
let rule = test_rule(vec![], vec![]);
assert!(rule.matches_path("anything.txt"));
}
#[test]
fn matches_event_type() {
let rule = test_rule(vec![], vec![]);
assert!(rule.matches_event_type(&FsEventType::Modified));
assert!(!rule.matches_event_type(&FsEventType::Created));
}
#[test]
fn empty_event_types_match_all() {
let mut rule = test_rule(vec![], vec![]);
rule.event_types = vec![];
assert!(rule.matches_event_type(&FsEventType::Created));
assert!(rule.matches_event_type(&FsEventType::Deleted));
}
}