use crate::Result;
use glob::glob;
use itertools::Itertools;
use miette::IntoDiagnostic;
use notify::{Config, EventKind, RecommendedWatcher, RecursiveMode};
use notify_debouncer_full::{DebounceEventResult, Debouncer, FileIdMap, new_debouncer_opt};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::time::Duration;
pub struct WatchFiles {
pub rx: tokio::sync::mpsc::Receiver<Vec<PathBuf>>,
debouncer: Debouncer<RecommendedWatcher, FileIdMap>,
}
impl WatchFiles {
pub fn new(duration: Duration) -> Result<Self> {
let h = tokio::runtime::Handle::current();
let (tx, rx) = tokio::sync::mpsc::channel(1);
let debouncer = new_debouncer_opt(
duration,
None,
move |res: DebounceEventResult| {
let tx = tx.clone();
h.spawn(async move {
if let Ok(ev) = res {
let paths = ev
.into_iter()
.filter(|e| {
matches!(
e.kind,
EventKind::Modify(_)
| EventKind::Create(_)
| EventKind::Remove(_)
)
})
.flat_map(|e| e.paths.clone())
.unique()
.collect_vec();
if !paths.is_empty() {
let _ = tx.send(paths).await;
}
}
});
},
FileIdMap::new(),
Config::default(),
)
.into_diagnostic()?;
Ok(Self { debouncer, rx })
}
pub fn watch(&mut self, path: &Path, recursive_mode: RecursiveMode) -> Result<()> {
self.debouncer.watch(path, recursive_mode).into_diagnostic()
}
pub fn unwatch(&mut self, path: &Path) -> Result<()> {
self.debouncer.unwatch(path).into_diagnostic()
}
}
fn normalize_watch_path(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| {
if path.is_absolute() {
path.to_path_buf()
} else {
crate::env::CWD.join(path)
}
})
}
pub fn expand_watch_patterns(patterns: &[String], base_dir: &Path) -> Result<HashSet<PathBuf>> {
let mut dirs_to_watch = HashSet::new();
for pattern in patterns {
let normalized_pattern = pattern.strip_prefix("./").unwrap_or(pattern);
let full_pattern = if Path::new(normalized_pattern).is_absolute() {
normalize_path_for_glob(normalized_pattern)
} else {
normalize_path_for_glob(&base_dir.join(normalized_pattern).to_string_lossy())
};
match glob(&full_pattern) {
Ok(paths) => {
for entry in paths.flatten() {
if let Some(parent) = entry.parent() {
dirs_to_watch.insert(normalize_watch_path(parent));
}
}
}
Err(e) => {
log::warn!("Invalid glob pattern '{pattern}': {e}");
}
}
if normalized_pattern.contains('*') {
let normalized_pattern_str = normalize_path_for_glob(normalized_pattern);
let parts: Vec<&str> = normalized_pattern_str.split('/').collect();
let mut base = base_dir.to_path_buf();
for part in parts {
if part.contains('*') {
break;
}
base = base.join(part);
}
let dir_to_watch = if base.is_dir() {
base
} else {
base_dir.to_path_buf()
};
dirs_to_watch.insert(normalize_watch_path(&dir_to_watch));
} else {
let full_path = if Path::new(normalized_pattern).is_absolute() {
PathBuf::from(normalized_pattern)
} else {
base_dir.join(normalized_pattern)
};
if let Some(parent) = full_path.parent() {
let dir_to_watch = if parent.is_dir() {
parent.to_path_buf()
} else {
base_dir.to_path_buf()
};
dirs_to_watch.insert(normalize_watch_path(&dir_to_watch));
}
}
}
Ok(dirs_to_watch)
}
fn normalize_path_for_glob(path: &str) -> String {
path.replace('\\', "/")
}
pub fn path_matches_patterns(changed_path: &Path, patterns: &[String], base_dir: &Path) -> bool {
let changed_path_str = normalize_path_for_glob(&changed_path.to_string_lossy());
for pattern in patterns {
let normalized_pattern = pattern.strip_prefix("./").unwrap_or(pattern);
let full_pattern = if Path::new(normalized_pattern).is_absolute() {
normalize_path_for_glob(normalized_pattern)
} else {
normalize_path_for_glob(&base_dir.join(normalized_pattern).to_string_lossy())
};
let glob = globset::GlobBuilder::new(&full_pattern)
.case_insensitive(cfg!(target_os = "windows"))
.literal_separator(true) .build();
if let Ok(glob) = glob {
let matcher = glob.compile_matcher();
if matcher.is_match(&changed_path_str) {
return true;
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_normalize_watch_path_existing_directory() {
let temp_dir = TempDir::new().unwrap();
let dir_path = temp_dir.path().join("test_dir");
fs::create_dir(&dir_path).unwrap();
let normalized = normalize_watch_path(&dir_path);
assert!(normalized.is_absolute());
assert!(normalized.exists());
}
#[test]
fn test_normalize_watch_path_nonexistent_path() {
let path = PathBuf::from("/nonexistent/path/to/dir");
let normalized = normalize_watch_path(&path);
assert_eq!(normalized, path);
}
#[test]
fn test_normalize_watch_path_deduplication() {
let temp_dir = TempDir::new().unwrap();
let dir_path = temp_dir.path().join("test_dir");
fs::create_dir(&dir_path).unwrap();
let subdir = dir_path.join("subdir");
fs::create_dir(&subdir).unwrap();
let path1 = subdir.clone();
let path2 = subdir.join("..").join("subdir");
let normalized1 = normalize_watch_path(&path1);
let normalized2 = normalize_watch_path(&path2);
assert_eq!(normalized1, normalized2);
}
#[test]
fn test_expand_watch_patterns_specific_file() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
let test_file = base_dir.join("package.json");
fs::write(&test_file, "{}").unwrap();
let patterns = vec!["package.json".to_string()];
let dirs = expand_watch_patterns(&patterns, base_dir).unwrap();
assert_eq!(dirs.len(), 1);
let dir = dirs.iter().next().unwrap();
assert!(dir.is_absolute());
}
#[test]
fn test_expand_watch_patterns_glob() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
let subdir = base_dir.join("src");
fs::create_dir(&subdir).unwrap();
fs::write(subdir.join("file1.rs"), "").unwrap();
fs::write(subdir.join("file2.rs"), "").unwrap();
let patterns = vec!["src/**/*.rs".to_string()];
let dirs = expand_watch_patterns(&patterns, base_dir).unwrap();
assert!(!dirs.is_empty());
for dir in &dirs {
assert!(dir.is_absolute());
}
}
#[test]
fn test_expand_watch_patterns_nonexistent_file() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
let patterns = vec!["config.toml".to_string()];
let dirs = expand_watch_patterns(&patterns, base_dir).unwrap();
assert_eq!(dirs.len(), 1);
}
#[test]
fn test_path_matches_patterns_simple() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
let test_txt = base_dir.join("test.txt");
let test_rs = base_dir.join("test.rs");
fs::write(&test_txt, "").unwrap();
fs::write(&test_rs, "").unwrap();
assert!(path_matches_patterns(
&test_txt,
&["*.txt".to_string()],
base_dir
));
assert!(!path_matches_patterns(
&test_rs,
&["*.txt".to_string()],
base_dir
));
}
#[test]
fn test_path_matches_patterns_recursive_glob() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
let src_dir = base_dir.join("src");
let deep_dir = src_dir.join("deep");
fs::create_dir_all(&deep_dir).unwrap();
let deep_file = deep_dir.join("file.rs");
let src_file = src_dir.join("file.rs");
fs::write(&deep_file, "").unwrap();
fs::write(&src_file, "").unwrap();
assert!(path_matches_patterns(
&deep_file,
&["src/**/*.rs".to_string()],
base_dir
));
assert!(path_matches_patterns(
&src_file,
&["src/**/*.rs".to_string()],
base_dir
));
}
#[test]
fn test_path_matches_patterns_multiple_patterns() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
let cargo_toml = base_dir.join("Cargo.toml");
let main_rs = base_dir.join("main.rs");
let readme_md = base_dir.join("README.md");
fs::write(&cargo_toml, "").unwrap();
fs::write(&main_rs, "").unwrap();
fs::write(&readme_md, "").unwrap();
let patterns = vec!["*.rs".to_string(), "*.toml".to_string()];
assert!(path_matches_patterns(&cargo_toml, &patterns, base_dir));
assert!(path_matches_patterns(&main_rs, &patterns, base_dir));
assert!(!path_matches_patterns(&readme_md, &patterns, base_dir));
}
#[test]
fn test_path_matches_patterns_relative_prefix() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
let test_file = base_dir.join("config.json");
fs::write(&test_file, "{}").unwrap();
assert!(path_matches_patterns(
&test_file,
&["./config.json".to_string()],
base_dir
));
assert!(path_matches_patterns(
&test_file,
&["config.json".to_string()],
base_dir
));
}
#[test]
fn test_expand_watch_patterns_relative_prefix() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
let test_file = base_dir.join("config.json");
fs::write(&test_file, "{}").unwrap();
let patterns = vec!["./config.json".to_string()];
let dirs = expand_watch_patterns(&patterns, base_dir).unwrap();
assert_eq!(dirs.len(), 1);
let dir = dirs.iter().next().unwrap();
assert!(dir.is_absolute());
}
}