use std::path::{Path, PathBuf};
use std::sync::mpsc;
use notify::{EventKind, RecursiveMode, Watcher};
use tower_rules::{RuleId, SchemaVersion, TowerRule};
use crate::rules::{
parse::{parse_rule_json, parse_rule_yaml, ParseError},
validate::{validate, RuleError, RuleWarning},
};
#[derive(Debug, Clone, thiserror::Error)]
pub enum SkipReason {
#[error("parse error: {0}")]
ParseError(String),
#[error("validation error: {0}")]
ValidationError(String),
#[error("future schema version '{found}' — upgrade tower to load this rule")]
FutureVersion { found: String },
}
#[derive(Debug)]
pub struct SkippedFile {
pub path: PathBuf,
pub reason: SkipReason,
}
pub struct LoadResult {
pub loaded: Vec<TowerRule>,
pub warnings: Vec<(RuleId, Vec<RuleWarning>)>,
pub skipped: Vec<SkippedFile>,
}
#[derive(Debug)]
pub enum WatchEvent {
Updated { path: PathBuf, rule: TowerRule },
Removed { path: PathBuf, rule_id: RuleId },
Error { path: PathBuf, reason: SkipReason },
}
pub struct RuleWatcher {
_watcher: notify::RecommendedWatcher,
}
#[derive(Clone)]
pub struct RuleStore {
rules_dir: PathBuf,
}
impl RuleStore {
pub fn new(rules_dir: impl Into<PathBuf>) -> Self {
Self { rules_dir: rules_dir.into() }
}
pub fn rules_dir(&self) -> &Path {
&self.rules_dir
}
pub fn load_all(&self) -> LoadResult {
let mut loaded = Vec::new();
let mut warnings = Vec::new();
let mut skipped = Vec::new();
let entries = match std::fs::read_dir(&self.rules_dir) {
Ok(it) => it,
Err(_) => return LoadResult { loaded, warnings, skipped },
};
for entry in entries.flatten() {
let path = entry.path();
if !is_rule_file(&path) {
continue;
}
match self.load_file(&path) {
Ok((rule, warns)) => {
if !warns.is_empty() {
warnings.push((rule.id.clone(), warns));
}
loaded.push(rule);
}
Err(reason) => skipped.push(SkippedFile { path, reason }),
}
}
LoadResult { loaded, warnings, skipped }
}
pub fn load_file(&self, path: &Path) -> Result<(TowerRule, Vec<RuleWarning>), SkipReason> {
let src = std::fs::read_to_string(path)
.map_err(|e| SkipReason::ParseError(e.to_string()))?;
let version = probe_schema_version(path, &src)?;
let _ = version;
let rule = parse_src(path, &src).map_err(|e| SkipReason::ParseError(e.to_string()))?;
let warns = validate(&rule).map_err(|e: RuleError| SkipReason::ValidationError(e.to_string()))?;
Ok((rule, warns))
}
pub fn watch(&self) -> Result<(RuleWatcher, mpsc::Receiver<WatchEvent>), notify::Error> {
let (watch_tx, watch_rx) = mpsc::channel::<WatchEvent>();
let (notify_tx, notify_rx) = mpsc::channel::<notify::Result<notify::Event>>();
let mut watcher = notify::recommended_watcher(notify_tx)?;
watcher.watch(&self.rules_dir, RecursiveMode::NonRecursive)?;
let store = self.clone();
std::thread::Builder::new()
.name("tower-rule-watcher".into())
.stack_size(8 * 1024 * 1024)
.spawn(move || {
for result in notify_rx {
let event = match result {
Ok(e) => e,
Err(e) => {
eprintln!("tower::storage: notify error: {e}");
continue;
}
};
for path in event.paths {
if !is_rule_file(&path) {
continue;
}
match event.kind {
EventKind::Create(_) | EventKind::Modify(_) => {
let ev = match store.load_file(&path) {
Ok((rule, _)) => WatchEvent::Updated { path, rule },
Err(reason) => WatchEvent::Error { path, reason },
};
let _ = watch_tx.send(ev);
}
EventKind::Remove(_) => {
let rule_id = rule_id_from_path(&path)
.unwrap_or_else(|| RuleId("unknown".into()));
let _ = watch_tx.send(WatchEvent::Removed { path, rule_id });
}
_ => {}
}
}
}
})
.expect("spawn tower-rule-watcher thread");
Ok((RuleWatcher { _watcher: watcher }, watch_rx))
}
}
fn is_rule_file(path: &Path) -> bool {
matches!(
path.extension().and_then(|e| e.to_str()),
Some("yaml") | Some("yml") | Some("json")
)
}
fn rule_id_from_path(path: &Path) -> Option<RuleId> {
path.file_stem().and_then(|s| s.to_str()).map(|s| RuleId(s.to_string()))
}
fn probe_schema_version(path: &Path, src: &str) -> Result<SchemaVersion, SkipReason> {
let raw: serde_yaml::Value = if is_json_path(path) {
serde_json::from_str::<serde_json::Value>(src)
.map_err(|e| SkipReason::ParseError(e.to_string()))
.map(|v| serde_yaml::to_value(v).unwrap_or(serde_yaml::Value::Null))?
} else {
serde_yaml::from_str::<serde_yaml::Value>(src)
.map_err(|e| SkipReason::ParseError(e.to_string()))?
};
let version_str = raw
.get("schema_version")
.and_then(|v| v.as_str())
.unwrap_or("V1");
match version_str {
"V1" => Ok(SchemaVersion::V1),
other => Err(SkipReason::FutureVersion { found: other.to_string() }),
}
}
fn parse_src(path: &Path, src: &str) -> Result<TowerRule, ParseError> {
if is_json_path(path) { parse_rule_json(src) } else { parse_rule_yaml(src) }
}
fn is_json_path(path: &Path) -> bool {
matches!(path.extension().and_then(|e| e.to_str()), Some("json"))
}