use std::path::{Path, PathBuf};
use std::time::Duration;
use notify_debouncer_mini::{DebouncedEventKind, new_debouncer};
#[derive(Debug, Clone)]
pub enum ChangeKind {
TestFile(PathBuf),
SourceFile(PathBuf),
FeatureFile(PathBuf),
StepFile(PathBuf),
Config,
}
const DEFAULT_IGNORE_SEGMENTS: &[&str] = &["target", "node_modules", ".git", "test-results", "dist", ".next"];
fn is_ignored(path: &Path, extra_ignore: &[String]) -> bool {
path.components().any(|c| {
if let std::path::Component::Normal(s) = c {
let s = s.to_str().unwrap_or("");
DEFAULT_IGNORE_SEGMENTS.contains(&s) || extra_ignore.iter().any(|ign| ign == s)
} else {
false
}
})
}
pub struct FileWatcher {
rx: async_channel::Receiver<ChangeKind>,
_debouncer: notify_debouncer_mini::Debouncer<notify::RecommendedWatcher>,
}
impl FileWatcher {
pub fn new(root: &Path, test_globs: &[String], ignore_patterns: &[String]) -> ferridriver::error::Result<Self> {
use ferridriver::FerriError;
let (tx, rx) = async_channel::bounded(256);
let compiled_globs: Vec<glob::Pattern> = test_globs.iter().filter_map(|g| glob::Pattern::new(g).ok()).collect();
let root_owned = root.to_path_buf();
let extra_ignore: Vec<String> = ignore_patterns.to_vec();
let mut debouncer = new_debouncer(
Duration::from_millis(100),
move |result: Result<Vec<notify_debouncer_mini::DebouncedEvent>, notify::Error>| {
let Ok(events) = result else { return };
for event in events {
if event.kind != DebouncedEventKind::Any {
continue;
}
let path = &event.path;
if is_ignored(path, &extra_ignore) {
continue;
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
match ext {
"rs" | "ts" | "tsx" | "js" | "jsx" | "mts" | "mjs" | "feature" => {},
"toml" | "json" => {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if !name.starts_with("ferridriver.config")
&& !name.starts_with("tsconfig")
&& !name.starts_with("package")
{
continue;
}
},
_ => continue,
}
let kind = classify_change(path, &root_owned, &compiled_globs);
let _ = tx.try_send(kind);
}
},
)
.map_err(|e| FerriError::backend(format!("create file watcher: {e}")))?;
debouncer
.watcher()
.watch(root, notify::RecursiveMode::Recursive)
.map_err(|e| FerriError::backend(format!("watch {}: {e}", root.display())))?;
Ok(Self {
rx,
_debouncer: debouncer,
})
}
pub async fn recv(&self) -> Option<ChangeKind> {
self.rx.recv().await.ok()
}
pub fn drain_deduped(&self) -> Vec<ChangeKind> {
let mut seen = rustc_hash::FxHashSet::default();
let mut changes = Vec::new();
while let Ok(kind) = self.rx.try_recv() {
let key = match &kind {
ChangeKind::TestFile(p) | ChangeKind::SourceFile(p) | ChangeKind::FeatureFile(p) | ChangeKind::StepFile(p) => {
p.clone()
},
ChangeKind::Config => std::path::PathBuf::from("__config__"),
};
if seen.insert(key) {
changes.push(kind);
}
}
changes
}
}
fn classify_change(path: &Path, root: &Path, test_globs: &[glob::Pattern]) -> ChangeKind {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name == "ferridriver.config.toml" || name == "ferridriver.config.json" {
return ChangeKind::Config;
}
}
if ext == "feature" {
return ChangeKind::FeatureFile(path.to_path_buf());
}
if (ext == "rs" || ext == "ts" || ext == "js")
&& path
.components()
.any(|c| matches!(c, std::path::Component::Normal(s) if s == "steps" || s == "step_definitions"))
{
return ChangeKind::StepFile(path.to_path_buf());
}
if let Ok(relative) = path.strip_prefix(root) {
let rel_str = relative.to_string_lossy();
for glob in test_globs {
if glob.matches(&rel_str) {
return ChangeKind::TestFile(path.to_path_buf());
}
}
}
ChangeKind::SourceFile(path.to_path_buf())
}