Skip to main content

mecab_ko_dict/
file_watcher.rs

1//! # 파일 변경 감지 모듈
2//!
3//! `notify` 크레이트를 사용하여 사전 파일 변경을 감지하고
4//! 자동으로 핫 리로드를 트리거합니다.
5//!
6//! ## 아키텍처
7//!
8//! ```text
9//! ┌──────────────────────────────────────┐
10//! │ FileWatcher                          │
11//! │  - notify::RecommendedWatcher        │
12//! │  - crossbeam::Receiver               │
13//! └──────────────────────────────────────┘
14//!          │
15//!          ▼
16//! ┌──────────────────────────────────────┐
17//! │ File System Events                   │
18//! │  - Create, Modify, Delete            │
19//! └──────────────────────────────────────┘
20//!          │
21//!          ▼
22//! ┌──────────────────────────────────────┐
23//! │ HotReloadDictionary::reload()        │
24//! └──────────────────────────────────────┘
25//! ```
26//!
27//! ## 사용 예제
28//!
29//! ```rust,no_run
30//! use mecab_ko_dict::file_watcher::{FileWatcher, WatchConfig};
31//! use mecab_ko_dict::hot_reload::HotReloadDictionary;
32//! use std::sync::Arc;
33//!
34//! let dict = Arc::new(HotReloadDictionary::new("/path/to/dict").unwrap());
35//! let config = WatchConfig::default();
36//!
37//! let mut watcher = FileWatcher::new(dict.clone(), config).unwrap();
38//! watcher.start().unwrap();
39//!
40//! // 파일 변경 감지 및 자동 리로드
41//! // ...
42//!
43//! watcher.stop().unwrap();
44//! ```
45
46use 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/// 파일 감시 설정
61#[derive(Debug, Clone)]
62pub struct WatchConfig {
63    /// 디바운스 시간 (밀리초)
64    pub debounce_ms: u64,
65    /// 재귀 감시 여부
66    pub recursive: bool,
67    /// 감시할 파일 확장자
68    pub watch_extensions: Vec<String>,
69    /// 무시할 파일 패턴
70    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    /// 디바운스 시간 설정
92    #[must_use]
93    pub const fn debounce_ms(mut self, ms: u64) -> Self {
94        self.debounce_ms = ms;
95        self
96    }
97
98    /// 재귀 감시 설정
99    #[must_use]
100    pub const fn recursive(mut self, recursive: bool) -> Self {
101        self.recursive = recursive;
102        self
103    }
104
105    /// 감시할 파일 확장자 추가
106    #[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    /// 무시할 파일 패턴 추가
113    #[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    /// 파일이 감시 대상인지 확인
120    fn should_watch(&self, path: &Path) -> bool {
121        let path_str = path.to_string_lossy();
122
123        // 무시 패턴 확인
124        for pattern in &self.ignore_patterns {
125            if path_str.contains(pattern) {
126                return false;
127            }
128        }
129
130        // 확장자 확인
131        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/// 파일 변경 이벤트
141#[derive(Debug, Clone)]
142pub enum FileEvent {
143    /// 파일 생성
144    Created(PathBuf),
145    /// 파일 수정
146    Modified(PathBuf),
147    /// 파일 삭제
148    Deleted(PathBuf),
149    /// 파일 이름 변경
150    Renamed {
151        /// 이전 경로
152        from: PathBuf,
153        /// 새 경로
154        to: PathBuf,
155    },
156}
157
158/// 파일 감시자
159pub struct FileWatcher {
160    /// 사전 인스턴스
161    dict: Arc<HotReloadDictionary>,
162    /// 감시 설정
163    config: WatchConfig,
164    /// notify 감시자
165    watcher: Option<RecommendedWatcher>,
166    /// 이벤트 수신자
167    event_rx: Option<Receiver<notify::Result<Event>>>,
168    /// 종료 신호 송신자
169    stop_tx: Option<Sender<()>>,
170    /// 워커 스레드 핸들
171    worker_handle: Option<thread::JoinHandle<()>>,
172}
173
174impl FileWatcher {
175    /// 새 파일 감시자 생성
176    ///
177    /// # Arguments
178    ///
179    /// * `dict` - 핫 리로드 사전 인스턴스
180    /// * `config` - 감시 설정
181    ///
182    /// # Errors
183    ///
184    /// Currently always succeeds, but returns Result for future extensibility.
185    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    /// 기본 설정으로 파일 감시자 생성
197    ///
198    /// # Errors
199    ///
200    /// Currently always succeeds, but returns Result for future extensibility.
201    pub fn new_default(dict: Arc<HotReloadDictionary>) -> Result<Self> {
202        Self::new(dict, WatchConfig::default())
203    }
204
205    /// 파일 감시 시작
206    ///
207    /// # Errors
208    ///
209    /// Returns an error if the watcher cannot be created or the directory cannot be accessed.
210    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        // notify 감시자 생성
221        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        // 사전 디렉토리 감시
229        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        // 워커 스레드 시작
245        self.start_worker(stop_rx)?;
246
247        Ok(())
248    }
249
250    /// 파일 감시 중지
251    ///
252    /// # Errors
253    ///
254    /// Currently always succeeds, but returns Result for API consistency.
255    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    /// 감시 중인지 확인
271    #[must_use]
272    pub const fn is_watching(&self) -> bool {
273        self.watcher.is_some()
274    }
275
276    /// 워커 스레드 시작
277    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    /// 워커 루프
297    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            // 종료 신호 확인
305            if stop_rx.try_recv().is_ok() {
306                break;
307            }
308
309            // 이벤트 수신 (타임아웃 설정)
310            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                    // 타임아웃은 정상
319                }
320                Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
321                    // 채널 닫힘
322                    break;
323                }
324            }
325        }
326    }
327
328    /// 이벤트 처리
329    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                // 파일 삭제 및 기타 이벤트는 무시 (기존 사전 유지)
340            }
341        }
342    }
343
344    /// 사전 리로드
345    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            // 시스템 사전 파일 변경 시
350            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}