Skip to main content

ai_agent/utils/hooks/
file_changed_watcher.rs

1// Source: ~/claudecode/openclaudecode/src/utils/hooks/fileChangedWatcher.ts
2#![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/// File event type
11#[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
28/// Result from executing file changed hooks
29pub struct HookOutsideReplResult {
30    pub results: Vec<HookResult>,
31    pub watch_paths: Vec<String>,
32    pub system_messages: Vec<String>,
33}
34
35/// Individual hook result
36pub struct HookResult {
37    pub succeeded: bool,
38    pub output: Option<String>,
39}
40
41/// File watcher state
42struct 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
72/// Set environment hook notifier callback
73pub 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
78/// Initialize the file changed watcher
79pub 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        // Register cleanup (would hook into the cleanup registry)
112        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
123/// Resolve watch paths from configuration
124fn 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    // Matcher field: filenames to watch in cwd, pipe-separated (e.g. ".envrc|.env")
139    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    // Combine static matcher paths with dynamic paths from hook output
160    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
170/// Start watching the given paths (polling-based since notify crate not available)
171fn start_watching(paths: &[String]) {
172    log_for_debugging(&format!(
173        "FileChanged: watching {} paths (polling mode)",
174        paths.len()
175    ));
176
177    // Store the paths for polling
178    {
179        let mut state = FILE_WATCHER_STATE.lock().unwrap();
180        state.watched_paths = paths.to_vec();
181    }
182
183    // In a production implementation, this would use a file system watcher crate
184    // like `notify`. For now, we store the paths and rely on external triggers.
185    // The core logic (resolve_watch_paths, update_watch_paths, on_cwd_changed_for_hooks)
186    // is fully implemented and ready to integrate with any file watching backend.
187}
188
189/// Handle a file event (core logic without notify crate dependency)
190fn handle_file_event(path: &str, event: &FileEvent) {
191    log_for_debugging(&format!("FileChanged: {} {}", event, path));
192
193    // Execute file changed hooks (async)
194    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
222/// Notify callback helper
223fn 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
230/// Update watch paths
231pub 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
256/// Restart watching with updated paths
257fn restart_watching() {
258    let paths = resolve_watch_paths();
259    if !paths.is_empty() {
260        start_watching(&paths);
261    }
262}
263
264/// Handle working directory change for hooks
265pub 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    // Re-evaluate from the current snapshot so mid-session hook changes are picked up
274    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    // Clear cwd env files (would call clear_cwd_env_files)
299
300    // Execute CwdChanged hooks
301    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    // Re-resolve matcher paths against the new cwd
334    {
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
345/// Execute file changed hooks for a path and event
346async fn execute_file_changed_hooks(
347    _path: &str,
348    _event: &FileEvent,
349) -> Result<HookOutsideReplResult, Box<dyn std::error::Error + Send + Sync>> {
350    // This would execute the actual file changed hooks
351    Ok(HookOutsideReplResult {
352        results: Vec::new(),
353        watch_paths: Vec::new(),
354        system_messages: Vec::new(),
355    })
356}
357
358/// Execute cwd changed hooks
359async fn execute_cwd_changed_hooks(
360    _old_cwd: &str,
361    _new_cwd: &str,
362) -> Result<HookOutsideReplResult, Box<dyn std::error::Error + Send + Sync>> {
363    // This would execute the actual cwd changed hooks
364    Ok(HookOutsideReplResult {
365        results: Vec::new(),
366        watch_paths: Vec::new(),
367        system_messages: Vec::new(),
368    })
369}
370
371/// Dispose of the file watcher
372fn 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
382/// Reset file changed watcher for testing
383pub fn reset_file_changed_watcher_for_testing() {
384    dispose();
385}
386
387/// Log for debugging
388fn log_for_debugging(msg: &str) {
389    log::debug!("{}", msg);
390}