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 ignore::gitignore::{Gitignore, GitignoreBuilder};
8use notify_debouncer_mini::new_debouncer;
9use std::collections::HashSet;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12use std::time::Duration;
13
14/// Events emitted by the file watcher.
15#[derive(Debug, Clone)]
16pub enum WatchEvent {
17    FileChanged(PathBuf),
18    FileCreated(PathBuf),
19    FileDeleted(PathBuf),
20}
21
22/// Default ignore directory names.
23const IGNORE_DIRS: &[&str] = &[
24    "node_modules",
25    "target",
26    ".git",
27    "__pycache__",
28    ".venv",
29    "venv",
30    ".mypy_cache",
31    ".pytest_cache",
32    "dist",
33    "build",
34    ".next",
35    "vendor",
36    ".cargo",
37];
38
39/// Watchable file extensions (code files).
40const WATCHABLE_EXTENSIONS: &[&str] = &[
41    "rs", "ts", "tsx", "js", "jsx", "py", "go", "c", "cpp", "cc", "cxx", "h", "hpp", "java", "rb",
42    "cs", "kt", "kts", "swift", "php", "scala", "sc", "tf", "hcl", "tfvars", "toml", "json",
43    "yaml", "yml",
44];
45
46/// Check if a file extension is watchable.
47pub fn is_watchable(path: &Path) -> bool {
48    path.extension()
49        .and_then(|ext| ext.to_str())
50        .map(|ext| WATCHABLE_EXTENSIONS.contains(&ext))
51        .unwrap_or(false)
52}
53
54/// Check if a path should be ignored.
55///
56/// Uses the provided `Gitignore` matcher first (checking the full path and
57/// each ancestor directory), then falls back to the hardcoded `IGNORE_DIRS`
58/// list for paths not covered by `.gitignore`.
59pub fn should_ignore(path: &Path, gitignore: Option<&Gitignore>) -> bool {
60    if let Some(gi) = gitignore {
61        // Check the file itself
62        if gi.matched(path, path.is_dir()).is_ignore() {
63            return true;
64        }
65        // Check each ancestor directory against the gitignore
66        let mut current = path.to_path_buf();
67        while current.pop() {
68            if gi.matched(&current, true).is_ignore() {
69                return true;
70            }
71        }
72    }
73    // Fallback to hardcoded dirs
74    for component in path.components() {
75        if let std::path::Component::Normal(name) = component {
76            if let Some(name_str) = name.to_str() {
77                if IGNORE_DIRS.contains(&name_str) {
78                    return true;
79                }
80            }
81        }
82    }
83    false
84}
85
86/// Build a `Gitignore` matcher from a project root.
87///
88/// Reads `.gitignore` if present, and also adds the hardcoded `IGNORE_DIRS`
89/// as fallback patterns.
90pub fn build_gitignore(root: &Path) -> Option<Gitignore> {
91    let mut builder = GitignoreBuilder::new(root);
92    // Add .gitignore if it exists
93    if let Some(err) = builder.add(root.join(".gitignore")) {
94        tracing::debug!("No .gitignore found: {err}");
95    }
96    // Add fallback patterns (use glob-style to match as directories anywhere)
97    for dir in IGNORE_DIRS {
98        let _ = builder.add_line(None, &format!("{dir}/"));
99    }
100    builder.build().ok()
101}
102
103/// Detect programming language from file extension.
104pub fn detect_language(path: &Path) -> Option<&'static str> {
105    path.extension()
106        .and_then(|ext| ext.to_str())
107        .and_then(|ext| match ext {
108            "rs" => Some("rust"),
109            "ts" | "tsx" => Some("typescript"),
110            "js" | "jsx" => Some("javascript"),
111            "py" => Some("python"),
112            "go" => Some("go"),
113            "c" | "h" => Some("c"),
114            "cpp" | "cc" | "cxx" | "hpp" => Some("cpp"),
115            "java" => Some("java"),
116            "rb" => Some("ruby"),
117            "cs" => Some("csharp"),
118            "kt" | "kts" => Some("kotlin"),
119            "swift" => Some("swift"),
120            "php" => Some("php"),
121            "scala" | "sc" => Some("scala"),
122            "tf" | "hcl" | "tfvars" => Some("hcl"),
123            _ => None,
124        })
125}
126
127/// File watcher that monitors a directory for changes with 50ms debouncing.
128pub struct FileWatcher {
129    _debouncer: notify_debouncer_mini::Debouncer<notify::RecommendedWatcher>,
130    receiver: Receiver<WatchEvent>,
131    #[allow(dead_code)]
132    gitignore: Arc<Option<Gitignore>>,
133}
134
135impl FileWatcher {
136    /// Create a new file watcher for the given root directory.
137    pub fn new(root: &Path) -> Result<Self, codemem_core::CodememError> {
138        let (tx, rx) = crossbeam_channel::unbounded::<WatchEvent>();
139        let event_tx = tx;
140
141        let gitignore = Arc::new(build_gitignore(root));
142        let gi_clone = Arc::clone(&gitignore);
143
144        // Track files we've already seen so we can distinguish create vs modify.
145        let known_files = std::sync::Mutex::new(HashSet::<PathBuf>::new());
146
147        let mut debouncer = new_debouncer(
148            Duration::from_millis(50),
149            move |res: Result<Vec<notify_debouncer_mini::DebouncedEvent>, notify::Error>| match res
150            {
151                Ok(events) => {
152                    let mut seen = HashSet::new();
153                    for event in events {
154                        let path = event.path;
155                        if !seen.insert(path.clone()) {
156                            continue;
157                        }
158                        if should_ignore(&path, gi_clone.as_ref().as_ref()) || !is_watchable(&path)
159                        {
160                            continue;
161                        }
162                        // Determine event type from filesystem state + known-files
163                        // set rather than the debouncer event kind, which varies
164                        // across platforms (FSEvents on macOS vs inotify on Linux).
165                        let watch_event = if path.exists() {
166                            if let Ok(mut known) = known_files.lock() {
167                                if known.insert(path.clone()) {
168                                    WatchEvent::FileCreated(path)
169                                } else {
170                                    WatchEvent::FileChanged(path)
171                                }
172                            } else {
173                                WatchEvent::FileChanged(path)
174                            }
175                        } else {
176                            if let Ok(mut known) = known_files.lock() {
177                                known.remove(&path);
178                            }
179                            WatchEvent::FileDeleted(path)
180                        };
181                        let _ = event_tx.send(watch_event);
182                    }
183                }
184                Err(e) => {
185                    tracing::error!("Watch error: {e}");
186                }
187            },
188        )
189        .map_err(|e| {
190            codemem_core::CodememError::Io(std::io::Error::other(format!(
191                "Failed to create debouncer: {e}"
192            )))
193        })?;
194
195        debouncer
196            .watcher()
197            .watch(root, notify::RecursiveMode::Recursive)
198            .map_err(|e| {
199                codemem_core::CodememError::Io(std::io::Error::other(format!(
200                    "Failed to watch {}: {e}",
201                    root.display()
202                )))
203            })?;
204
205        tracing::info!("Watching {} for changes", root.display());
206
207        Ok(Self {
208            _debouncer: debouncer,
209            receiver: rx,
210            gitignore,
211        })
212    }
213
214    /// Get the receiver for watch events.
215    pub fn receiver(&self) -> &Receiver<WatchEvent> {
216        &self.receiver
217    }
218}
219
220#[cfg(test)]
221#[path = "tests/lib_tests.rs"]
222mod tests;