Skip to main content

codemem_watch/
lib.rs

1//! codemem-watch: Real-time file watcher for Codemem.
2//!
3//! Uses `notify` with debouncing to detect file changes and trigger re-indexing.
4//! Respects `.gitignore` and common ignore patterns.
5
6use crossbeam_channel::Receiver;
7use notify_debouncer_mini::{new_debouncer, DebouncedEventKind};
8use std::collections::HashSet;
9use std::path::{Path, PathBuf};
10use std::time::Duration;
11
12/// Events emitted by the file watcher.
13#[derive(Debug, Clone)]
14pub enum WatchEvent {
15    FileChanged(PathBuf),
16    FileCreated(PathBuf),
17    FileDeleted(PathBuf),
18}
19
20/// Default ignore directory names.
21const IGNORE_DIRS: &[&str] = &[
22    "node_modules",
23    "target",
24    ".git",
25    "__pycache__",
26    ".venv",
27    "venv",
28    ".mypy_cache",
29    ".pytest_cache",
30    "dist",
31    "build",
32    ".next",
33    "vendor",
34    ".cargo",
35];
36
37/// Watchable file extensions (code files).
38const WATCHABLE_EXTENSIONS: &[&str] = &[
39    "rs", "ts", "tsx", "js", "jsx", "py", "go", "c", "cpp", "cc", "cxx", "h", "hpp", "java",
40    "toml", "json", "yaml", "yml",
41];
42
43/// Check if a file extension is watchable.
44pub fn is_watchable(path: &Path) -> bool {
45    path.extension()
46        .and_then(|ext| ext.to_str())
47        .map(|ext| WATCHABLE_EXTENSIONS.contains(&ext))
48        .unwrap_or(false)
49}
50
51/// Check if a path is inside an ignored directory.
52pub fn should_ignore(path: &Path) -> bool {
53    for component in path.components() {
54        if let std::path::Component::Normal(name) = component {
55            if let Some(name_str) = name.to_str() {
56                if IGNORE_DIRS.contains(&name_str) {
57                    return true;
58                }
59            }
60        }
61    }
62    false
63}
64
65/// Detect programming language from file extension.
66pub fn detect_language(path: &Path) -> Option<&'static str> {
67    path.extension()
68        .and_then(|ext| ext.to_str())
69        .and_then(|ext| match ext {
70            "rs" => Some("rust"),
71            "ts" | "tsx" => Some("typescript"),
72            "js" | "jsx" => Some("javascript"),
73            "py" => Some("python"),
74            "go" => Some("go"),
75            "c" | "h" => Some("c"),
76            "cpp" | "cc" | "cxx" | "hpp" => Some("cpp"),
77            "java" => Some("java"),
78            _ => None,
79        })
80}
81
82/// File watcher that monitors a directory for changes with 50ms debouncing.
83pub struct FileWatcher {
84    _debouncer: notify_debouncer_mini::Debouncer<notify::RecommendedWatcher>,
85    receiver: Receiver<WatchEvent>,
86}
87
88impl FileWatcher {
89    /// Create a new file watcher for the given root directory.
90    pub fn new(root: &Path) -> Result<Self, codemem_core::CodememError> {
91        let (tx, rx) = crossbeam_channel::unbounded::<WatchEvent>();
92        let event_tx = tx;
93
94        let mut debouncer = new_debouncer(
95            Duration::from_millis(50),
96            move |res: Result<Vec<notify_debouncer_mini::DebouncedEvent>, notify::Error>| match res
97            {
98                Ok(events) => {
99                    let mut seen = HashSet::new();
100                    for event in events {
101                        let path = event.path;
102                        if !seen.insert(path.clone()) {
103                            continue;
104                        }
105                        if should_ignore(&path) || !is_watchable(&path) {
106                            continue;
107                        }
108                        let watch_event = match event.kind {
109                            DebouncedEventKind::Any => {
110                                if path.exists() {
111                                    WatchEvent::FileChanged(path)
112                                } else {
113                                    WatchEvent::FileDeleted(path)
114                                }
115                            }
116                            DebouncedEventKind::AnyContinuous => WatchEvent::FileChanged(path),
117                            _ => WatchEvent::FileChanged(path),
118                        };
119                        let _ = event_tx.send(watch_event);
120                    }
121                }
122                Err(e) => {
123                    tracing::error!("Watch error: {e}");
124                }
125            },
126        )
127        .map_err(|e| {
128            codemem_core::CodememError::Io(std::io::Error::other(format!(
129                "Failed to create debouncer: {e}"
130            )))
131        })?;
132
133        debouncer
134            .watcher()
135            .watch(root, notify::RecursiveMode::Recursive)
136            .map_err(|e| {
137                codemem_core::CodememError::Io(std::io::Error::other(format!(
138                    "Failed to watch {}: {e}",
139                    root.display()
140                )))
141            })?;
142
143        tracing::info!("Watching {} for changes", root.display());
144
145        Ok(Self {
146            _debouncer: debouncer,
147            receiver: rx,
148        })
149    }
150
151    /// Get the receiver for watch events.
152    pub fn receiver(&self) -> &Receiver<WatchEvent> {
153        &self.receiver
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn test_is_watchable() {
163        assert!(is_watchable(Path::new("src/main.rs")));
164        assert!(is_watchable(Path::new("index.ts")));
165        assert!(is_watchable(Path::new("app.py")));
166        assert!(is_watchable(Path::new("main.go")));
167        assert!(!is_watchable(Path::new("image.png")));
168        assert!(!is_watchable(Path::new("binary.exe")));
169    }
170
171    #[test]
172    fn test_should_ignore() {
173        assert!(should_ignore(Path::new("project/node_modules/foo/bar.js")));
174        assert!(should_ignore(Path::new("project/target/debug/build.rs")));
175        assert!(should_ignore(Path::new(".git/config")));
176        assert!(!should_ignore(Path::new("src/main.rs")));
177        assert!(!should_ignore(Path::new("lib/utils.ts")));
178    }
179
180    #[test]
181    fn test_detect_language() {
182        assert_eq!(detect_language(Path::new("main.rs")), Some("rust"));
183        assert_eq!(detect_language(Path::new("app.tsx")), Some("typescript"));
184        assert_eq!(detect_language(Path::new("script.py")), Some("python"));
185        assert_eq!(detect_language(Path::new("main.go")), Some("go"));
186        assert_eq!(detect_language(Path::new("readme.md")), None);
187    }
188}