use crate::generate::{find_project_root, GenerateError};
use notify::RecursiveMode;
use notify_debouncer_mini::new_debouncer;
use std::io;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc::{channel, RecvTimeoutError};
use std::time::Duration;
pub struct DevArgs {
pub project_root: Option<PathBuf>,
pub watch_paths: Vec<PathBuf>,
pub debounce_ms: u64,
}
impl Default for DevArgs {
fn default() -> Self {
Self {
project_root: None,
watch_paths: Vec::new(),
debounce_ms: 250,
}
}
}
#[derive(Debug)]
pub enum DevError {
ProjectRoot(GenerateError),
Watcher(notify::Error),
Io { path: PathBuf, source: io::Error },
CargoSpawn(io::Error),
}
impl std::fmt::Display for DevError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ProjectRoot(e) => write!(f, "{e}"),
Self::Watcher(e) => write!(f, "could not set up file watcher: {e}"),
Self::Io { path, source } => write!(f, "I/O error at `{}`: {source}", path.display()),
Self::CargoSpawn(e) => write!(f, "could not spawn `cargo run`: {e}"),
}
}
}
impl std::error::Error for DevError {}
pub fn run(args: &DevArgs) -> Result<(), DevError> {
let root = match &args.project_root {
Some(p) => p.clone(),
None => find_project_root(Path::new(".")).map_err(DevError::ProjectRoot)?,
};
eprintln!(
"cargo kick dev — starting initial run in `{}`",
root.display()
);
let mut child = spawn_cargo_run(&root)?;
let (tx, rx) = channel();
let mut debouncer = new_debouncer(Duration::from_millis(args.debounce_ms), move |res| {
let _ = tx.send(res);
})
.map_err(DevError::Watcher)?;
let watcher = debouncer.watcher();
let src = root.join("src");
watcher
.watch(&src, RecursiveMode::Recursive)
.map_err(DevError::Watcher)?;
eprintln!(" watching {}", src.display());
for extra in &args.watch_paths {
let abs = if extra.is_absolute() {
extra.clone()
} else {
root.join(extra)
};
watcher
.watch(&abs, RecursiveMode::Recursive)
.map_err(DevError::Watcher)?;
eprintln!(" watching {}", abs.display());
}
eprintln!(" Ctrl-C to quit.\n");
loop {
match rx.recv_timeout(Duration::from_millis(500)) {
Ok(Ok(events)) => {
if !is_relevant(&events) {
continue;
}
eprintln!("\ncargo kick dev — change detected; restarting\n");
kill_silently(&mut child);
child = spawn_cargo_run(&root)?;
}
Ok(Err(errs)) => {
eprintln!("cargo kick dev — watcher error: {errs:?}");
}
Err(RecvTimeoutError::Timeout) => {
let _ = child.try_wait();
}
Err(RecvTimeoutError::Disconnected) => {
kill_silently(&mut child);
return Ok(());
}
}
}
}
fn spawn_cargo_run(root: &Path) -> Result<Child, DevError> {
let mut cmd = Command::new("cargo");
cmd.arg("run")
.current_dir(root)
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
set_new_process_group(&mut cmd);
cmd.spawn().map_err(DevError::CargoSpawn)
}
#[cfg(unix)]
fn set_new_process_group(cmd: &mut Command) {
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
#[cfg(windows)]
fn set_new_process_group(cmd: &mut Command) {
use std::os::windows::process::CommandExt;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP);
}
#[cfg(not(any(unix, windows)))]
fn set_new_process_group(_: &mut Command) {
}
fn kill_silently(child: &mut Child) {
let pid = child.id();
kill_process_tree(pid);
let _ = child.kill();
let _ = child.wait();
}
#[cfg(unix)]
fn kill_process_tree(pid: u32) {
let group_arg = format!("-{pid}");
let _ = Command::new("kill")
.arg("-TERM")
.arg(&group_arg)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
std::thread::sleep(std::time::Duration::from_millis(200));
let _ = Command::new("kill")
.arg("-KILL")
.arg(&group_arg)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
#[cfg(windows)]
fn kill_process_tree(pid: u32) {
let _ = Command::new("taskkill")
.args(["/F", "/T", "/PID"])
.arg(pid.to_string())
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
#[cfg(not(any(unix, windows)))]
fn kill_process_tree(_pid: u32) {
}
pub(crate) fn is_relevant(events: &[notify_debouncer_mini::DebouncedEvent]) -> bool {
events.iter().any(|e| is_relevant_path(&e.path))
}
pub(crate) fn is_relevant_path(p: &Path) -> bool {
for comp in p.components() {
match comp.as_os_str().to_str() {
Some("target") | Some(".git") | Some("node_modules") => return false,
Some(s) if s.starts_with('~') => return false,
Some(s) if s.ends_with("~") => return false,
_ => {}
}
}
let in_src = p
.components()
.any(|c| c.as_os_str().to_str() == Some("src"));
if in_src {
return true;
}
matches!(
p.extension().and_then(|s| s.to_str()),
Some("rs" | "toml" | "lock" | "html" | "css" | "js" | "json" | "yaml" | "yml")
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn is_relevant_path_accepts_rs_in_src() {
assert!(is_relevant_path(&PathBuf::from("src/main.rs")));
assert!(is_relevant_path(&PathBuf::from(
"src/modules/posts/handlers.rs"
)));
}
#[test]
fn is_relevant_path_accepts_toml() {
assert!(is_relevant_path(&PathBuf::from("Cargo.toml")));
}
#[test]
fn is_relevant_path_rejects_target() {
assert!(!is_relevant_path(&PathBuf::from(
"target/debug/build/foo.rs"
)));
assert!(!is_relevant_path(&PathBuf::from(
"/abs/proj/target/debug/app.exe"
)));
}
#[test]
fn is_relevant_path_rejects_git_and_node_modules() {
assert!(!is_relevant_path(&PathBuf::from(".git/HEAD")));
assert!(!is_relevant_path(&PathBuf::from(
"node_modules/foo/index.js"
)));
}
#[test]
fn is_relevant_path_rejects_editor_temp_files() {
assert!(!is_relevant_path(&PathBuf::from("src/main.rs~")));
assert!(!is_relevant_path(&PathBuf::from("~scratch.rs")));
}
#[test]
fn is_relevant_path_rejects_unrelated_extensions() {
assert!(!is_relevant_path(&PathBuf::from("notes.txt")));
assert!(!is_relevant_path(&PathBuf::from("logo.png")));
}
}