use crate::markdown_config::error::{MarkdownConfigError, MarkdownConfigResult};
use crate::markdown_config::loader::ConfigurationLoader;
use notify::{RecursiveMode, Watcher};
use std::path::PathBuf;
use std::sync::mpsc;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use tracing::{debug, error, warn};
pub struct FileWatcher {
loader: Arc<ConfigurationLoader>,
paths: Vec<PathBuf>,
debounce_ms: u64,
is_watching: Arc<RwLock<bool>>,
}
impl FileWatcher {
pub fn new(loader: Arc<ConfigurationLoader>, paths: Vec<PathBuf>) -> Self {
Self {
loader,
paths,
debounce_ms: 500,
is_watching: Arc::new(RwLock::new(false)),
}
}
pub fn with_debounce(
loader: Arc<ConfigurationLoader>,
paths: Vec<PathBuf>,
debounce_ms: u64,
) -> Self {
Self {
loader,
paths,
debounce_ms,
is_watching: Arc::new(RwLock::new(false)),
}
}
pub async fn watch(&self) -> MarkdownConfigResult<()> {
let (tx, rx) = mpsc::channel();
let mut watcher = notify::recommended_watcher(move |res| {
match res {
Ok(event) => {
if let Err(e) = tx.send(event) {
error!("Failed to send file watch event: {}", e);
}
}
Err(e) => {
error!("File watcher error: {}", e);
}
}
})
.map_err(|e| {
MarkdownConfigError::watch_error(format!("Failed to create file watcher: {}", e))
})?;
for path in &self.paths {
if path.exists() {
watcher
.watch(path, RecursiveMode::Recursive)
.map_err(|e| {
MarkdownConfigError::watch_error(format!(
"Failed to watch path {}: {}",
path.display(),
e
))
})?;
debug!("Watching configuration directory: {}", path.display());
}
}
*self.is_watching.write().await = true;
let mut last_reload = std::time::Instant::now();
loop {
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(event) => {
if self.is_config_file_event(&event) {
let now = std::time::Instant::now();
let elapsed = now.duration_since(last_reload);
if elapsed.as_millis() as u64 >= self.debounce_ms {
debug!("Configuration file changed, reloading...");
self.reload_configurations().await;
last_reload = now;
} else {
debug!(
"Debouncing configuration reload ({}ms remaining)",
self.debounce_ms - elapsed.as_millis() as u64
);
}
}
}
Err(mpsc::RecvTimeoutError::Timeout) => {
continue;
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
break;
}
}
}
*self.is_watching.write().await = false;
Ok(())
}
fn is_config_file_event(&self, event: ¬ify::Event) -> bool {
use notify::EventKind;
match event.kind {
EventKind::Modify(_) | EventKind::Create(_) => {}
_ => return false,
}
event.paths.iter().any(|path| {
if let Some(file_name) = path.file_name() {
if let Some(name_str) = file_name.to_str() {
return name_str.ends_with(".agent.md")
|| name_str.ends_with(".mode.md")
|| name_str.ends_with(".command.md");
}
}
false
})
}
async fn reload_configurations(&self) {
match self.loader.load_all(&self.paths).await {
Ok((success, errors, error_list)) => {
debug!(
"Configuration reload complete: {} successful, {} failed",
success, errors
);
if !error_list.is_empty() {
for (path, error) in error_list {
warn!(
"Failed to load configuration from {}: {}",
path.display(),
error
);
}
}
}
Err(e) => {
error!("Failed to reload configurations: {}", e);
}
}
}
pub async fn is_watching(&self) -> bool {
*self.is_watching.read().await
}
pub async fn stop(&self) {
*self.is_watching.write().await = false;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::markdown_config::registry::ConfigRegistry;
#[test]
fn test_file_watcher_creation() {
let registry = Arc::new(ConfigRegistry::new());
let loader = Arc::new(ConfigurationLoader::new(registry));
let paths = vec![PathBuf::from("/tmp")];
let watcher = FileWatcher::new(loader, paths.clone());
assert_eq!(watcher.paths, paths);
assert_eq!(watcher.debounce_ms, 500);
}
#[test]
fn test_file_watcher_custom_debounce() {
let registry = Arc::new(ConfigRegistry::new());
let loader = Arc::new(ConfigurationLoader::new(registry));
let paths = vec![PathBuf::from("/tmp")];
let watcher = FileWatcher::with_debounce(loader, paths, 1000);
assert_eq!(watcher.debounce_ms, 1000);
}
#[test]
fn test_is_config_file_event() {
let registry = Arc::new(ConfigRegistry::new());
let loader = Arc::new(ConfigurationLoader::new(registry));
let paths = vec![PathBuf::from("/tmp")];
let watcher = FileWatcher::new(loader, paths);
let event = notify::Event {
kind: notify::EventKind::Modify(notify::event::ModifyKind::Data(
notify::event::DataChange::Content,
)),
paths: vec![PathBuf::from("/tmp/test.agent.md")],
attrs: Default::default(),
};
assert!(watcher.is_config_file_event(&event));
let event = notify::Event {
kind: notify::EventKind::Modify(notify::event::ModifyKind::Data(
notify::event::DataChange::Content,
)),
paths: vec![PathBuf::from("/tmp/test.mode.md")],
attrs: Default::default(),
};
assert!(watcher.is_config_file_event(&event));
let event = notify::Event {
kind: notify::EventKind::Modify(notify::event::ModifyKind::Data(
notify::event::DataChange::Content,
)),
paths: vec![PathBuf::from("/tmp/test.command.md")],
attrs: Default::default(),
};
assert!(watcher.is_config_file_event(&event));
let event = notify::Event {
kind: notify::EventKind::Modify(notify::event::ModifyKind::Data(
notify::event::DataChange::Content,
)),
paths: vec![PathBuf::from("/tmp/test.md")],
attrs: Default::default(),
};
assert!(!watcher.is_config_file_event(&event));
let event = notify::Event {
kind: notify::EventKind::Access(notify::event::AccessKind::Read),
paths: vec![PathBuf::from("/tmp/test.agent.md")],
attrs: Default::default(),
};
assert!(!watcher.is_config_file_event(&event));
}
#[tokio::test]
async fn test_watcher_is_watching() {
let registry = Arc::new(ConfigRegistry::new());
let loader = Arc::new(ConfigurationLoader::new(registry));
let paths = vec![PathBuf::from("/tmp")];
let watcher = FileWatcher::new(loader, paths);
assert!(!watcher.is_watching().await);
}
}