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
20pub struct WatcherHandle {
22 _debouncer: Debouncer<notify::RecommendedWatcher, RecommendedCache>,
24}
25
26pub 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 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 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 relevant_files.sort_by(|a, b| a.1.cmp(&b.1));
103 relevant_files.dedup_by(|a, b| a.1 == b.1);
104
105 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 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 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}