Skip to main content

weavatrix_scan/
watch.rs

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/// Normalized watcher event category independent of a watcher implementation.
8#[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/// One raw path event supplied by a filesystem watcher.
20#[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/// Deterministic invalidation plan derived from watcher events.
37#[derive(Debug, Clone, Default, PartialEq, Eq)]
38pub struct WatchPlan {
39    pub changed: Vec<String>,
40    pub removed: Vec<String>,
41    /// Directory, ignore-input, root, or explicit rescan event was observed.
42    pub full_rescan: bool,
43    /// Events outside the configured root or containing traversal components.
44    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
53/// Converts watcher-specific path notifications into scanner cache work.
54pub struct WatcherEventAdapter {
55    root: PathBuf,
56    event_root: PathBuf,
57    ignore_files: BTreeSet<String>,
58}
59
60impl WatcherEventAdapter {
61    /// Creates an adapter for an existing repository root.
62    ///
63    /// # Errors
64    ///
65    /// Returns an error when the root cannot be canonicalized or is not a
66    /// directory.
67    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    /// Creates an adapter using the scanner's configured ignore filenames.
97    ///
98    /// # Errors
99    ///
100    /// Returns the same errors as [`Self::new`].
101    pub fn with_options(root: impl AsRef<Path>, options: &ScanOptions) -> Result<Self> {
102        Self::new(root, &options.ignore_files)
103    }
104
105    /// Coalesces raw watcher events into a stable scanner invalidation plan.
106    #[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}