use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use crossbeam_channel::{bounded, Receiver, Sender};
use notify::{
event::{Event, EventKind},
RecommendedWatcher, RecursiveMode, Watcher,
};
use crate::error::{DictError, Result};
use crate::hot_reload::HotReloadDictionary;
#[derive(Debug, Clone)]
pub struct WatchConfig {
pub debounce_ms: u64,
pub recursive: bool,
pub watch_extensions: Vec<String>,
pub ignore_patterns: Vec<String>,
}
impl Default for WatchConfig {
fn default() -> Self {
Self {
debounce_ms: 300,
recursive: false,
watch_extensions: vec![
"dic".to_string(),
"bin".to_string(),
"def".to_string(),
"csv".to_string(),
"zst".to_string(),
],
ignore_patterns: vec![".tmp".to_string(), ".swp".to_string(), "~".to_string()],
}
}
}
impl WatchConfig {
#[must_use]
pub const fn debounce_ms(mut self, ms: u64) -> Self {
self.debounce_ms = ms;
self
}
#[must_use]
pub const fn recursive(mut self, recursive: bool) -> Self {
self.recursive = recursive;
self
}
#[must_use]
pub fn watch_extension(mut self, ext: impl Into<String>) -> Self {
self.watch_extensions.push(ext.into());
self
}
#[must_use]
pub fn ignore_pattern(mut self, pattern: impl Into<String>) -> Self {
self.ignore_patterns.push(pattern.into());
self
}
fn should_watch(&self, path: &Path) -> bool {
let path_str = path.to_string_lossy();
for pattern in &self.ignore_patterns {
if path_str.contains(pattern) {
return false;
}
}
if let Some(ext) = path.extension() {
let ext_str = ext.to_string_lossy();
return self.watch_extensions.iter().any(|e| e == &*ext_str);
}
false
}
}
#[derive(Debug, Clone)]
pub enum FileEvent {
Created(PathBuf),
Modified(PathBuf),
Deleted(PathBuf),
Renamed {
from: PathBuf,
to: PathBuf,
},
}
pub struct FileWatcher {
dict: Arc<HotReloadDictionary>,
config: WatchConfig,
watcher: Option<RecommendedWatcher>,
event_rx: Option<Receiver<notify::Result<Event>>>,
stop_tx: Option<Sender<()>>,
worker_handle: Option<thread::JoinHandle<()>>,
}
impl FileWatcher {
pub const fn new(dict: Arc<HotReloadDictionary>, config: WatchConfig) -> Result<Self> {
Ok(Self {
dict,
config,
watcher: None,
event_rx: None,
stop_tx: None,
worker_handle: None,
})
}
pub fn new_default(dict: Arc<HotReloadDictionary>) -> Result<Self> {
Self::new(dict, WatchConfig::default())
}
pub fn start(&mut self) -> Result<()> {
if self.watcher.is_some() {
return Err(DictError::Format(
"File watcher already started".to_string(),
));
}
let (tx, rx) = bounded(100);
let (stop_tx, stop_rx) = bounded(1);
let mut watcher = RecommendedWatcher::new(
tx,
notify::Config::default()
.with_poll_interval(Duration::from_millis(self.config.debounce_ms)),
)
.map_err(|e| DictError::Format(format!("Failed to create watcher: {e}")))?;
let dicdir = self.dict.dicdir();
let recursive_mode = if self.config.recursive {
RecursiveMode::Recursive
} else {
RecursiveMode::NonRecursive
};
watcher
.watch(dicdir, recursive_mode)
.map_err(|e| DictError::Format(format!("Failed to watch directory: {e}")))?;
self.watcher = Some(watcher);
self.event_rx = Some(rx);
self.stop_tx = Some(stop_tx);
self.start_worker(stop_rx)?;
Ok(())
}
pub fn stop(&mut self) -> Result<()> {
if let Some(stop_tx) = self.stop_tx.take() {
let _ = stop_tx.send(());
}
if let Some(handle) = self.worker_handle.take() {
let _ = handle.join();
}
self.watcher = None;
self.event_rx = None;
Ok(())
}
#[must_use]
pub const fn is_watching(&self) -> bool {
self.watcher.is_some()
}
fn start_worker(&mut self, stop_rx: Receiver<()>) -> Result<()> {
let event_rx = self
.event_rx
.as_ref()
.ok_or_else(|| DictError::Format("Event receiver not initialized".to_string()))?;
let dict = Arc::clone(&self.dict);
let config = self.config.clone();
let rx = event_rx.clone();
let handle = thread::spawn(move || {
Self::worker_loop(&dict, &config, &rx, &stop_rx);
});
self.worker_handle = Some(handle);
Ok(())
}
fn worker_loop(
dict: &Arc<HotReloadDictionary>,
config: &WatchConfig,
event_rx: &Receiver<notify::Result<Event>>,
stop_rx: &Receiver<()>,
) {
loop {
if stop_rx.try_recv().is_ok() {
break;
}
match event_rx.recv_timeout(Duration::from_millis(100)) {
Ok(Ok(event)) => {
Self::handle_event(dict, config, event);
}
Ok(Err(e)) => {
eprintln!("File watcher error: {e}");
}
Err(crossbeam_channel::RecvTimeoutError::Timeout) => {
}
Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
break;
}
}
}
}
fn handle_event(dict: &Arc<HotReloadDictionary>, config: &WatchConfig, event: Event) {
match event.kind {
EventKind::Create(_) | EventKind::Modify(_) => {
for path in event.paths {
if config.should_watch(&path) {
Self::reload_dictionary(dict, &path);
}
}
}
EventKind::Remove(_) | EventKind::Access(_) | EventKind::Any | EventKind::Other => {
}
}
}
fn reload_dictionary(dict: &Arc<HotReloadDictionary>, path: &Path) {
if let Some(filename) = path.file_name() {
let filename_str = filename.to_string_lossy();
if filename_str.contains("sys.dic")
|| filename_str.contains("matrix")
|| filename_str.ends_with(".zst")
{
match dict.reload_system_dict() {
Ok(version) => {
println!("Dictionary reloaded successfully (version {version})");
}
Err(e) => {
eprintln!("Failed to reload dictionary: {e}");
}
}
}
}
}
}
impl Drop for FileWatcher {
fn drop(&mut self) {
let _ = self.stop();
}
}
#[cfg(test)]
#[allow(clippy::panic)]
mod tests {
use super::*;
#[test]
fn test_watch_config_default() {
let config = WatchConfig::default();
assert_eq!(config.debounce_ms, 300);
assert!(!config.recursive);
assert!(config.watch_extensions.contains(&"dic".to_string()));
}
#[test]
fn test_watch_config_builder() {
let config = WatchConfig::default()
.debounce_ms(500)
.recursive(true)
.watch_extension("txt")
.ignore_pattern(".bak");
assert_eq!(config.debounce_ms, 500);
assert!(config.recursive);
assert!(config.watch_extensions.contains(&"txt".to_string()));
assert!(config.ignore_patterns.contains(&".bak".to_string()));
}
#[test]
fn test_should_watch() {
let config = WatchConfig::default();
assert!(config.should_watch(Path::new("test.dic")));
assert!(config.should_watch(Path::new("matrix.bin")));
assert!(config.should_watch(Path::new("user.csv")));
assert!(!config.should_watch(Path::new("test.txt")));
assert!(!config.should_watch(Path::new("test.dic~")));
assert!(!config.should_watch(Path::new(".test.dic.swp")));
}
#[test]
fn test_file_event_types() {
let created = FileEvent::Created(PathBuf::from("test.dic"));
let modified = FileEvent::Modified(PathBuf::from("test.dic"));
let deleted = FileEvent::Deleted(PathBuf::from("test.dic"));
let renamed = FileEvent::Renamed {
from: PathBuf::from("old.dic"),
to: PathBuf::from("new.dic"),
};
assert!(
matches!(created, FileEvent::Created(_)),
"Expected Created event"
);
assert!(
matches!(modified, FileEvent::Modified(_)),
"Expected Modified event"
);
assert!(
matches!(deleted, FileEvent::Deleted(_)),
"Expected Deleted event"
);
assert!(
matches!(renamed, FileEvent::Renamed { .. }),
"Expected Renamed event"
);
}
}