kick_rs_cli/dev.rs
1//! `cargo kick dev` — watch the project's source tree and restart
2//! the app on save.
3//!
4//! Thin wrapper over `cargo run`. On each batch of debounced file
5//! events under `src/` (or any user-supplied path), we kill the
6//! current child *and the entire process tree it spawned* and
7//! respawn `cargo run`. stdout/stderr from the child stream through
8//! to the user's terminal so compile errors and runtime logs land
9//! as they would for a manual `cargo run`.
10//!
11//! Process-tree cleanup: `cargo run` itself spawns the built
12//! binary. Without explicit tree termination, killing cargo leaves
13//! the app running (and the port bound) across the restart. We
14//! work around that by:
15//!
16//! - Spawning cargo in its own process group (Unix `setpgid` /
17//! Windows `CREATE_NEW_PROCESS_GROUP`).
18//! - On kill, sending the signal to the whole group: `kill -KILL
19//! -<pgid>` on Unix, `taskkill /F /T /PID` on Windows.
20//!
21//! Result: restart releases the port immediately on both platforms.
22
23use crate::generate::{find_project_root, GenerateError};
24use notify::RecursiveMode;
25use notify_debouncer_mini::new_debouncer;
26use std::io;
27use std::path::{Path, PathBuf};
28use std::process::{Child, Command, Stdio};
29use std::sync::mpsc::{channel, RecvTimeoutError};
30use std::time::Duration;
31
32/// Decoded form of the `dev` subcommand.
33pub struct DevArgs {
34 /// Override the project root. Defaults to walking up from `cwd`.
35 pub project_root: Option<PathBuf>,
36 /// Extra paths to watch (in addition to `src/`). Useful for
37 /// templates, static fixtures, anything that should trigger a
38 /// rebuild but doesn't live under `src/`. Defaults to empty.
39 pub watch_paths: Vec<PathBuf>,
40 /// Debounce window for file events. Defaults to 250ms — long
41 /// enough to swallow the multi-event storm editors emit on save,
42 /// short enough that adopters don't notice the lag.
43 pub debounce_ms: u64,
44}
45
46impl Default for DevArgs {
47 fn default() -> Self {
48 Self {
49 project_root: None,
50 watch_paths: Vec::new(),
51 debounce_ms: 250,
52 }
53 }
54}
55
56#[derive(Debug)]
57pub enum DevError {
58 ProjectRoot(GenerateError),
59 Watcher(notify::Error),
60 Io { path: PathBuf, source: io::Error },
61 CargoSpawn(io::Error),
62}
63
64impl std::fmt::Display for DevError {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 match self {
67 Self::ProjectRoot(e) => write!(f, "{e}"),
68 Self::Watcher(e) => write!(f, "could not set up file watcher: {e}"),
69 Self::Io { path, source } => write!(f, "I/O error at `{}`: {source}", path.display()),
70 Self::CargoSpawn(e) => write!(f, "could not spawn `cargo run`: {e}"),
71 }
72 }
73}
74
75impl std::error::Error for DevError {}
76
77/// Run the dev loop. Returns only when the user Ctrl-C's the parent
78/// process — the watcher otherwise loops forever.
79pub fn run(args: &DevArgs) -> Result<(), DevError> {
80 let root = match &args.project_root {
81 Some(p) => p.clone(),
82 None => find_project_root(Path::new(".")).map_err(DevError::ProjectRoot)?,
83 };
84
85 // Initial spawn — fail fast if `cargo` isn't on PATH.
86 eprintln!(
87 "cargo kick dev — starting initial run in `{}`",
88 root.display()
89 );
90 let mut child = spawn_cargo_run(&root)?;
91
92 // notify-debouncer-mini coalesces event storms into one
93 // `Vec<DebouncedEvent>` per debounce window. The channel
94 // receives those vecs; one vec = one rebuild trigger.
95 let (tx, rx) = channel();
96 let mut debouncer = new_debouncer(Duration::from_millis(args.debounce_ms), move |res| {
97 // We pass the Result through unchanged — the loop below
98 // logs errors but keeps watching.
99 let _ = tx.send(res);
100 })
101 .map_err(DevError::Watcher)?;
102
103 let watcher = debouncer.watcher();
104
105 // Always watch `src/`. Adopter-supplied extras come next.
106 let src = root.join("src");
107 watcher
108 .watch(&src, RecursiveMode::Recursive)
109 .map_err(DevError::Watcher)?;
110 eprintln!(" watching {}", src.display());
111 for extra in &args.watch_paths {
112 let abs = if extra.is_absolute() {
113 extra.clone()
114 } else {
115 root.join(extra)
116 };
117 watcher
118 .watch(&abs, RecursiveMode::Recursive)
119 .map_err(DevError::Watcher)?;
120 eprintln!(" watching {}", abs.display());
121 }
122
123 eprintln!(" Ctrl-C to quit.\n");
124
125 // Main loop: every time the debounce window yields events,
126 // kill the current child and respawn. Idle times use a short
127 // recv_timeout so we can also poll the child's liveness — if
128 // the binary exits on its own (build failure, runtime panic),
129 // we don't want to leave a zombie around the next time a save
130 // fires.
131 loop {
132 match rx.recv_timeout(Duration::from_millis(500)) {
133 Ok(Ok(events)) => {
134 if !is_relevant(&events) {
135 continue;
136 }
137 eprintln!("\ncargo kick dev — change detected; restarting\n");
138 kill_silently(&mut child);
139 child = spawn_cargo_run(&root)?;
140 }
141 Ok(Err(errs)) => {
142 eprintln!("cargo kick dev — watcher error: {errs:?}");
143 }
144 Err(RecvTimeoutError::Timeout) => {
145 // Reap exited child without blocking — keeps zombies off
146 // the table on platforms that don't auto-reap.
147 let _ = child.try_wait();
148 }
149 Err(RecvTimeoutError::Disconnected) => {
150 // The debouncer's sender hung up — unexpected; treat
151 // as a fatal condition and exit the loop.
152 kill_silently(&mut child);
153 return Ok(());
154 }
155 }
156 }
157}
158
159/// Spawn `cargo run` rooted at `root`, in its own process group so
160/// a single kill signal can reach grandchildren (the built binary).
161///
162/// Without this, `Child::kill()` only terminates `cargo` itself; the
163/// app it spawned keeps running, holding ports / DB pools / etc.
164/// across our restart.
165fn spawn_cargo_run(root: &Path) -> Result<Child, DevError> {
166 let mut cmd = Command::new("cargo");
167 cmd.arg("run")
168 .current_dir(root)
169 .stdin(Stdio::null())
170 .stdout(Stdio::inherit())
171 .stderr(Stdio::inherit());
172 set_new_process_group(&mut cmd);
173 cmd.spawn().map_err(DevError::CargoSpawn)
174}
175
176/// Platform glue for "make this child the leader of a new process
177/// group". On Unix this is `setpgid(0,0)` (via std's stable
178/// `process_group(0)` extension). On Windows it's
179/// `CREATE_NEW_PROCESS_GROUP` in the creation flags.
180#[cfg(unix)]
181fn set_new_process_group(cmd: &mut Command) {
182 use std::os::unix::process::CommandExt;
183 cmd.process_group(0);
184}
185
186#[cfg(windows)]
187fn set_new_process_group(cmd: &mut Command) {
188 use std::os::windows::process::CommandExt;
189 // CREATE_NEW_PROCESS_GROUP — Microsoft docs, processthreadsapi.h.
190 // Listed as 0x00000200 so callers don't need the `winapi` crate.
191 const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
192 cmd.creation_flags(CREATE_NEW_PROCESS_GROUP);
193}
194
195#[cfg(not(any(unix, windows)))]
196fn set_new_process_group(_: &mut Command) {
197 // Other targets: best-effort no-op; Child::kill is the only
198 // recourse and the port-still-bound caveat applies.
199}
200
201/// Best-effort kill of `cargo` *and every process it spawned*.
202/// Ignores errors because the child may already be dead (compile
203/// failure, panic). We just want it gone before we respawn.
204///
205/// Strategy:
206/// - On Unix, send SIGTERM to the negated PID (i.e. the process
207/// group). If anyone's still alive after a short grace period,
208/// send SIGKILL the same way.
209/// - On Windows, shell out to `taskkill /F /T /PID`, which kills the
210/// whole tree (`/T`) forcefully (`/F`). `cargo run`'s grandchild
211/// (the actual app binary) is the one we usually care about — it's
212/// what holds the listening socket.
213/// - On unknown targets, fall back to `Child::kill`.
214fn kill_silently(child: &mut Child) {
215 let pid = child.id();
216 // Platform-specific tree kill first — this is the one that
217 // actually reaches grandchildren (`cargo run`'s app binary).
218 kill_process_tree(pid);
219 // Defensive: ensure cargo itself is dead too. On platforms where
220 // the tree kill is a no-op, this is the only thing that actually
221 // terminates the child.
222 let _ = child.kill();
223 // Reap the zombie so the OS releases the PID.
224 let _ = child.wait();
225}
226
227#[cfg(unix)]
228fn kill_process_tree(pid: u32) {
229 // Negative target = group. We sent setpgid earlier so the cargo
230 // pid is also the group id.
231 let group_arg = format!("-{pid}");
232 // TERM first — gives the child a chance to flush. If anything's
233 // still alive after a short delay, hammer with KILL.
234 let _ = Command::new("kill")
235 .arg("-TERM")
236 .arg(&group_arg)
237 .stdin(Stdio::null())
238 .stdout(Stdio::null())
239 .stderr(Stdio::null())
240 .status();
241 std::thread::sleep(std::time::Duration::from_millis(200));
242 let _ = Command::new("kill")
243 .arg("-KILL")
244 .arg(&group_arg)
245 .stdin(Stdio::null())
246 .stdout(Stdio::null())
247 .stderr(Stdio::null())
248 .status();
249}
250
251#[cfg(windows)]
252fn kill_process_tree(pid: u32) {
253 // `taskkill /F /T /PID` — /F = force, /T = tree.
254 let _ = Command::new("taskkill")
255 .args(["/F", "/T", "/PID"])
256 .arg(pid.to_string())
257 .stdin(Stdio::null())
258 .stdout(Stdio::null())
259 .stderr(Stdio::null())
260 .status();
261}
262
263#[cfg(not(any(unix, windows)))]
264fn kill_process_tree(_pid: u32) {
265 // No portable tree-kill — the surrounding Child::kill+wait in
266 // kill_silently is the only thing that runs.
267}
268
269/// Filter the debounced events down to "something we care about".
270/// Notify will fire on `.git/`, `target/`, IDE swap files, etc. We
271/// reject those before pulling the trigger on a rebuild — saves a
272/// lot of spurious restarts.
273pub(crate) fn is_relevant(events: &[notify_debouncer_mini::DebouncedEvent]) -> bool {
274 events.iter().any(|e| is_relevant_path(&e.path))
275}
276
277/// Heuristic: a path is relevant if it's a Rust source / TOML /
278/// template-ish file *and* not inside an obvious noise directory
279/// (target, .git, node_modules, build-script output dirs).
280pub(crate) fn is_relevant_path(p: &Path) -> bool {
281 // Reject noise directories anywhere in the path.
282 for comp in p.components() {
283 match comp.as_os_str().to_str() {
284 Some("target") | Some(".git") | Some("node_modules") => return false,
285 // Editor temp files commonly start with `.` or `~`.
286 Some(s) if s.starts_with('~') => return false,
287 Some(s) if s.ends_with("~") => return false,
288 _ => {}
289 }
290 }
291 // Accept rust + cargo + html/css/js/etc. Anything inside src/ is
292 // a strong signal too — keep it permissive there.
293 let in_src = p
294 .components()
295 .any(|c| c.as_os_str().to_str() == Some("src"));
296 if in_src {
297 return true;
298 }
299 matches!(
300 p.extension().and_then(|s| s.to_str()),
301 Some("rs" | "toml" | "lock" | "html" | "css" | "js" | "json" | "yaml" | "yml")
302 )
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308 use std::path::PathBuf;
309
310 #[test]
311 fn is_relevant_path_accepts_rs_in_src() {
312 assert!(is_relevant_path(&PathBuf::from("src/main.rs")));
313 assert!(is_relevant_path(&PathBuf::from(
314 "src/modules/posts/handlers.rs"
315 )));
316 }
317
318 #[test]
319 fn is_relevant_path_accepts_toml() {
320 assert!(is_relevant_path(&PathBuf::from("Cargo.toml")));
321 }
322
323 #[test]
324 fn is_relevant_path_rejects_target() {
325 assert!(!is_relevant_path(&PathBuf::from(
326 "target/debug/build/foo.rs"
327 )));
328 assert!(!is_relevant_path(&PathBuf::from(
329 "/abs/proj/target/debug/app.exe"
330 )));
331 }
332
333 #[test]
334 fn is_relevant_path_rejects_git_and_node_modules() {
335 assert!(!is_relevant_path(&PathBuf::from(".git/HEAD")));
336 assert!(!is_relevant_path(&PathBuf::from(
337 "node_modules/foo/index.js"
338 )));
339 }
340
341 #[test]
342 fn is_relevant_path_rejects_editor_temp_files() {
343 assert!(!is_relevant_path(&PathBuf::from("src/main.rs~")));
344 assert!(!is_relevant_path(&PathBuf::from("~scratch.rs")));
345 }
346
347 #[test]
348 fn is_relevant_path_rejects_unrelated_extensions() {
349 assert!(!is_relevant_path(&PathBuf::from("notes.txt")));
350 assert!(!is_relevant_path(&PathBuf::from("logo.png")));
351 }
352}