Skip to main content

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}