use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};
use std::time::Duration;
use notify::{RecursiveMode, Watcher as _};
pub(crate) const DEBOUNCE: Duration = Duration::from_millis(300);
const IGNORED_DIRECTORIES: &[&str] = &["target", "node_modules", ".arcature", ".git"];
const GENERATED_ASSETS: &str = "resources/js/generated/";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum Change {
Restart,
Rebuild,
}
#[must_use]
pub(crate) fn classify(path: &Path) -> Option<Change> {
if ignored(path) {
return None;
}
if path.extension().is_some_and(|extension| extension == "rs") {
return Some(Change::Rebuild);
}
match path.file_name().and_then(std::ffi::OsStr::to_str) {
Some("Cargo.toml" | "Cargo.lock") => Some(Change::Rebuild),
Some(name) if name == ".env" || name.starts_with(".env.") => Some(Change::Restart),
_ => None,
}
}
#[must_use]
#[cfg(test)]
pub(crate) fn triggers_rebuild(path: &Path) -> bool {
classify(path) == Some(Change::Rebuild)
}
fn ignored(path: &Path) -> bool {
if path.components().any(|component| {
matches!(component, Component::Normal(name)
if name.to_str().is_some_and(|name| IGNORED_DIRECTORIES.contains(&name)))
}) {
return true;
}
path.to_string_lossy()
.replace('\\', "/")
.contains(GENERATED_ASSETS)
}
fn watchable(name: &std::ffi::OsStr) -> bool {
name.to_str()
.is_some_and(|name| !IGNORED_DIRECTORIES.contains(&name))
}
enum Signal {
Changed(Change),
Appeared(PathBuf),
}
pub(crate) struct Watch {
watcher: notify::RecommendedWatcher,
signals: tokio::sync::mpsc::UnboundedReceiver<Signal>,
subscribed: HashSet<PathBuf>,
pending: Option<Change>,
}
impl Watch {
pub(crate) fn start(root: &Path) -> Result<Self, notify::Error> {
let (sender, signals) = tokio::sync::mpsc::unbounded_channel();
let watch_root = root.to_path_buf();
let mut watcher =
notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
let Ok(event) = event else {
return;
};
if let Some(change) = event.paths.iter().filter_map(|path| classify(path)).max() {
let _ = sender.send(Signal::Changed(change));
}
for path in &event.paths {
if path.parent() == Some(watch_root.as_path())
&& path.file_name().is_some_and(watchable)
&& path.is_dir()
{
let _ = sender.send(Signal::Appeared(path.clone()));
}
}
})?;
watcher.watch(root, RecursiveMode::NonRecursive)?;
let mut subscribed = HashSet::new();
if let Ok(entries) = std::fs::read_dir(root) {
for entry in entries.flatten() {
if !entry.file_type().is_ok_and(|kind| kind.is_dir())
|| !watchable(&entry.file_name())
{
continue;
}
let path = entry.path();
match watcher.watch(&path, RecursiveMode::Recursive) {
Ok(()) => {
subscribed.insert(path);
}
Err(error) => eprintln!(
"warning: not watching {} for changes: {error}",
path.display()
),
}
}
}
Ok(Self {
watcher,
signals,
subscribed,
pending: None,
})
}
pub(crate) async fn next_change(&mut self) -> Option<Change> {
loop {
let signal = match self.pending {
None => self.signals.recv().await?,
Some(_) => match tokio::time::timeout(DEBOUNCE, self.signals.recv()).await {
Ok(Some(signal)) => signal,
Ok(None) | Err(_) => return self.pending.take(),
},
};
match signal {
Signal::Changed(change) => {
self.pending = Some(self.pending.map_or(change, |held| held.max(change)));
}
Signal::Appeared(path) => self.subscribe(path),
}
}
}
fn subscribe(&mut self, path: PathBuf) {
if !self.subscribed.insert(path.clone()) {
return;
}
if let Err(error) = self.watcher.watch(&path, RecursiveMode::Recursive) {
self.subscribed.remove(&path);
eprintln!(
"warning: not watching {} for changes: {error}",
path.display()
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn a_rust_source_edit_rebuilds() {
assert!(triggers_rebuild(&PathBuf::from(
"app/controllers/home_controller.rs"
)));
assert!(triggers_rebuild(&PathBuf::from("src/main.rs")));
}
#[test]
fn a_frontend_edit_never_rebuilds_because_vite_already_handled_it() {
for path in [
"resources/js/pages/home.tsx",
"resources/css/app.css",
"resources/js/app.vue",
"package.json",
] {
assert_eq!(
classify(&PathBuf::from(path)),
None,
"{path} must not cost a rebuild"
);
}
}
#[test]
fn adding_a_dependency_rebuilds_like_editing_a_source_file() {
assert!(triggers_rebuild(&PathBuf::from("Cargo.toml")));
assert!(triggers_rebuild(&PathBuf::from("Cargo.lock")));
}
#[test]
fn an_environment_edit_restarts_without_paying_for_a_compile() {
for path in [".env", ".env.local", "config/.env.development"] {
assert_eq!(
classify(&PathBuf::from(path)),
Some(Change::Restart),
"{path} changes what the process read at boot, not what the compiler reads"
);
}
}
#[test]
fn a_burst_touching_both_asks_for_the_costlier_one() {
assert!(Change::Rebuild > Change::Restart);
assert_eq!(Change::Restart.max(Change::Rebuild), Change::Rebuild);
}
#[test]
fn build_output_never_rebuilds_or_the_loop_would_never_stop() {
for path in [
"target/debug/build/foo-123/out/generated.rs",
"target/debug/incremental/app/s-abc/query-cache.rs",
] {
assert!(!triggers_rebuild(&PathBuf::from(path)), "{path}");
}
}
#[test]
fn a_vendored_manifest_inside_an_ignored_directory_is_still_ignored() {
assert_eq!(
classify(&PathBuf::from("target/package/thing-1.0/Cargo.toml")),
None
);
assert_eq!(
classify(&PathBuf::from("node_modules/esbuild/Cargo.toml")),
None
);
}
#[test]
fn the_supervisors_own_scratch_directory_never_rebuilds() {
assert_eq!(classify(&PathBuf::from(".arcature/restart")), None);
assert_eq!(classify(&PathBuf::from(".arcature/anything.rs")), None);
}
#[test]
fn generated_typescript_never_rebuilds() {
assert!(!triggers_rebuild(&PathBuf::from(
"resources/js/generated/routes.rs"
)));
assert!(!triggers_rebuild(&PathBuf::from(
r"resources\js\generated\routes.rs"
)));
}
#[test]
fn a_dependency_source_inside_node_modules_never_rebuilds() {
assert!(!triggers_rebuild(&PathBuf::from(
"node_modules/some-pkg/build.rs"
)));
}
#[test]
fn an_absolute_path_is_judged_by_the_same_rules() {
let root = std::env::temp_dir().join("project");
assert!(triggers_rebuild(&root.join("app").join("service.rs")));
assert!(!triggers_rebuild(&root.join("target").join("thing.rs")));
}
#[test]
fn the_directories_that_would_exhaust_the_kernels_watch_budget_are_never_subscribed_to() {
for name in ["target", "node_modules", ".git", ".arcature"] {
assert!(
!watchable(std::ffi::OsStr::new(name)),
"{name} must never be subscribed to recursively"
);
}
for name in ["app", "src", "config", "resources"] {
assert!(watchable(std::ffi::OsStr::new(name)), "{name}");
}
}
#[test]
fn only_the_source_directories_of_a_project_are_subscribed_to() {
let root = std::env::temp_dir().join(format!("arcature-watch-{}", std::process::id()));
drop(std::fs::remove_dir_all(&root));
for directory in ["app", "target", "node_modules", ".git"] {
std::fs::create_dir_all(root.join(directory)).expect("a temp tree");
}
std::fs::write(root.join("Cargo.toml"), b"[package]").expect("a manifest");
let watch = Watch::start(&root).expect("the watcher starts");
assert!(watch.subscribed.contains(&root.join("app")));
for ignored in ["target", "node_modules", ".git"] {
assert!(
!watch.subscribed.contains(&root.join(ignored)),
"{ignored} was subscribed to"
);
}
drop(watch);
drop(std::fs::remove_dir_all(&root));
}
}