1use std::collections::BTreeMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::time::{Duration, Instant, SystemTime};
5
6use thiserror::Error;
7
8pub const DEFAULT_DEV_WATCH_DEBOUNCE_MS: u64 = 120;
9
10const IGNORED_NAMES: &[&str] = &[
11 ".git",
12 ".hg",
13 ".svn",
14 "node_modules",
15 "target",
16 "dist",
17 "build",
18 "coverage",
19 ".DS_Store",
20];
21
22const IGNORED_SUFFIXES: &[&str] = &[".tmp", ".swp", ".swo", "~"];
23
24#[derive(Clone, Debug, PartialEq, Eq)]
25pub struct DevWatchOptions {
26 pub root: PathBuf,
27 pub debounce: Duration,
28 pub extra_ignored_names: Vec<String>,
29}
30
31impl DevWatchOptions {
32 #[must_use]
33 pub fn new(root: impl Into<PathBuf>) -> Self {
34 Self {
35 root: root.into(),
36 debounce: Duration::from_millis(DEFAULT_DEV_WATCH_DEBOUNCE_MS),
37 extra_ignored_names: Vec::new(),
38 }
39 }
40}
41
42#[derive(Clone, Debug, PartialEq, Eq)]
43pub struct DevWatchSnapshot {
44 files: BTreeMap<PathBuf, WatchedFileState>,
45}
46
47#[derive(Clone, Debug, PartialEq, Eq)]
48pub struct DevWatchEvent {
49 pub path: PathBuf,
50 pub kind: DevWatchEventKind,
51}
52
53#[derive(Clone, Debug, PartialEq, Eq)]
54pub enum DevWatchEventKind {
55 Created,
56 Modified,
57 Removed,
58}
59
60#[derive(Clone, Debug, PartialEq, Eq)]
61pub struct DevWatchTrigger {
62 pub events: Vec<DevWatchEvent>,
63}
64
65#[derive(Clone, Debug)]
66pub struct PollingDevWatcher {
67 options: DevWatchOptions,
68 snapshot: DevWatchSnapshot,
69 pending: Vec<DevWatchEvent>,
70 last_event_at: Option<Instant>,
71}
72
73#[derive(Clone, Debug, PartialEq, Eq)]
74struct WatchedFileState {
75 modified: Option<SystemTime>,
76 len: u64,
77}
78
79#[derive(Debug, Error)]
80pub enum DevWatchError {
81 #[error("failed to scan watch root {path}: {source}")]
82 Io {
83 path: PathBuf,
84 #[source]
85 source: std::io::Error,
86 },
87}
88
89impl PollingDevWatcher {
90 pub fn new(options: DevWatchOptions) -> Result<Self, DevWatchError> {
91 let snapshot = collect_watch_snapshot(&options)?;
92 Ok(Self {
93 options,
94 snapshot,
95 pending: Vec::new(),
96 last_event_at: None,
97 })
98 }
99
100 pub fn poll(&mut self) -> Result<Option<DevWatchTrigger>, DevWatchError> {
101 let next = collect_watch_snapshot(&self.options)?;
102 let events = diff_snapshots(&self.snapshot, &next);
103 self.snapshot = next;
104 if !events.is_empty() {
105 self.pending.extend(events);
106 self.last_event_at = Some(Instant::now());
107 return Ok(None);
108 }
109 if self.pending.is_empty() {
110 return Ok(None);
111 }
112 let Some(last_event_at) = self.last_event_at else {
113 return Ok(None);
114 };
115 if last_event_at.elapsed() < self.options.debounce {
116 return Ok(None);
117 }
118 let mut events = Vec::new();
119 std::mem::swap(&mut events, &mut self.pending);
120 self.last_event_at = None;
121 Ok(Some(DevWatchTrigger { events }))
122 }
123}
124
125pub fn collect_watch_snapshot(
126 options: &DevWatchOptions,
127) -> Result<DevWatchSnapshot, DevWatchError> {
128 let mut files = BTreeMap::new();
129 collect_watch_snapshot_inner(&options.root, options, &mut files)?;
130 Ok(DevWatchSnapshot { files })
131}
132
133#[must_use]
134pub fn should_ignore_dev_watch_path(path: &Path, extra_ignored_names: &[String]) -> bool {
135 path.components().any(|component| {
136 let name = component.as_os_str().to_string_lossy();
137 IGNORED_NAMES.iter().any(|ignored| name == *ignored)
138 || extra_ignored_names
139 .iter()
140 .any(|ignored| name.as_ref() == ignored)
141 || IGNORED_SUFFIXES.iter().any(|suffix| name.ends_with(suffix))
142 || (name == ".runx"
143 && path
144 .components()
145 .any(|nested| nested.as_os_str() == "receipts"))
146 })
147}
148
149fn collect_watch_snapshot_inner(
150 directory: &Path,
151 options: &DevWatchOptions,
152 files: &mut BTreeMap<PathBuf, WatchedFileState>,
153) -> Result<(), DevWatchError> {
154 if should_ignore_dev_watch_path(directory, &options.extra_ignored_names) {
155 return Ok(());
156 }
157 let entries = match fs::read_dir(directory) {
158 Ok(entries) => entries,
159 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
160 Err(source) => {
161 return Err(DevWatchError::Io {
162 path: directory.to_path_buf(),
163 source,
164 });
165 }
166 };
167 for entry in entries {
168 let entry = entry.map_err(|source| DevWatchError::Io {
169 path: directory.to_path_buf(),
170 source,
171 })?;
172 let path = entry.path();
173 if should_ignore_dev_watch_path(&path, &options.extra_ignored_names) {
174 continue;
175 }
176 let metadata = entry.metadata().map_err(|source| DevWatchError::Io {
177 path: path.clone(),
178 source,
179 })?;
180 if metadata.is_dir() {
181 collect_watch_snapshot_inner(&path, options, files)?;
182 } else if metadata.is_file() {
183 files.insert(
184 path,
185 WatchedFileState {
186 modified: metadata.modified().ok(),
187 len: metadata.len(),
188 },
189 );
190 }
191 }
192 Ok(())
193}
194
195fn diff_snapshots(left: &DevWatchSnapshot, right: &DevWatchSnapshot) -> Vec<DevWatchEvent> {
196 let mut events = Vec::new();
197 for (path, state) in &right.files {
198 match left.files.get(path) {
199 None => events.push(DevWatchEvent {
200 path: path.clone(),
201 kind: DevWatchEventKind::Created,
202 }),
203 Some(previous) if previous != state => events.push(DevWatchEvent {
204 path: path.clone(),
205 kind: DevWatchEventKind::Modified,
206 }),
207 Some(_) => {}
208 }
209 }
210 for path in left.files.keys() {
211 if !right.files.contains_key(path) {
212 events.push(DevWatchEvent {
213 path: path.clone(),
214 kind: DevWatchEventKind::Removed,
215 });
216 }
217 }
218 events
219}