1use 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#[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 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 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 #[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 #[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}