mecab_ko_dict/
file_watcher.rs1use std::path::{Path, PathBuf};
47use std::sync::Arc;
48use std::thread;
49use std::time::Duration;
50
51use crossbeam_channel::{bounded, Receiver, Sender};
52use notify::{
53 event::{Event, EventKind},
54 RecommendedWatcher, RecursiveMode, Watcher,
55};
56
57use crate::error::{DictError, Result};
58use crate::hot_reload::HotReloadDictionary;
59
60#[derive(Debug, Clone)]
62pub struct WatchConfig {
63 pub debounce_ms: u64,
65 pub recursive: bool,
67 pub watch_extensions: Vec<String>,
69 pub ignore_patterns: Vec<String>,
71}
72
73impl Default for WatchConfig {
74 fn default() -> Self {
75 Self {
76 debounce_ms: 300,
77 recursive: false,
78 watch_extensions: vec![
79 "dic".to_string(),
80 "bin".to_string(),
81 "def".to_string(),
82 "csv".to_string(),
83 "zst".to_string(),
84 ],
85 ignore_patterns: vec![".tmp".to_string(), ".swp".to_string(), "~".to_string()],
86 }
87 }
88}
89
90impl WatchConfig {
91 #[must_use]
93 pub const fn debounce_ms(mut self, ms: u64) -> Self {
94 self.debounce_ms = ms;
95 self
96 }
97
98 #[must_use]
100 pub const fn recursive(mut self, recursive: bool) -> Self {
101 self.recursive = recursive;
102 self
103 }
104
105 #[must_use]
107 pub fn watch_extension(mut self, ext: impl Into<String>) -> Self {
108 self.watch_extensions.push(ext.into());
109 self
110 }
111
112 #[must_use]
114 pub fn ignore_pattern(mut self, pattern: impl Into<String>) -> Self {
115 self.ignore_patterns.push(pattern.into());
116 self
117 }
118
119 fn should_watch(&self, path: &Path) -> bool {
121 let path_str = path.to_string_lossy();
122
123 for pattern in &self.ignore_patterns {
125 if path_str.contains(pattern) {
126 return false;
127 }
128 }
129
130 if let Some(ext) = path.extension() {
132 let ext_str = ext.to_string_lossy();
133 return self.watch_extensions.iter().any(|e| e == &*ext_str);
134 }
135
136 false
137 }
138}
139
140#[derive(Debug, Clone)]
142pub enum FileEvent {
143 Created(PathBuf),
145 Modified(PathBuf),
147 Deleted(PathBuf),
149 Renamed {
151 from: PathBuf,
153 to: PathBuf,
155 },
156}
157
158pub struct FileWatcher {
160 dict: Arc<HotReloadDictionary>,
162 config: WatchConfig,
164 watcher: Option<RecommendedWatcher>,
166 event_rx: Option<Receiver<notify::Result<Event>>>,
168 stop_tx: Option<Sender<()>>,
170 worker_handle: Option<thread::JoinHandle<()>>,
172}
173
174impl FileWatcher {
175 pub const fn new(dict: Arc<HotReloadDictionary>, config: WatchConfig) -> Result<Self> {
186 Ok(Self {
187 dict,
188 config,
189 watcher: None,
190 event_rx: None,
191 stop_tx: None,
192 worker_handle: None,
193 })
194 }
195
196 pub fn new_default(dict: Arc<HotReloadDictionary>) -> Result<Self> {
202 Self::new(dict, WatchConfig::default())
203 }
204
205 pub fn start(&mut self) -> Result<()> {
211 if self.watcher.is_some() {
212 return Err(DictError::Format(
213 "File watcher already started".to_string(),
214 ));
215 }
216
217 let (tx, rx) = bounded(100);
218 let (stop_tx, stop_rx) = bounded(1);
219
220 let mut watcher = RecommendedWatcher::new(
222 tx,
223 notify::Config::default()
224 .with_poll_interval(Duration::from_millis(self.config.debounce_ms)),
225 )
226 .map_err(|e| DictError::Format(format!("Failed to create watcher: {e}")))?;
227
228 let dicdir = self.dict.dicdir();
230 let recursive_mode = if self.config.recursive {
231 RecursiveMode::Recursive
232 } else {
233 RecursiveMode::NonRecursive
234 };
235
236 watcher
237 .watch(dicdir, recursive_mode)
238 .map_err(|e| DictError::Format(format!("Failed to watch directory: {e}")))?;
239
240 self.watcher = Some(watcher);
241 self.event_rx = Some(rx);
242 self.stop_tx = Some(stop_tx);
243
244 self.start_worker(stop_rx)?;
246
247 Ok(())
248 }
249
250 pub fn stop(&mut self) -> Result<()> {
256 if let Some(stop_tx) = self.stop_tx.take() {
257 let _ = stop_tx.send(());
258 }
259
260 if let Some(handle) = self.worker_handle.take() {
261 let _ = handle.join();
262 }
263
264 self.watcher = None;
265 self.event_rx = None;
266
267 Ok(())
268 }
269
270 #[must_use]
272 pub const fn is_watching(&self) -> bool {
273 self.watcher.is_some()
274 }
275
276 fn start_worker(&mut self, stop_rx: Receiver<()>) -> Result<()> {
278 let event_rx = self
279 .event_rx
280 .as_ref()
281 .ok_or_else(|| DictError::Format("Event receiver not initialized".to_string()))?;
282
283 let dict = Arc::clone(&self.dict);
284 let config = self.config.clone();
285 let rx = event_rx.clone();
286
287 let handle = thread::spawn(move || {
288 Self::worker_loop(&dict, &config, &rx, &stop_rx);
289 });
290
291 self.worker_handle = Some(handle);
292
293 Ok(())
294 }
295
296 fn worker_loop(
298 dict: &Arc<HotReloadDictionary>,
299 config: &WatchConfig,
300 event_rx: &Receiver<notify::Result<Event>>,
301 stop_rx: &Receiver<()>,
302 ) {
303 loop {
304 if stop_rx.try_recv().is_ok() {
306 break;
307 }
308
309 match event_rx.recv_timeout(Duration::from_millis(100)) {
311 Ok(Ok(event)) => {
312 Self::handle_event(dict, config, event);
313 }
314 Ok(Err(e)) => {
315 eprintln!("File watcher error: {e}");
316 }
317 Err(crossbeam_channel::RecvTimeoutError::Timeout) => {
318 }
320 Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
321 break;
323 }
324 }
325 }
326 }
327
328 fn handle_event(dict: &Arc<HotReloadDictionary>, config: &WatchConfig, event: Event) {
330 match event.kind {
331 EventKind::Create(_) | EventKind::Modify(_) => {
332 for path in event.paths {
333 if config.should_watch(&path) {
334 Self::reload_dictionary(dict, &path);
335 }
336 }
337 }
338 EventKind::Remove(_) | EventKind::Access(_) | EventKind::Any | EventKind::Other => {
339 }
341 }
342 }
343
344 fn reload_dictionary(dict: &Arc<HotReloadDictionary>, path: &Path) {
346 if let Some(filename) = path.file_name() {
347 let filename_str = filename.to_string_lossy();
348
349 if filename_str.contains("sys.dic")
351 || filename_str.contains("matrix")
352 || filename_str.ends_with(".zst")
353 {
354 match dict.reload_system_dict() {
355 Ok(version) => {
356 println!("Dictionary reloaded successfully (version {version})");
357 }
358 Err(e) => {
359 eprintln!("Failed to reload dictionary: {e}");
360 }
361 }
362 }
363 }
364 }
365}
366
367impl Drop for FileWatcher {
368 fn drop(&mut self) {
369 let _ = self.stop();
370 }
371}
372
373#[cfg(test)]
374#[allow(clippy::panic)]
375mod tests {
376 use super::*;
377
378 #[test]
379 fn test_watch_config_default() {
380 let config = WatchConfig::default();
381 assert_eq!(config.debounce_ms, 300);
382 assert!(!config.recursive);
383 assert!(config.watch_extensions.contains(&"dic".to_string()));
384 }
385
386 #[test]
387 fn test_watch_config_builder() {
388 let config = WatchConfig::default()
389 .debounce_ms(500)
390 .recursive(true)
391 .watch_extension("txt")
392 .ignore_pattern(".bak");
393
394 assert_eq!(config.debounce_ms, 500);
395 assert!(config.recursive);
396 assert!(config.watch_extensions.contains(&"txt".to_string()));
397 assert!(config.ignore_patterns.contains(&".bak".to_string()));
398 }
399
400 #[test]
401 fn test_should_watch() {
402 let config = WatchConfig::default();
403
404 assert!(config.should_watch(Path::new("test.dic")));
405 assert!(config.should_watch(Path::new("matrix.bin")));
406 assert!(config.should_watch(Path::new("user.csv")));
407 assert!(!config.should_watch(Path::new("test.txt")));
408 assert!(!config.should_watch(Path::new("test.dic~")));
409 assert!(!config.should_watch(Path::new(".test.dic.swp")));
410 }
411
412 #[test]
413 fn test_file_event_types() {
414 let created = FileEvent::Created(PathBuf::from("test.dic"));
415 let modified = FileEvent::Modified(PathBuf::from("test.dic"));
416 let deleted = FileEvent::Deleted(PathBuf::from("test.dic"));
417 let renamed = FileEvent::Renamed {
418 from: PathBuf::from("old.dic"),
419 to: PathBuf::from("new.dic"),
420 };
421
422 assert!(
423 matches!(created, FileEvent::Created(_)),
424 "Expected Created event"
425 );
426
427 assert!(
428 matches!(modified, FileEvent::Modified(_)),
429 "Expected Modified event"
430 );
431
432 assert!(
433 matches!(deleted, FileEvent::Deleted(_)),
434 "Expected Deleted event"
435 );
436
437 assert!(
438 matches!(renamed, FileEvent::Renamed { .. }),
439 "Expected Renamed event"
440 );
441 }
442}