pitchfork_cli/
watch_files.rs1use crate::Result;
2use glob::glob;
3use itertools::Itertools;
4use miette::IntoDiagnostic;
5use notify::{Config, EventKind, RecommendedWatcher, RecursiveMode};
6use notify_debouncer_full::{DebounceEventResult, Debouncer, FileIdMap, new_debouncer_opt};
7use std::collections::HashSet;
8use std::path::{Path, PathBuf};
9use std::time::Duration;
10
11pub struct WatchFiles {
12 pub rx: tokio::sync::mpsc::Receiver<Vec<PathBuf>>,
13 debouncer: Debouncer<RecommendedWatcher, FileIdMap>,
14}
15
16impl WatchFiles {
17 pub fn new(duration: Duration) -> Result<Self> {
18 let h = tokio::runtime::Handle::current();
19 let (tx, rx) = tokio::sync::mpsc::channel(1);
20 let debouncer = new_debouncer_opt(
21 duration,
22 None,
23 move |res: DebounceEventResult| {
24 let tx = tx.clone();
25 h.spawn(async move {
26 if let Ok(ev) = res {
27 let paths = ev
28 .into_iter()
29 .filter(|e| {
30 matches!(
31 e.kind,
32 EventKind::Modify(_)
33 | EventKind::Create(_)
34 | EventKind::Remove(_)
35 )
36 })
37 .flat_map(|e| e.paths.clone())
38 .unique()
39 .collect_vec();
40 if !paths.is_empty() {
41 let _ = tx.send(paths).await;
43 }
44 }
45 });
46 },
47 FileIdMap::new(),
48 Config::default(),
49 )
50 .into_diagnostic()?;
51
52 Ok(Self { debouncer, rx })
53 }
54
55 pub fn watch(&mut self, path: &Path, recursive_mode: RecursiveMode) -> Result<()> {
56 self.debouncer.watch(path, recursive_mode).into_diagnostic()
57 }
58}
59
60pub fn expand_watch_patterns(patterns: &[String], base_dir: &Path) -> Result<HashSet<PathBuf>> {
64 let mut dirs_to_watch = HashSet::new();
65
66 for pattern in patterns {
67 let full_pattern = if Path::new(pattern).is_absolute() {
69 pattern.clone()
70 } else {
71 base_dir.join(pattern).to_string_lossy().to_string()
72 };
73
74 match glob(&full_pattern) {
76 Ok(paths) => {
77 for entry in paths.flatten() {
78 if let Some(parent) = entry.parent() {
81 dirs_to_watch.insert(parent.to_path_buf());
82 }
83 }
84 }
85 Err(e) => {
86 log::warn!("Invalid glob pattern '{}': {}", pattern, e);
87 }
88 }
89
90 if pattern.contains('*') {
94 let parts: Vec<&str> = pattern.split('/').collect();
96 let mut base = base_dir.to_path_buf();
97 for part in parts {
98 if part.contains('*') {
99 break;
100 }
101 base = base.join(part);
102 }
103 let dir_to_watch = if base.is_dir() {
106 base
107 } else {
108 base_dir.to_path_buf()
109 };
110 dirs_to_watch.insert(dir_to_watch);
111 } else {
112 let full_path = if Path::new(pattern).is_absolute() {
115 PathBuf::from(pattern)
116 } else {
117 base_dir.join(pattern)
118 };
119 if let Some(parent) = full_path.parent() {
120 let dir_to_watch = if parent.is_dir() {
122 parent.to_path_buf()
123 } else {
124 base_dir.to_path_buf()
125 };
126 dirs_to_watch.insert(dir_to_watch);
127 }
128 }
129 }
130
131 Ok(dirs_to_watch)
132}
133
134fn normalize_path_for_glob(path: &str) -> String {
137 path.replace('\\', "/")
138}
139
140pub fn path_matches_patterns(changed_path: &Path, patterns: &[String], base_dir: &Path) -> bool {
143 let changed_path_str = normalize_path_for_glob(&changed_path.to_string_lossy());
145
146 for pattern in patterns {
147 let full_pattern = if Path::new(pattern).is_absolute() {
149 normalize_path_for_glob(pattern)
150 } else {
151 normalize_path_for_glob(&base_dir.join(pattern).to_string_lossy())
152 };
153
154 let glob = globset::GlobBuilder::new(&full_pattern)
156 .case_insensitive(cfg!(target_os = "windows"))
157 .literal_separator(true) .build();
159
160 if let Ok(glob) = glob {
161 let matcher = glob.compile_matcher();
162 if matcher.is_match(&changed_path_str) {
163 return true;
164 }
165 }
166 }
167 false
168}