Skip to main content

mdql_core/
watcher.rs

1//! Filesystem watcher that detects FK violations when files change on disk.
2
3use std::path::PathBuf;
4use std::sync::mpsc;
5use std::time::{Duration, Instant};
6
7use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
8
9use crate::errors::{MdqlError, ValidationError};
10
11/// Watches a database directory for file changes and re-validates foreign keys.
12pub struct FkWatcher {
13    _watcher: RecommendedWatcher,
14    errors_rx: mpsc::Receiver<Vec<ValidationError>>,
15}
16
17impl FkWatcher {
18    /// Start watching a database directory. On any .md file change,
19    /// re-runs FK validation and sends results on an internal channel.
20    pub fn start(db_path: PathBuf) -> Result<Self, MdqlError> {
21        let (tx, rx) = mpsc::channel();
22
23        let watcher_db_path = db_path.clone();
24        let mut last_run = Instant::now() - Duration::from_secs(10);
25
26        let mut watcher = notify::recommended_watcher(move |res: Result<Event, notify::Error>| {
27            let event = match res {
28                Ok(e) => e,
29                Err(_) => return,
30            };
31
32            // Only react to file changes (create, modify, rename, remove)
33            let dominated = matches!(
34                event.kind,
35                EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
36            );
37            if !dominated {
38                return;
39            }
40
41            // Only react to .md files (ignore .lock, .journal, .swp, .tmp, etc.)
42            let has_md = event.paths.iter().any(|p| {
43                p.extension().and_then(|e| e.to_str()) == Some("md")
44            });
45            if !has_md {
46                return;
47            }
48
49            // Debounce: skip if we validated within the last 500ms
50            let now = Instant::now();
51            if now.duration_since(last_run) < Duration::from_millis(500) {
52                return;
53            }
54            last_run = now;
55
56            // Re-validate
57            if let Ok((_config, _tables, errors)) =
58                crate::loader::load_database(&watcher_db_path)
59            {
60                let fk_errors: Vec<_> = errors
61                    .into_iter()
62                    .filter(|e| e.error_type == "fk_violation" || e.error_type == "fk_missing_table")
63                    .collect();
64                let _ = tx.send(fk_errors);
65            }
66        })
67        .map_err(|e| MdqlError::General(format!("Failed to start file watcher: {}", e)))?;
68
69        watcher
70            .watch(&db_path, RecursiveMode::Recursive)
71            .map_err(|e| MdqlError::General(format!("Failed to watch directory: {}", e)))?;
72
73        Ok(FkWatcher {
74            _watcher: watcher,
75            errors_rx: rx,
76        })
77    }
78
79    /// Non-blocking: drain any pending FK validation results.
80    /// Returns the most recent set of FK errors, or None if no changes detected.
81    pub fn poll(&self) -> Option<Vec<ValidationError>> {
82        let mut latest = None;
83        // Drain all pending messages, keep only the most recent
84        while let Ok(errors) = self.errors_rx.try_recv() {
85            latest = Some(errors);
86        }
87        latest
88    }
89}