Skip to main content

harn_hostlib/fs_watch/
mod.rs

1//! File-system watch host capability.
2//!
3//! Wraps `notify` to deliver coalesced file-change batches into Harn's
4//! session-scoped `AgentEvent` stream.
5
6use std::collections::{BTreeSet, HashMap};
7use std::path::{Path, PathBuf};
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::{mpsc, Mutex, OnceLock};
10use std::thread;
11use std::time::{Duration, SystemTime, UNIX_EPOCH};
12
13use globset::{Glob, GlobSet, GlobSetBuilder};
14use harn_vm::agent_events::{AgentEvent, FsWatchEvent};
15use harn_vm::ignore_policy::{self, IgnorePolicy};
16use harn_vm::VmValue;
17use ignore::gitignore::Gitignore;
18use notify::event::{ModifyKind, RenameMode};
19use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
20
21use crate::error::HostlibError;
22use crate::registry::{BuiltinRegistry, HostlibCapability, RegisteredBuiltin, SyncHandler};
23use crate::tools::args::{
24    build_dict, dict_arg, optional_bool, optional_int, optional_string, str_value, to_agent_path,
25};
26use crate::value_args::optional_string_list;
27
28const SUBSCRIBE_BUILTIN: &str = "hostlib_fs_watch_subscribe";
29const UNSUBSCRIBE_BUILTIN: &str = "hostlib_fs_watch_unsubscribe";
30const DEFAULT_DEBOUNCE_MS: u64 = 50;
31const DEFAULT_KINDS: &[&str] = &["create", "modify", "remove", "rename"];
32const SUPPORTED_KINDS: &[&str] = &["access", "create", "modify", "other", "remove", "rename"];
33
34static NEXT_SUBSCRIPTION_ID: AtomicU64 = AtomicU64::new(1);
35
36/// File-watch capability handle.
37#[derive(Default)]
38pub struct FsWatchCapability;
39
40impl HostlibCapability for FsWatchCapability {
41    fn module_name(&self) -> &'static str {
42        "fs_watch"
43    }
44
45    fn register_builtins(&self, registry: &mut BuiltinRegistry) {
46        registry.register(RegisteredBuiltin {
47            name: SUBSCRIBE_BUILTIN,
48            module: "fs_watch",
49            method: "subscribe",
50            handler: subscribe_handler(),
51        });
52        registry.register(RegisteredBuiltin {
53            name: UNSUBSCRIBE_BUILTIN,
54            module: "fs_watch",
55            method: "unsubscribe",
56            handler: unsubscribe_handler(),
57        });
58    }
59}
60
61fn subscribe_handler() -> SyncHandler {
62    std::sync::Arc::new(subscribe)
63}
64
65fn unsubscribe_handler() -> SyncHandler {
66    std::sync::Arc::new(unsubscribe)
67}
68
69struct Subscription {
70    _watcher: RecommendedWatcher,
71    stop_tx: mpsc::Sender<WatchMessage>,
72    worker: Option<thread::JoinHandle<()>>,
73}
74
75impl Drop for Subscription {
76    fn drop(&mut self) {
77        let _ = self.stop_tx.send(WatchMessage::Stop);
78        if let Some(worker) = self.worker.take() {
79            let _ = worker.join();
80        }
81    }
82}
83
84enum WatchMessage {
85    Event(Event),
86    Error(String),
87    Stop,
88}
89
90#[derive(Clone)]
91struct WatchFilter {
92    session_id: String,
93    subscription_id: String,
94    root: PathBuf,
95    globs: Option<GlobSet>,
96    gitignore: Option<Gitignore>,
97    kinds: BTreeSet<String>,
98}
99
100fn subscriptions() -> &'static Mutex<HashMap<String, Subscription>> {
101    static SUBSCRIPTIONS: OnceLock<Mutex<HashMap<String, Subscription>>> = OnceLock::new();
102    SUBSCRIPTIONS.get_or_init(|| Mutex::new(HashMap::new()))
103}
104
105fn subscribe(args: &[VmValue]) -> Result<VmValue, HostlibError> {
106    let raw = dict_arg(SUBSCRIBE_BUILTIN, args)?;
107    let dict = raw.as_ref();
108    let request = SubscribeRequest::from_dict(dict)?;
109    let subscription_id = next_subscription_id();
110    let (tx, rx) = mpsc::channel();
111
112    let filter = WatchFilter {
113        session_id: request.session_id.clone(),
114        subscription_id: subscription_id.clone(),
115        root: request.root.clone(),
116        globs: request.globs,
117        gitignore: request.gitignore,
118        kinds: request.kinds,
119    };
120    let debounce = Duration::from_millis(request.debounce_ms);
121    let worker = thread::Builder::new()
122        .name(format!("harn-fs-watch-{subscription_id}"))
123        .spawn(move || watch_worker(rx, debounce, filter))
124        .map_err(|err| HostlibError::Backend {
125            builtin: SUBSCRIBE_BUILTIN,
126            message: format!("failed to spawn watch worker: {err}"),
127        })?;
128
129    let notify_tx = tx.clone();
130    let mut watcher = notify::recommended_watcher(move |result: notify::Result<Event>| {
131        let message = match result {
132            Ok(event) => WatchMessage::Event(event),
133            Err(err) => WatchMessage::Error(err.to_string()),
134        };
135        let _ = notify_tx.send(message);
136    })
137    .map_err(|err| HostlibError::Backend {
138        builtin: SUBSCRIBE_BUILTIN,
139        message: format!("failed to create watcher: {err}"),
140    })?;
141
142    let mode = if request.recursive {
143        RecursiveMode::Recursive
144    } else {
145        RecursiveMode::NonRecursive
146    };
147    for path in &request.watch_paths {
148        watcher
149            .watch(path, mode)
150            .map_err(|err| HostlibError::Backend {
151                builtin: SUBSCRIBE_BUILTIN,
152                message: format!("failed to watch {}: {err}", path.display()),
153            })?;
154    }
155
156    subscriptions()
157        .lock()
158        .expect("fs_watch mutex poisoned")
159        .insert(
160            subscription_id.clone(),
161            Subscription {
162                _watcher: watcher,
163                stop_tx: tx,
164                worker: Some(worker),
165            },
166        );
167
168    Ok(build_dict([(
169        "subscription_id",
170        str_value(subscription_id.as_str()),
171    )]))
172}
173
174fn unsubscribe(args: &[VmValue]) -> Result<VmValue, HostlibError> {
175    let raw = dict_arg(UNSUBSCRIBE_BUILTIN, args)?;
176    let dict = raw.as_ref();
177    let subscription_id = match dict.get("subscription_id") {
178        Some(VmValue::String(value)) if !value.trim().is_empty() => value.to_string(),
179        Some(other) => {
180            return Err(HostlibError::InvalidParameter {
181                builtin: UNSUBSCRIBE_BUILTIN,
182                param: "subscription_id",
183                message: format!("expected non-empty string, got {}", other.type_name()),
184            });
185        }
186        None => {
187            return Err(HostlibError::MissingParameter {
188                builtin: UNSUBSCRIBE_BUILTIN,
189                param: "subscription_id",
190            });
191        }
192    };
193    let removed = subscriptions()
194        .lock()
195        .expect("fs_watch mutex poisoned")
196        .remove(&subscription_id)
197        .is_some();
198    Ok(build_dict([("removed", VmValue::Bool(removed))]))
199}
200
201struct SubscribeRequest {
202    session_id: String,
203    root: PathBuf,
204    watch_paths: Vec<PathBuf>,
205    recursive: bool,
206    debounce_ms: u64,
207    globs: Option<GlobSet>,
208    gitignore: Option<Gitignore>,
209    kinds: BTreeSet<String>,
210}
211
212impl SubscribeRequest {
213    fn from_dict(dict: &harn_vm::value::DictMap) -> Result<Self, HostlibError> {
214        let root_param = optional_string(SUBSCRIBE_BUILTIN, dict, "root")?;
215        let raw_paths = optional_string_list(SUBSCRIBE_BUILTIN, dict, "paths")?;
216        let raw_globs = optional_string_list(SUBSCRIBE_BUILTIN, dict, "globs")?;
217        let session_id = optional_string(SUBSCRIBE_BUILTIN, dict, "session_id")?
218            .or_else(harn_vm::agent_sessions::current_session_id)
219            .ok_or(HostlibError::MissingParameter {
220                builtin: SUBSCRIBE_BUILTIN,
221                param: "session_id",
222            })?;
223
224        if session_id.trim().is_empty() {
225            return Err(HostlibError::InvalidParameter {
226                builtin: SUBSCRIBE_BUILTIN,
227                param: "session_id",
228                message: "must not be empty".to_string(),
229            });
230        }
231
232        if root_param.is_none() && raw_paths.is_none() {
233            return Err(HostlibError::MissingParameter {
234                builtin: SUBSCRIBE_BUILTIN,
235                param: "root",
236            });
237        }
238
239        let root = match root_param.as_deref() {
240            Some(root) => normalize_existing_path(SUBSCRIBE_BUILTIN, "root", root)?,
241            None => std::env::current_dir().map_err(|err| HostlibError::Backend {
242                builtin: SUBSCRIBE_BUILTIN,
243                message: format!("failed to resolve current directory: {err}"),
244            })?,
245        };
246
247        let raw_paths = raw_paths.unwrap_or_else(|| {
248            root_param
249                .as_ref()
250                .map(|root| vec![root.clone()])
251                .unwrap_or_default()
252        });
253        if raw_paths.is_empty() {
254            return Err(HostlibError::InvalidParameter {
255                builtin: SUBSCRIBE_BUILTIN,
256                param: "paths",
257                message: "must contain at least one path".to_string(),
258            });
259        }
260
261        let mut watch_paths = Vec::with_capacity(raw_paths.len());
262        for path in raw_paths {
263            let path = PathBuf::from(path);
264            let resolved = if path.is_relative() && root_param.is_some() {
265                root.join(path)
266            } else {
267                path
268            };
269            let normalized = normalize_existing_path_buf(SUBSCRIBE_BUILTIN, "paths", &resolved)?;
270            if normalized.strip_prefix(&root).is_err() {
271                return Err(HostlibError::InvalidParameter {
272                    builtin: SUBSCRIBE_BUILTIN,
273                    param: "paths",
274                    message: format!(
275                        "watch path `{}` is outside root `{}`",
276                        normalized.display(),
277                        root.display()
278                    ),
279                });
280            }
281            watch_paths.push(normalized);
282        }
283
284        let recursive = optional_bool(SUBSCRIBE_BUILTIN, dict, "recursive", true)?;
285        let debounce_ms = optional_int(
286            SUBSCRIBE_BUILTIN,
287            dict,
288            "debounce_ms",
289            DEFAULT_DEBOUNCE_MS as i64,
290        )?;
291        if debounce_ms < 0 {
292            return Err(HostlibError::InvalidParameter {
293                builtin: SUBSCRIBE_BUILTIN,
294                param: "debounce_ms",
295                message: "must be >= 0".to_string(),
296            });
297        }
298        // Watching is the one surface that defaults to `Builtin` rather than
299        // `Project`, because the two halves of the stack answer different
300        // questions here.
301        //
302        // Universal hygiene applies: a recursive watch over `node_modules` or
303        // `target` exhausts the OS watch budget (one inotify watch per
304        // directory on Linux) and delivers churn nobody consumes, which is this
305        // issue's unbounded-cost class in its most expensive form.
306        //
307        // Project ignore files do not apply: `.gitignore` says to keep a file
308        // out of version control, which is not the same claim as staying
309        // silent when it changes. A developer actively editing such a file
310        // still needs
311        // its events, and a dropped event is invisible — indistinguishable from
312        // nothing having happened.
313        //
314        // A subscriber that genuinely wants raw coverage passes
315        // `ignore_policy: "none"`.
316        let ignore_policy = ignore_policy_arg(dict)?.unwrap_or(IgnorePolicy::Builtin);
317
318        Ok(Self {
319            session_id,
320            gitignore: match ignore_policy {
321                IgnorePolicy::None => None,
322                policy => Some(ignore_policy::matcher(&root, policy)),
323            },
324            globs: build_globs(raw_globs.unwrap_or_default())?,
325            kinds: parse_kinds(dict)?,
326            root,
327            watch_paths,
328            recursive,
329            debounce_ms: debounce_ms as u64,
330        })
331    }
332}
333
334fn watch_worker(rx: mpsc::Receiver<WatchMessage>, debounce: Duration, filter: WatchFilter) {
335    let mut pending = Vec::new();
336    loop {
337        match rx.recv() {
338            Ok(WatchMessage::Event(event)) => {
339                pending.push(event);
340                loop {
341                    match rx.recv_timeout(debounce) {
342                        Ok(WatchMessage::Event(event)) => pending.push(event),
343                        Ok(WatchMessage::Error(error)) => emit_watch_error(&filter, error),
344                        Ok(WatchMessage::Stop) | Err(mpsc::RecvTimeoutError::Disconnected) => {
345                            emit_pending(&filter, &mut pending);
346                            return;
347                        }
348                        Err(mpsc::RecvTimeoutError::Timeout) => break,
349                    }
350                }
351                emit_pending(&filter, &mut pending);
352            }
353            Ok(WatchMessage::Error(error)) => emit_watch_error(&filter, error),
354            Ok(WatchMessage::Stop) | Err(_) => return,
355        }
356    }
357}
358
359fn emit_pending(filter: &WatchFilter, pending: &mut Vec<Event>) {
360    if pending.is_empty() {
361        return;
362    }
363    let events = coalesce_events(std::mem::take(pending), filter);
364    if events.is_empty() {
365        return;
366    }
367    harn_vm::agent_events::emit_event(&AgentEvent::FsWatch {
368        session_id: filter.session_id.clone(),
369        subscription_id: filter.subscription_id.clone(),
370        events,
371    });
372}
373
374fn emit_watch_error(filter: &WatchFilter, error: String) {
375    harn_vm::agent_events::emit_event(&AgentEvent::FsWatch {
376        session_id: filter.session_id.clone(),
377        subscription_id: filter.subscription_id.clone(),
378        events: vec![FsWatchEvent {
379            kind: "error".to_string(),
380            paths: Vec::new(),
381            relative_paths: Vec::new(),
382            raw_kind: "error".to_string(),
383            error: Some(error),
384        }],
385    });
386}
387
388fn coalesce_events(events: Vec<Event>, filter: &WatchFilter) -> Vec<FsWatchEvent> {
389    let mut seen = BTreeSet::new();
390    let mut output = Vec::new();
391    for event in events {
392        let kind = normalize_kind(&event.kind);
393        if !filter.kinds.contains(kind) {
394            continue;
395        }
396        let mut paths = Vec::new();
397        let mut relative_paths = Vec::new();
398        for path in &event.paths {
399            if !filter.matches_path(path) {
400                continue;
401            }
402            paths.push(path_to_string(path));
403            relative_paths.push(filter.relative_path(path));
404        }
405        if paths.is_empty() {
406            continue;
407        }
408        paths.sort();
409        paths.dedup();
410        relative_paths.sort();
411        relative_paths.dedup();
412        let raw_kind = format!("{:?}", event.kind);
413        if !seen.insert((kind.to_string(), paths.clone(), raw_kind.clone())) {
414            continue;
415        }
416        output.push(FsWatchEvent {
417            kind: kind.to_string(),
418            paths,
419            relative_paths,
420            raw_kind,
421            error: None,
422        });
423    }
424    output
425}
426
427impl WatchFilter {
428    fn matches_path(&self, path: &Path) -> bool {
429        if let Some(gitignore) = &self.gitignore {
430            // A watcher is handed leaf paths, so matching the path on its own
431            // would pass every file *inside* an ignored directory — which is
432            // the churn this filter exists to suppress, since a rule like
433            // `node_modules/` only ever matches the directory entry itself.
434            //
435            // The parent-aware matcher asserts its argument is under the
436            // matcher's root, and it strips that root itself. Handing it a
437            // path already relative to the watch root satisfies the assert
438            // without depending on the two roots being spelled identically —
439            // they need not be, since one may be canonicalized.
440            let verdict = match path.strip_prefix(&self.root) {
441                Ok(relative) => gitignore.matched_path_or_any_parents(relative, path.is_dir()),
442                Err(_) => gitignore.matched(path, path.is_dir()),
443            };
444            if verdict.is_ignore() {
445                return false;
446            }
447        }
448        if let Some(globs) = &self.globs {
449            let relative = self.relative_path(path);
450            return globs.is_match(relative);
451        }
452        true
453    }
454
455    fn relative_path(&self, path: &Path) -> String {
456        let relative = path.strip_prefix(&self.root).unwrap_or(path);
457        let value = path_to_string(relative);
458        if value.is_empty() {
459            ".".to_string()
460        } else {
461            value
462        }
463    }
464}
465
466fn normalize_kind(kind: &EventKind) -> &'static str {
467    match kind {
468        EventKind::Create(_) => "create",
469        EventKind::Remove(_) => "remove",
470        EventKind::Modify(ModifyKind::Name(
471            RenameMode::Any
472            | RenameMode::To
473            | RenameMode::From
474            | RenameMode::Both
475            | RenameMode::Other,
476        )) => "rename",
477        EventKind::Modify(_) | EventKind::Any => "modify",
478        EventKind::Access(_) => "access",
479        EventKind::Other => "other",
480    }
481}
482
483fn parse_kinds(dict: &harn_vm::value::DictMap) -> Result<BTreeSet<String>, HostlibError> {
484    let values = optional_string_list(SUBSCRIBE_BUILTIN, dict, "kinds")?.unwrap_or_else(|| {
485        DEFAULT_KINDS
486            .iter()
487            .map(|kind| (*kind).to_string())
488            .collect()
489    });
490    let mut kinds = BTreeSet::new();
491    for kind in values {
492        if SUPPORTED_KINDS.contains(&kind.as_str()) {
493            kinds.insert(kind);
494        } else {
495            return Err(HostlibError::InvalidParameter {
496                builtin: SUBSCRIBE_BUILTIN,
497                param: "kinds",
498                message: format!("unsupported event kind `{kind}`"),
499            });
500        }
501    }
502    Ok(kinds)
503}
504
505fn build_globs(globs: Vec<String>) -> Result<Option<GlobSet>, HostlibError> {
506    if globs.is_empty() {
507        return Ok(None);
508    }
509    let mut builder = GlobSetBuilder::new();
510    for glob in globs {
511        let normalized = normalize_glob(&glob);
512        builder.add(
513            Glob::new(&normalized).map_err(|err| HostlibError::InvalidParameter {
514                builtin: SUBSCRIBE_BUILTIN,
515                param: "globs",
516                message: format!("invalid glob `{glob}`: {err}"),
517            })?,
518        );
519    }
520    Ok(Some(builder.build().map_err(|err| {
521        HostlibError::InvalidParameter {
522            builtin: SUBSCRIBE_BUILTIN,
523            param: "globs",
524            message: format!("invalid glob set: {err}"),
525        }
526    })?))
527}
528
529fn ignore_policy_arg(dict: &harn_vm::value::DictMap) -> Result<Option<IgnorePolicy>, HostlibError> {
530    let Some(raw) = optional_string(SUBSCRIBE_BUILTIN, dict, IgnorePolicy::OPTION_KEY)? else {
531        return Ok(None);
532    };
533    IgnorePolicy::parse_for(SUBSCRIBE_BUILTIN, &raw)
534        .map(Some)
535        .map_err(|message| HostlibError::InvalidParameter {
536            builtin: SUBSCRIBE_BUILTIN,
537            param: "ignore_policy",
538            message,
539        })
540}
541
542fn normalize_glob(glob: &str) -> String {
543    let glob = glob.replace('\\', "/");
544    if glob == "*" || glob.starts_with("**/") || glob.contains('/') {
545        glob
546    } else {
547        format!("**/{glob}")
548    }
549}
550
551fn normalize_existing_path(
552    builtin: &'static str,
553    param: &'static str,
554    path: &str,
555) -> Result<PathBuf, HostlibError> {
556    normalize_existing_path_buf(builtin, param, &PathBuf::from(path))
557}
558
559fn normalize_existing_path_buf(
560    builtin: &'static str,
561    param: &'static str,
562    path: &Path,
563) -> Result<PathBuf, HostlibError> {
564    path.canonicalize()
565        .map_err(|err| HostlibError::InvalidParameter {
566            builtin,
567            param,
568            message: format!(
569                "{} does not resolve to an existing path: {err}",
570                path.display()
571            ),
572        })
573}
574
575fn path_to_string(path: &Path) -> String {
576    to_agent_path(path)
577}
578
579fn next_subscription_id() -> String {
580    let seq = NEXT_SUBSCRIPTION_ID.fetch_add(1, Ordering::Relaxed);
581    let millis = SystemTime::now()
582        .duration_since(UNIX_EPOCH)
583        .map(|duration| duration.as_millis())
584        .unwrap_or(0);
585    format!("fsw-{millis}-{seq}")
586}
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591
592    fn event(kind: EventKind, path: impl Into<PathBuf>) -> Event {
593        Event::new(kind).add_path(path.into())
594    }
595
596    fn filter(root: PathBuf, globs: Option<Vec<&str>>) -> WatchFilter {
597        WatchFilter {
598            session_id: "session".to_string(),
599            subscription_id: "sub".to_string(),
600            root,
601            globs: globs.map(|patterns| {
602                build_globs(patterns.into_iter().map(str::to_string).collect())
603                    .unwrap()
604                    .unwrap()
605            }),
606            gitignore: None,
607            kinds: parse_kinds(&harn_vm::value::DictMap::new()).unwrap(),
608        }
609    }
610
611    /// Watching defaults to `builtin`, and the two halves matter
612    /// independently: universal hygiene keeps a recursive watch out of
613    /// dependency and build trees that would exhaust the OS watch budget,
614    /// while project ignore files stay unread so a gitignored file a
615    /// developer is actively editing still reports its changes. A dropped
616    /// event is indistinguishable from no change, so both directions are
617    /// pinned here.
618    #[test]
619    fn watch_default_applies_builtin_hygiene_but_not_project_ignore_files() {
620        let temp = tempfile::tempdir().unwrap();
621        let root = temp.path().to_path_buf();
622        std::fs::create_dir_all(root.join(".git")).unwrap();
623        std::fs::write(root.join(".gitignore"), "tracked-but-ignored.txt\n").unwrap();
624        std::fs::create_dir_all(root.join("node_modules/dep")).unwrap();
625
626        let mut dict = harn_vm::value::DictMap::new();
627        dict.insert(
628            arcstr::ArcStr::from("root"),
629            harn_vm::VmValue::String(arcstr::ArcStr::from(root.to_string_lossy().as_ref())),
630        );
631        dict.insert(
632            arcstr::ArcStr::from("session_id"),
633            harn_vm::VmValue::String(arcstr::ArcStr::from("session")),
634        );
635        let request = SubscribeRequest::from_dict(&dict).expect("subscribe request");
636
637        let mut filter = filter(root.clone(), None);
638        filter.gitignore = request.gitignore;
639
640        assert!(
641            !filter.matches_path(&root.join("node_modules/dep/index.js")),
642            "a default watch must not descend dependency trees"
643        );
644        assert!(
645            filter.matches_path(&root.join("tracked-but-ignored.txt")),
646            "a gitignored file still changes, and the subscriber still needs to know"
647        );
648        assert!(filter.matches_path(&root.join("src/main.rs")));
649    }
650
651    /// `ignore_policy: "none"` is the one escape hatch back to raw coverage.
652    #[test]
653    fn watch_ignore_policy_none_watches_everything() {
654        let temp = tempfile::tempdir().unwrap();
655        let root = temp.path().to_path_buf();
656        std::fs::create_dir_all(root.join("node_modules/dep")).unwrap();
657
658        let mut dict = harn_vm::value::DictMap::new();
659        dict.insert(
660            arcstr::ArcStr::from("root"),
661            harn_vm::VmValue::String(arcstr::ArcStr::from(root.to_string_lossy().as_ref())),
662        );
663        dict.insert(
664            arcstr::ArcStr::from("session_id"),
665            harn_vm::VmValue::String(arcstr::ArcStr::from("session")),
666        );
667        dict.insert(
668            arcstr::ArcStr::from("ignore_policy"),
669            harn_vm::VmValue::String(arcstr::ArcStr::from("none")),
670        );
671        let request = SubscribeRequest::from_dict(&dict).expect("subscribe request");
672        assert!(request.gitignore.is_none());
673
674        let mut filter = filter(root.clone(), None);
675        filter.gitignore = request.gitignore;
676        assert!(filter.matches_path(&root.join("node_modules/dep/index.js")));
677    }
678
679    #[test]
680    fn coalesce_deduplicates_same_kind_and_path() {
681        let root = std::env::current_dir().unwrap();
682        let path = root.join("src/lib.rs");
683        let filter = filter(root, None);
684        let events = coalesce_events(
685            vec![
686                event(EventKind::Modify(ModifyKind::Any), &path),
687                event(EventKind::Modify(ModifyKind::Any), &path),
688            ],
689            &filter,
690        );
691        assert_eq!(events.len(), 1);
692        assert_eq!(events[0].kind, "modify");
693    }
694
695    #[test]
696    fn glob_filter_uses_relative_paths() {
697        let root = std::env::current_dir().unwrap();
698        let filter = filter(root.clone(), Some(vec!["*.rs"]));
699        let events = coalesce_events(
700            vec![
701                event(
702                    EventKind::Create(notify::event::CreateKind::Any),
703                    root.join("src/lib.rs"),
704                ),
705                event(
706                    EventKind::Create(notify::event::CreateKind::Any),
707                    root.join("README.md"),
708                ),
709            ],
710            &filter,
711        );
712        assert_eq!(events.len(), 1);
713        assert_eq!(events[0].relative_paths, vec!["src/lib.rs"]);
714    }
715
716    #[test]
717    fn kind_filter_drops_unrequested_events() {
718        let root = std::env::current_dir().unwrap();
719        let mut filter = filter(root.clone(), None);
720        filter.kinds = BTreeSet::from(["remove".to_string()]);
721
722        let events = coalesce_events(
723            vec![
724                event(
725                    EventKind::Create(notify::event::CreateKind::Any),
726                    root.join("src/lib.rs"),
727                ),
728                event(
729                    EventKind::Remove(notify::event::RemoveKind::Any),
730                    root.join("src/lib.rs"),
731                ),
732            ],
733            &filter,
734        );
735
736        assert_eq!(events.len(), 1);
737        assert_eq!(events[0].kind, "remove");
738    }
739
740    #[test]
741    fn kind_filter_allows_access_and_other_events() {
742        let root = std::env::current_dir().unwrap();
743        let mut config = harn_vm::value::DictMap::new();
744        config.insert(
745            harn_vm::value::intern_key("kinds"),
746            VmValue::List(std::sync::Arc::new(vec![
747                VmValue::String(arcstr::ArcStr::from("access")),
748                VmValue::String(arcstr::ArcStr::from("other")),
749            ])),
750        );
751        let mut filter = filter(root.clone(), None);
752        filter.kinds = parse_kinds(&config).unwrap();
753
754        let events = coalesce_events(
755            vec![
756                event(
757                    EventKind::Access(notify::event::AccessKind::Any),
758                    root.join("src/lib.rs"),
759                ),
760                event(EventKind::Other, root.join("README.md")),
761            ],
762            &filter,
763        );
764
765        assert_eq!(events.len(), 2);
766        assert_eq!(events[0].kind, "access");
767        assert_eq!(events[1].kind, "other");
768    }
769
770    #[test]
771    fn gitignore_filter_drops_ignored_paths() {
772        let temp = tempfile::tempdir().unwrap();
773        std::fs::create_dir_all(temp.path().join(".git")).unwrap();
774        std::fs::write(temp.path().join(".gitignore"), "ignored.txt\n").unwrap();
775        let mut filter = filter(temp.path().to_path_buf(), None);
776        filter.gitignore = Some(ignore_policy::matcher(temp.path(), IgnorePolicy::Project));
777
778        let events = coalesce_events(
779            vec![
780                event(
781                    EventKind::Modify(ModifyKind::Any),
782                    temp.path().join("allowed.txt"),
783                ),
784                event(
785                    EventKind::Modify(ModifyKind::Any),
786                    temp.path().join("ignored.txt"),
787                ),
788            ],
789            &filter,
790        );
791
792        assert_eq!(events.len(), 1);
793        assert_eq!(events[0].relative_paths, vec!["allowed.txt"]);
794    }
795}