ai_agent/utils/hooks/
file_changed_watcher.rs1#![allow(dead_code)]
3
4use std::collections::HashSet;
5use std::path::{Path, PathBuf};
6use std::sync::{Arc, Mutex};
7
8use crate::utils::hooks::hooks_config_snapshot::get_hooks_config_from_snapshot;
9
10#[derive(Debug, Clone)]
12pub enum FileEvent {
13 Change,
14 Add,
15 Unlink,
16}
17
18impl std::fmt::Display for FileEvent {
19 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20 match self {
21 FileEvent::Change => write!(f, "change"),
22 FileEvent::Add => write!(f, "add"),
23 FileEvent::Unlink => write!(f, "unlink"),
24 }
25 }
26}
27
28pub struct HookOutsideReplResult {
30 pub results: Vec<HookResult>,
31 pub watch_paths: Vec<String>,
32 pub system_messages: Vec<String>,
33}
34
35pub struct HookResult {
37 pub succeeded: bool,
38 pub output: Option<String>,
39}
40
41struct FileWatcherState {
43 watched_paths: Vec<String>,
44 current_cwd: String,
45 dynamic_watch_paths: Vec<String>,
46 dynamic_watch_paths_sorted: Vec<String>,
47 initialized: bool,
48 has_env_hooks: bool,
49 notify_callback: Option<Box<dyn Fn(String, bool) + Send + Sync>>,
50}
51
52impl FileWatcherState {
53 fn new() -> Self {
54 Self {
55 watched_paths: Vec::new(),
56 current_cwd: String::new(),
57 dynamic_watch_paths: Vec::new(),
58 dynamic_watch_paths_sorted: Vec::new(),
59 initialized: false,
60 has_env_hooks: false,
61 notify_callback: None,
62 }
63 }
64}
65
66lazy_static::lazy_static! {
67 static ref FILE_WATCHER_STATE: Arc<Mutex<FileWatcherState>> = Arc::new(Mutex::new(
68 FileWatcherState::new()
69 ));
70}
71
72pub fn set_env_hook_notifier(cb: Option<Box<dyn Fn(String, bool) + Send + Sync>>) {
74 let mut state = FILE_WATCHER_STATE.lock().unwrap();
75 state.notify_callback = cb;
76}
77
78pub fn initialize_file_changed_watcher(cwd: &str) {
80 {
81 let state = FILE_WATCHER_STATE.lock().unwrap();
82 if state.initialized {
83 return;
84 }
85 }
86
87 let config = get_hooks_config_from_snapshot();
88
89 let has_env_hooks = {
90 let cwd_changed_len = config
91 .as_ref()
92 .and_then(|c| c.events.get("CwdChanged"))
93 .map(|m| m.len())
94 .unwrap_or(0);
95 let file_changed_len = config
96 .as_ref()
97 .and_then(|c| c.events.get("FileChanged"))
98 .map(|m| m.len())
99 .unwrap_or(0);
100 cwd_changed_len > 0 || file_changed_len > 0
101 };
102
103 {
104 let mut state = FILE_WATCHER_STATE.lock().unwrap();
105 state.initialized = true;
106 state.current_cwd = cwd.to_string();
107 state.has_env_hooks = has_env_hooks;
108 }
109
110 if has_env_hooks {
111 log_for_debugging("FileChanged: registered cleanup for file watcher");
113 }
114
115 let paths = resolve_watch_paths();
116 if paths.is_empty() {
117 return;
118 }
119
120 start_watching(&paths);
121}
122
123fn resolve_watch_paths() -> Vec<String> {
125 let state = FILE_WATCHER_STATE.lock().unwrap();
126 let cwd = state.current_cwd.clone();
127 let dynamic_paths = state.dynamic_watch_paths.clone();
128 drop(state);
129
130 let config = get_hooks_config_from_snapshot();
131
132 let matchers = config
133 .as_ref()
134 .and_then(|c| c.events.get("FileChanged"))
135 .cloned()
136 .unwrap_or_default();
137
138 let mut static_paths: HashSet<String> = HashSet::new();
140 for matcher in &matchers {
141 let matcher_str = matcher.matcher.as_deref().unwrap_or("");
142 if matcher_str.is_empty() {
143 continue;
144 }
145 for name in matcher_str.split('|').map(|s: &str| s.trim()) {
146 if name.is_empty() {
147 continue;
148 }
149 let path = Path::new(name);
150 let full_path = if path.is_absolute() {
151 path.to_path_buf()
152 } else {
153 PathBuf::from(&cwd).join(name)
154 };
155 static_paths.insert(full_path.to_string_lossy().to_string());
156 }
157 }
158
159 let mut all_paths: Vec<String> = static_paths.into_iter().collect();
161 for p in dynamic_paths {
162 if !all_paths.contains(&p) {
163 all_paths.push(p);
164 }
165 }
166
167 all_paths
168}
169
170fn start_watching(paths: &[String]) {
172 log_for_debugging(&format!(
173 "FileChanged: watching {} paths (polling mode)",
174 paths.len()
175 ));
176
177 {
179 let mut state = FILE_WATCHER_STATE.lock().unwrap();
180 state.watched_paths = paths.to_vec();
181 }
182
183 }
188
189fn handle_file_event(path: &str, event: &FileEvent) {
191 log_for_debugging(&format!("FileChanged: {} {}", event, path));
192
193 let path_clone = path.to_string();
195 let event_clone = event.clone();
196 tokio::spawn(async move {
197 match execute_file_changed_hooks(&path_clone, &event_clone).await {
198 Ok(result) => {
199 if !result.watch_paths.is_empty() {
200 update_watch_paths(&result.watch_paths);
201 }
202 for msg in result.system_messages {
203 notify_callback_inner(&msg, false);
204 }
205 for r in result.results {
206 if !r.succeeded {
207 if let Some(output) = r.output {
208 notify_callback_inner(&output, true);
209 }
210 }
211 }
212 }
213 Err(e) => {
214 let msg = format!("FileChanged hook failed: {}", e);
215 log_for_debugging(&msg);
216 notify_callback_inner(&msg, true);
217 }
218 }
219 });
220}
221
222fn notify_callback_inner(text: &str, is_error: bool) {
224 let state = FILE_WATCHER_STATE.lock().unwrap();
225 if let Some(ref cb) = state.notify_callback {
226 cb(text.to_string(), is_error);
227 }
228}
229
230pub fn update_watch_paths(paths: &[String]) {
232 let mut state = FILE_WATCHER_STATE.lock().unwrap();
233 if !state.initialized {
234 return;
235 }
236
237 let mut sorted = paths.to_vec();
238 sorted.sort();
239
240 if sorted.len() == state.dynamic_watch_paths_sorted.len()
241 && sorted
242 .iter()
243 .zip(state.dynamic_watch_paths_sorted.iter())
244 .all(|(a, b)| a == b)
245 {
246 return;
247 }
248
249 state.dynamic_watch_paths = paths.to_vec();
250 state.dynamic_watch_paths_sorted = sorted;
251 drop(state);
252
253 restart_watching();
254}
255
256fn restart_watching() {
258 let paths = resolve_watch_paths();
259 if !paths.is_empty() {
260 start_watching(&paths);
261 }
262}
263
264pub async fn on_cwd_changed_for_hooks(
266 old_cwd: &str,
267 new_cwd: &str,
268) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
269 if old_cwd == new_cwd {
270 return Ok(());
271 }
272
273 let config = get_hooks_config_from_snapshot();
275 let current_has_env_hooks = {
276 let cwd_changed_len = config
277 .as_ref()
278 .and_then(|c| c.events.get("CwdChanged"))
279 .map(|m| m.len())
280 .unwrap_or(0);
281 let file_changed_len = config
282 .as_ref()
283 .and_then(|c| c.events.get("FileChanged"))
284 .map(|m| m.len())
285 .unwrap_or(0);
286 cwd_changed_len > 0 || file_changed_len > 0
287 };
288
289 if !current_has_env_hooks {
290 return Ok(());
291 }
292
293 {
294 let mut state = FILE_WATCHER_STATE.lock().unwrap();
295 state.current_cwd = new_cwd.to_string();
296 }
297
298 let hook_result = execute_cwd_changed_hooks(old_cwd, new_cwd)
302 .await
303 .unwrap_or_else(|e| {
304 let msg = format!("CwdChanged hook failed: {}", e);
305 log_for_debugging(&msg);
306 notify_callback_inner(&msg, true);
307 HookOutsideReplResult {
308 results: Vec::new(),
309 watch_paths: Vec::new(),
310 system_messages: Vec::new(),
311 }
312 });
313
314 {
315 let mut state = FILE_WATCHER_STATE.lock().unwrap();
316 state.dynamic_watch_paths = hook_result.watch_paths.clone();
317 let mut sorted = hook_result.watch_paths.clone();
318 sorted.sort();
319 state.dynamic_watch_paths_sorted = sorted;
320 }
321
322 for msg in &hook_result.system_messages {
323 notify_callback_inner(msg, false);
324 }
325 for r in &hook_result.results {
326 if !r.succeeded {
327 if let Some(ref output) = r.output {
328 notify_callback_inner(output, true);
329 }
330 }
331 }
332
333 {
335 let state = FILE_WATCHER_STATE.lock().unwrap();
336 if state.initialized {
337 drop(state);
338 restart_watching();
339 }
340 }
341
342 Ok(())
343}
344
345async fn execute_file_changed_hooks(
347 _path: &str,
348 _event: &FileEvent,
349) -> Result<HookOutsideReplResult, Box<dyn std::error::Error + Send + Sync>> {
350 Ok(HookOutsideReplResult {
352 results: Vec::new(),
353 watch_paths: Vec::new(),
354 system_messages: Vec::new(),
355 })
356}
357
358async fn execute_cwd_changed_hooks(
360 _old_cwd: &str,
361 _new_cwd: &str,
362) -> Result<HookOutsideReplResult, Box<dyn std::error::Error + Send + Sync>> {
363 Ok(HookOutsideReplResult {
365 results: Vec::new(),
366 watch_paths: Vec::new(),
367 system_messages: Vec::new(),
368 })
369}
370
371fn dispose() {
373 let mut state = FILE_WATCHER_STATE.lock().unwrap();
374 state.watched_paths.clear();
375 state.dynamic_watch_paths.clear();
376 state.dynamic_watch_paths_sorted.clear();
377 state.initialized = false;
378 state.has_env_hooks = false;
379 state.notify_callback = None;
380}
381
382pub fn reset_file_changed_watcher_for_testing() {
384 dispose();
385}
386
387fn log_for_debugging(msg: &str) {
389 log::debug!("{}", msg);
390}