1use crate::config::ScanOptions;
2use crate::error::{Error, Result};
3use crate::path::normalized_relative_path;
4use std::collections::{BTreeSet, HashSet};
5use std::path::{Component, Path, PathBuf};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum WatchEventKind {
10 Create,
11 Modify,
12 Remove,
13 RenameFrom,
14 RenameTo,
15 Directory,
16 Rescan,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct WatchEvent {
22 pub path: PathBuf,
23 pub kind: WatchEventKind,
24}
25
26impl WatchEvent {
27 #[must_use]
28 pub fn new(path: impl Into<PathBuf>, kind: WatchEventKind) -> Self {
29 Self {
30 path: path.into(),
31 kind,
32 }
33 }
34}
35
36#[derive(Debug, Clone, Default, PartialEq, Eq)]
38pub struct WatchPlan {
39 pub changed: Vec<String>,
40 pub removed: Vec<String>,
41 pub full_rescan: bool,
43 pub rejected_events: u64,
45}
46
47impl WatchPlan {
48 pub fn invalidated(&self) -> impl Iterator<Item = &str> {
49 self.changed.iter().chain(&self.removed).map(String::as_str)
50 }
51}
52
53pub struct WatcherEventAdapter {
55 root: PathBuf,
56 event_root: PathBuf,
57 ignore_files: BTreeSet<String>,
58}
59
60impl WatcherEventAdapter {
61 pub fn new<I, S>(root: impl AsRef<Path>, ignore_files: I) -> Result<Self>
68 where
69 I: IntoIterator<Item = S>,
70 S: AsRef<str>,
71 {
72 let requested = root.as_ref();
73 let event_root = if requested.is_absolute() {
74 requested.to_path_buf()
75 } else {
76 std::env::current_dir()
77 .map_err(|source| Error::io(requested, source))?
78 .join(requested)
79 };
80 let root = requested
81 .canonicalize()
82 .map_err(|source| Error::io(requested, source))?;
83 if !root.is_dir() {
84 return Err(Error::InvalidRoot(root));
85 }
86 Ok(Self {
87 root,
88 event_root,
89 ignore_files: ignore_files
90 .into_iter()
91 .map(|name| name.as_ref().replace('\\', "/"))
92 .collect(),
93 })
94 }
95
96 pub fn with_options(root: impl AsRef<Path>, options: &ScanOptions) -> Result<Self> {
102 Self::new(root, &options.ignore_files)
103 }
104
105 #[must_use]
107 pub fn plan<I>(&self, events: I) -> WatchPlan
108 where
109 I: IntoIterator<Item = WatchEvent>,
110 {
111 let mut changed = HashSet::new();
112 let mut removed = HashSet::new();
113 let mut full_rescan = false;
114 let mut rejected_events = 0_u64;
115
116 for event in events {
117 if event.kind == WatchEventKind::Rescan {
118 full_rescan = true;
119 continue;
120 }
121 let Some(relative) = self.relative(&event.path) else {
122 rejected_events = rejected_events.saturating_add(1);
123 continue;
124 };
125 if relative.is_empty()
126 || event.kind == WatchEventKind::Directory
127 || self.controls_selection(&relative)
128 {
129 full_rescan = true;
130 continue;
131 }
132 match event.kind {
133 WatchEventKind::Create | WatchEventKind::Modify | WatchEventKind::RenameTo => {
134 removed.remove(&relative);
135 changed.insert(relative);
136 }
137 WatchEventKind::Remove | WatchEventKind::RenameFrom => {
138 changed.remove(&relative);
139 removed.insert(relative);
140 }
141 WatchEventKind::Directory | WatchEventKind::Rescan => {
142 full_rescan = true;
143 }
144 }
145 }
146
147 let mut changed = changed.into_iter().collect::<Vec<_>>();
148 let mut removed = removed.into_iter().collect::<Vec<_>>();
149 changed.sort_unstable();
150 removed.sort_unstable();
151 WatchPlan {
152 changed,
153 removed,
154 full_rescan,
155 rejected_events,
156 }
157 }
158
159 fn relative(&self, path: &Path) -> Option<String> {
160 let relative = if path.is_absolute() {
161 strip_root(path, &self.event_root)
162 .or_else(|| strip_root(path, &self.root))
163 .or_else(|| strip_canonical_root(path, &self.root))?
164 } else {
165 path.to_path_buf()
166 };
167 if relative.components().any(|component| {
168 matches!(
169 component,
170 Component::ParentDir | Component::RootDir | Component::Prefix(_)
171 )
172 }) {
173 return None;
174 }
175 Some(normalized_relative_path(&relative))
176 }
177
178 fn controls_selection(&self, relative: &str) -> bool {
179 if matches!(relative, ".git/config" | ".git/info/exclude") {
180 return true;
181 }
182 let file_name = Path::new(relative)
183 .file_name()
184 .and_then(|name| name.to_str());
185 file_name.is_some_and(|file_name| {
186 self.ignore_files.iter().any(|configured| {
187 Path::new(configured)
188 .file_name()
189 .and_then(|name| name.to_str())
190 == Some(file_name)
191 })
192 })
193 }
194}
195
196fn strip_root(path: &Path, root: &Path) -> Option<PathBuf> {
197 if let Ok(relative) = path.strip_prefix(root) {
198 return Some(relative.to_path_buf());
199 }
200 #[cfg(windows)]
201 if let Some(relative) = strip_windows_root(path, root) {
202 return Some(relative);
203 }
204 None
205}
206
207fn strip_canonical_root(path: &Path, root: &Path) -> Option<PathBuf> {
208 if let Ok(canonical) = path.canonicalize()
209 && let Ok(relative) = canonical.strip_prefix(root)
210 {
211 return Some(relative.to_path_buf());
212 }
213 None
214}
215
216#[cfg(windows)]
217fn strip_windows_root(path: &Path, root: &Path) -> Option<PathBuf> {
218 let mut path_components = path.components();
219 for root_component in root.components() {
220 let path_component = path_components.next()?;
221 if !windows_component_eq(path_component, root_component) {
222 return None;
223 }
224 }
225 Some(path_components.collect())
226}
227
228#[cfg(windows)]
229fn windows_component_eq(left: Component<'_>, right: Component<'_>) -> bool {
230 let normalize = |component: Component<'_>| {
231 let value = component.as_os_str().to_string_lossy();
232 value.strip_prefix(r"\\?\UNC\").map_or_else(
233 || value.strip_prefix(r"\\?\").unwrap_or(&value).to_owned(),
234 |suffix| format!(r"\\{suffix}"),
235 )
236 };
237 normalize(left).eq_ignore_ascii_case(&normalize(right))
238}