Skip to main content

code_kb_core/
watcher.rs

1use ignore::WalkBuilder;
2use notify::RecursiveMode;
3use notify_debouncer_full::{DebouncedEvent, Debouncer, RecommendedCache, new_debouncer};
4use std::path::{Path, PathBuf};
5use std::time::Duration;
6use thiserror::Error;
7use tracing::{info, warn};
8
9use crate::sync::{delete_file, ensure_fresh_file, scan_workspace, update_file};
10use crate::workspace::{Workspace, is_hard_excluded, to_forward_slash};
11
12#[derive(Debug, Error)]
13pub enum WatcherError {
14    #[error("Failed to initialize notify watcher: {0}")]
15    Notify(#[from] notify::Error),
16    #[error("Failed to build gitignore filters: {0}")]
17    Ignore(#[from] ignore::Error),
18}
19
20/// Active background file watcher handle.
21pub struct WatcherHandle {
22    // Retaining debouncer keeps the background notify thread running
23    _debouncer: Debouncer<notify::RecommendedWatcher, RecommendedCache>,
24}
25
26/// Starts debounced background file watcher with git storm circuit breaker.
27pub fn start_watcher(
28    workspace: Workspace,
29    db_path: PathBuf,
30) -> Result<WatcherHandle, WatcherError> {
31    let ws_clone = workspace.clone();
32    let db_clone = db_path.clone();
33
34    // 150ms debounce window
35    let mut debouncer = new_debouncer(
36        Duration::from_millis(150),
37        None,
38        move |res: Result<Vec<DebouncedEvent>, _>| {
39            let events = match res {
40                Ok(evts) => evts,
41                Err(err) => {
42                    warn!("File watcher error: {:?}", err);
43                    return;
44                }
45            };
46
47            if events.is_empty() {
48                return;
49            }
50
51            let mut ignore_builder = WalkBuilder::new(&ws_clone.canonical_root);
52            ignore_builder
53                .standard_filters(true)
54                .hidden(false)
55                .add_custom_ignore_filename(".julieignore")
56                .add_custom_ignore_filename(".code-kb-ignore")
57                .add_custom_ignore_filename(".codekbignore");
58            let mut ignore_matcher = match ignore_builder.build_matchers().pop() {
59                Some(m) => m,
60                None => {
61                    warn!("Failed to initialize ignore matcher for workspace root; skipping tick");
62                    return;
63                }
64            };
65
66            let mut relevant_files = Vec::new();
67            for event in &events {
68                // Ignore read/access events (e.g. inotify IN_OPEN / IN_ACCESS)
69                if event.kind.is_access() {
70                    continue;
71                }
72
73                for path in &event.paths {
74                    let norm_path = dunce::simplified(path);
75                    if let Some(rel) =
76                        crate::workspace::strip_prefix_lossy(norm_path, &ws_clone.canonical_root)
77                    {
78                        let rel_str = to_forward_slash(rel);
79                        let rel_str = rel_str.trim_start_matches('/').to_string();
80                        if rel_str.is_empty() || is_hard_excluded(&rel_str) {
81                            continue;
82                        }
83                        let is_dir = norm_path.is_dir();
84                        let (matched, error) =
85                            ignore_matcher.matched_with_errors(Path::new(&rel_str), is_dir);
86                        if let Some(error) = error {
87                            warn!("Failed to load ignore rule: {error}");
88                        }
89                        if matched.is_ignore() {
90                            continue;
91                        }
92                        relevant_files.push((norm_path.to_path_buf(), rel_str));
93                    }
94                }
95            }
96
97            if relevant_files.is_empty() {
98                return;
99            }
100
101            // Deduplicate paths in this window
102            relevant_files.sort_by(|a, b| a.1.cmp(&b.1));
103            relevant_files.dedup_by(|a, b| a.1 == b.1);
104
105            // Git checkout storm circuit-breaker:
106            // If more than 50 files changed within the debounce window,
107            // cancel micro-updates and run a single bulk scan.
108            if relevant_files.len() > 50 {
109                tracing::debug!(
110                    "Git storm detected ({} files changed in window). Running bulk scan...",
111                    relevant_files.len()
112                );
113                if let Err(e) = scan_workspace(&ws_clone, &db_clone, false) {
114                    warn!("Bulk scan failed during git storm: {e}");
115                }
116                return;
117            }
118
119            // Otherwise, process incremental updates in lock-free WAL mode
120            let conn_opt = crate::db::open_read_only(&db_clone).ok();
121            for (abs, rel) in relevant_files {
122                if abs.exists() && abs.is_file() {
123                    let mut handled = false;
124                    if let Some(ref conn) = conn_opt
125                        && let Ok(_fresh) = ensure_fresh_file(&ws_clone, &db_clone, conn, &rel)
126                    {
127                        handled = true;
128                    }
129                    if !handled {
130                        let _ = update_file(&ws_clone, &db_clone, &rel);
131                    }
132                } else if !abs.exists() {
133                    let mut child_paths = Vec::new();
134                    let local_conn;
135                    let conn_ref = match conn_opt.as_ref() {
136                        Some(c) => Some(c),
137                        None => {
138                            local_conn = crate::db::open_read_only(&db_clone).ok();
139                            local_conn.as_ref()
140                        }
141                    };
142                    if let Some(conn) = conn_ref {
143                        let escaped = crate::queries::escape_like(&rel);
144                        let pattern = format!("{escaped}/%");
145                        if let Ok(mut stmt) = conn.prepare(
146                            "SELECT path FROM files WHERE path LIKE ?1 ESCAPE '\\' LIMIT 51",
147                        ) && let Ok(rows) = stmt.query_map([&pattern], |r| r.get::<_, String>(0))
148                        {
149                            for child in rows.flatten() {
150                                child_paths.push(child);
151                            }
152                        }
153                    }
154                    if child_paths.len() > 50 {
155                        let _ = scan_workspace(&ws_clone, &db_clone, false);
156                    } else {
157                        for child in child_paths {
158                            let _ = delete_file(&ws_clone, &db_clone, &child);
159                        }
160                        let _ = delete_file(&ws_clone, &db_clone, &rel);
161                    }
162                }
163            }
164        },
165    )?;
166
167    // Watch workspace root recursively
168    debouncer.watch(&workspace.canonical_root, RecursiveMode::Recursive)?;
169
170    info!(
171        "Tier 3 file watcher active on '{}' (150ms debounce)",
172        workspace.canonical_root.display()
173    );
174
175    Ok(WatcherHandle {
176        _debouncer: debouncer,
177    })
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use std::path::Path;
184
185    #[test]
186    fn test_drive_root_leading_slash_trimmed() {
187        let raw_rel = Path::new("/src/components/button.rs");
188        let rel_str = to_forward_slash(raw_rel);
189        let trimmed = rel_str.trim_start_matches('/').to_string();
190        assert_eq!(trimmed, "src/components/button.rs");
191        assert!(!trimmed.starts_with('/'));
192
193        let raw_rel_win = Path::new("\\src\\components\\button.rs");
194        let rel_str_win = to_forward_slash(raw_rel_win);
195        let trimmed_win = rel_str_win.trim_start_matches('/').to_string();
196        assert_eq!(trimmed_win, "src/components/button.rs");
197        assert!(!trimmed_win.starts_with('/'));
198    }
199
200    #[test]
201    fn test_child_files_query_pattern_escaped() {
202        let dir_rel = "src/components";
203        let escaped = dir_rel
204            .replace('\\', "\\\\")
205            .replace('%', "\\%")
206            .replace('_', "\\_");
207        let pattern = format!("{escaped}/%");
208        assert_eq!(pattern, "src/components/%");
209
210        let dir_with_wildcards = "src/foo_bar%baz";
211        let escaped2 = dir_with_wildcards
212            .replace('\\', "\\\\")
213            .replace('%', "\\%")
214            .replace('_', "\\_");
215        let pattern2 = format!("{escaped2}/%");
216        assert_eq!(pattern2, "src/foo\\_bar\\%baz/%");
217    }
218}