use std::{
collections::{BTreeSet, HashSet},
ffi::OsStr,
path::{Path, PathBuf},
sync::Arc,
};
use console::style;
use ignore::{
WalkBuilder,
gitignore::{Gitignore, GitignoreBuilder},
};
use notify::{EventKind, RecursiveMode, Watcher, recommended_watcher};
use tokio::{
sync::mpsc,
time::{Duration, timeout},
};
const DEBOUNCE: Duration = Duration::from_millis(50);
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Change {
Source,
Manifest,
}
pub struct SourceWatcher {
watcher: notify::RecommendedWatcher,
events: mpsc::UnboundedReceiver<Change>,
pending: Option<Change>,
roots: Vec<PathBuf>,
watched: HashSet<PathBuf>,
filter: Arc<PathFilter>,
}
impl SourceWatcher {
pub async fn start() -> Self {
let plan = WatchPlan::discover().await;
let filter = Arc::new(plan.filter);
let (tx, events) = mpsc::unbounded_channel();
let callback_filter = Arc::clone(&filter);
let mut watcher = recommended_watcher(move |event: notify::Result<notify::Event>| {
let Ok(event) = event else { return };
if matches!(event.kind, EventKind::Access(_)) {
return;
}
let mut change = event.need_rescan().then_some(Change::Source);
for path in &event.paths {
if callback_filter.ignores(path) {
continue;
}
if path.file_name().is_some_and(|name| name == "Cargo.toml") {
change = Some(Change::Manifest);
break;
}
change = Some(Change::Source);
}
if let Some(change) = change {
let _ = tx.send(change);
}
})
.expect("failed to create file watcher");
for root in &plan.roots {
if let Err(error) = watcher.watch(root, RecursiveMode::NonRecursive) {
report_watch_error(root, &error);
}
}
let mut watcher = Self {
watcher,
events,
pending: None,
roots: plan.roots,
watched: HashSet::new(),
filter,
};
watcher.resync();
watcher
}
pub async fn changed(&mut self) -> Change {
if self.pending.is_none() {
let change = self.events.recv().await.expect("watcher channel closed");
self.pending = Some(change);
}
while let Ok(Some(change)) = timeout(DEBOUNCE, self.events.recv()).await {
if change == Change::Manifest {
self.pending = Some(Change::Manifest);
}
}
self.resync();
self.pending.take().expect("pending change set above")
}
fn resync(&mut self) {
self.watched.retain(|dir| dir.is_dir());
let mut fresh = Vec::new();
for root in &self.roots {
let Ok(entries) = std::fs::read_dir(root) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() && !self.watched.contains(&path) && !self.filter.ignores(&path) {
fresh.push(path);
}
}
}
for dir in fresh {
if let Err(error) = self.watcher.watch(&dir, RecursiveMode::Recursive) {
report_watch_error(&dir, &error);
}
self.watched.insert(dir);
}
}
}
struct WatchPlan {
roots: Vec<PathBuf>,
filter: PathFilter,
}
impl WatchPlan {
async fn discover() -> Self {
let Some(metadata) = crate::common::cargo::Metadata::full().await else {
eprintln!(
" {}",
style("cargo metadata failed; watching ./src").yellow()
);
let roots = vec![canonical(PathBuf::from("./src"))];
return Self {
filter: PathFilter {
roots: roots.clone(),
target_dir: None,
matchers: Vec::new(),
},
roots,
};
};
let mut roots: Vec<PathBuf> = metadata
.local_package_dirs()
.into_iter()
.map(canonical)
.filter(|dir| dir.is_dir())
.collect();
if let Some(root) = metadata.workspace_root() {
roots.push(canonical(root));
}
let roots = dedupe_roots(roots);
let target_dir = metadata.target_dir().map(canonical);
let matchers = gitignore_matchers(&roots, target_dir.as_deref());
Self {
filter: PathFilter {
roots: roots.clone(),
target_dir,
matchers,
},
roots,
}
}
}
struct PathFilter {
roots: Vec<PathBuf>,
target_dir: Option<PathBuf>,
matchers: Vec<Gitignore>,
}
impl PathFilter {
fn ignores(&self, path: &Path) -> bool {
if let Some(target) = &self.target_dir
&& path.starts_with(target)
{
return true;
}
if path
.file_name()
.and_then(OsStr::to_str)
.is_some_and(is_editor_temp)
{
return true;
}
if let Some(rel) = self
.roots
.iter()
.find_map(|root| path.strip_prefix(root).ok())
&& rel
.components()
.any(|component| component.as_os_str().to_string_lossy().starts_with('.'))
{
return true;
}
let is_dir = path.is_dir();
self.matchers.iter().any(|matcher| {
path.starts_with(matcher.path())
&& matcher
.matched_path_or_any_parents(path, is_dir)
.is_ignore()
})
}
}
fn is_editor_temp(name: &str) -> bool {
name.ends_with('~') || name == "4913" || (name.starts_with('#') && name.ends_with('#')) || name.contains("___jb_") }
fn dedupe_roots(mut roots: Vec<PathBuf>) -> Vec<PathBuf> {
roots.sort();
roots.dedup();
let mut kept: Vec<PathBuf> = Vec::new();
for root in roots {
if !kept.iter().any(|ancestor| root.starts_with(ancestor)) {
kept.push(root);
}
}
kept
}
fn gitignore_matchers(roots: &[PathBuf], target_dir: Option<&Path>) -> Vec<Gitignore> {
let mut gitignores = BTreeSet::new();
let mut excludes = BTreeSet::new();
for root in roots {
for dir in root.ancestors() {
let gitignore = dir.join(".gitignore");
if gitignore.is_file() {
gitignores.insert(gitignore);
}
if dir.join(".git").exists() {
let exclude = dir.join(".git/info/exclude");
if exclude.is_file() {
excludes.insert((dir.to_path_buf(), exclude));
}
break;
}
}
let target = target_dir.map(Path::to_path_buf);
let mut walk = WalkBuilder::new(root);
walk.require_git(false)
.filter_entry(move |entry| Some(entry.path()) != target.as_deref());
for entry in walk.build().flatten() {
if entry.file_type().is_some_and(|kind| kind.is_dir()) {
let gitignore = entry.path().join(".gitignore");
if gitignore.is_file() {
gitignores.insert(gitignore);
}
}
}
}
let mut matchers = Vec::new();
for path in gitignores {
let (matcher, _error) = Gitignore::new(&path);
if !matcher.is_empty() {
matchers.push(matcher);
}
}
for (repo_root, exclude) in excludes {
let mut builder = GitignoreBuilder::new(&repo_root);
builder.add(&exclude);
if let Ok(matcher) = builder.build()
&& !matcher.is_empty()
{
matchers.push(matcher);
}
}
matchers
}
fn canonical(path: PathBuf) -> PathBuf {
path.canonicalize().unwrap_or(path)
}
fn report_watch_error(path: &Path, error: ¬ify::Error) {
eprintln!(
" {}",
style(format!("failed to watch {}: {error}", path.display())).yellow()
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn editor_temp_names() {
assert!(is_editor_temp("main.rs~"));
assert!(is_editor_temp("4913"));
assert!(is_editor_temp("#main.rs#"));
assert!(is_editor_temp("main.rs.___jb_tmp___"));
assert!(!is_editor_temp("main.rs"));
assert!(!is_editor_temp("Cargo.toml"));
}
#[test]
fn dedupe_drops_contained_roots() {
let roots = vec![
PathBuf::from("/work/app/crates/web"),
PathBuf::from("/work/app"),
PathBuf::from("/work/app"),
PathBuf::from("/work/lib"),
];
let expected = vec![PathBuf::from("/work/app"), PathBuf::from("/work/lib")];
assert_eq!(dedupe_roots(roots), expected);
}
#[test]
fn filter_ignores_irrelevant_paths() {
let root = std::env::temp_dir().join(format!("topcoat-watch-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join(".gitignore"), "generated/\n*.log\n").unwrap();
let filter = PathFilter {
roots: vec![root.clone()],
target_dir: Some(root.join("target")),
matchers: gitignore_matchers(std::slice::from_ref(&root), None),
};
assert!(filter.ignores(&root.join("target/debug/app")));
assert!(filter.ignores(&root.join(".git/HEAD")));
assert!(filter.ignores(&root.join("src/.main.rs.swp")));
assert!(filter.ignores(&root.join("src/main.rs~")));
assert!(filter.ignores(&root.join("generated/out.rs")));
assert!(filter.ignores(&root.join("server.log")));
assert!(!filter.ignores(&root.join("src/main.rs")));
assert!(!filter.ignores(&root.join("Cargo.toml")));
assert!(!filter.ignores(&root.join("assets/logo.svg")));
std::fs::remove_dir_all(&root).unwrap();
}
}