Skip to main content

runx_runtime/dev/
watch.rs

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}