xtask_watch/
lib.rs

1//! This crate provides a [`Watch`](crate::Watch) that launch a given command,
2//! re-launching the command when changes are detected in your source code.
3//!
4//! This [`Watch`](crate::Watch) struct is intended to be used with the
5//! [xtask concept](https://github.com/matklad/cargo-xtask/) and implements
6//! [`clap::Parser`](https://docs.rs/clap/latest/clap/trait.Parser.html) so it
7//! can easily be used in your xtask crate. See [clap's `flatten`](https://github.com/clap-rs/clap/blob/master/examples/derive_ref/flatten_hand_args.rs)
8//! to see how to extend it.
9//!
10//! # Setup
11//!
12//! The best way to add xtask-watch to your project is to create a workspace
13//! with two packages: your project's package and the xtask package.
14//!
15//! ## Create a project using xtask
16//!
17//! * Create a new directory that will contains the two package of your project
18//!   and the workspace's `Cargo.toml`
19//!
20//!   ```console
21//!   mkdir my-project
22//!   cd my-project
23//!   touch Cargo.toml
24//!   ```
25//!
26//! * Create the project package and the xtask package using `cargo new`:
27//!
28//!   ```console
29//!   cargo new my-project
30//!   cargo new xtask
31//!   ```
32//!
33//! * Open the workspace's Cargo.toml and add the following:
34//!
35//!   ```toml
36//!   [workspace]
37//!   members = [
38//!       "my-project",
39//!       "xtask",
40//!   ]
41//!   ```
42//!
43//!
44//! * Create a `.cargo/config.toml` file and add the following content:
45//!
46//!   ```toml
47//!   [alias]
48//!   xtask = "run --package xtask --"
49//!   ```
50//!
51//! The directory layout should look like this:
52//!
53//! ```console
54//! my-project
55//! ├── .cargo
56//! │   └── config.toml
57//! ├── Cargo.toml
58//! ├── my-project
59//! │   ├── Cargo.toml
60//! │   └── src
61//! │       └── ...
62//! └── xtask
63//!     ├── Cargo.toml
64//!     └── src
65//!         └── main.rs
66//! ```
67//!
68//! And now you can run your xtask package using:
69//!
70//! ```console
71//! cargo xtask
72//! ```
73//! You can find more informations about xtask
74//! [here](https://github.com/matklad/cargo-xtask/).
75//!
76//! ## Use xtask-watch as a dependency
77//!
78//! Finally, add the following to the xtask package's Cargo.toml:
79//!
80//! ```toml
81//! [dependencies]
82//! xtask-watch = "0.1.0"
83//! ```
84//!
85//! # Examples
86//!
87//! ## A basic implementation
88//!
89//! ```rust,no_run
90//! use std::process::Command;
91//! use xtask_watch::{
92//!     anyhow::Result,
93//!     clap,
94//! };
95//!
96//! #[derive(clap::Parser)]
97//! enum Opt {
98//!     Watch(xtask_watch::Watch),
99//! }
100//!
101//! fn main() -> Result<()> {
102//!     let opt: Opt = clap::Parser::parse();
103//!
104//!     let mut run_command = Command::new("cargo");
105//!     run_command.arg("check");
106//!
107//!     match opt {
108//!         Opt::Watch(watch) => {
109//!             log::info!("Starting to watch `cargo check`");
110//!             watch.run(run_command)?;
111//!         }
112//!     }
113//!
114//!     Ok(())
115//! }
116//! ```
117//!
118//! ## A more complex demonstration
119//!
120//! [`examples/demo`](https://github.com/rustminded/xtask-watch/tree/main/examples/demo)
121//! provides an implementation of xtask-watch that naively parse a command given
122//! by the user (or use `cargo check` by default) and watch the workspace after
123//! launching this command.
124//!
125//! # Troubleshooting
126//!
127//! When using the re-export of [`clap`](https://docs.rs/clap/latest/clap), you
128//! might encounter this error:
129//!
130//! ```console
131//! error[E0433]: failed to resolve: use of undeclared crate or module `clap`
132//!  --> xtask/src/main.rs:4:10
133//!   |
134//! 4 | #[derive(Parser)]
135//!   |          ^^^^^^ use of undeclared crate or module `clap`
136//!   |
137//!   = note: this error originates in the derive macro `Parser` (in Nightly builds, run with -Z macro-backtrace for more info)
138//! ```
139//!
140//! This occurs because you need to import clap in the scope too. This error can
141//! be resolved like this:
142//!
143//! ```rust
144//! use xtask_watch::clap;
145//!
146//! #[derive(clap::Parser)]
147//! struct MyStruct {}
148//! ```
149//!
150//! Or like this:
151//!
152//! ```rust
153//! use xtask_watch::{clap, clap::Parser};
154//!
155//! #[derive(Parser)]
156//! struct MyStruct {}
157//! ```
158
159#![deny(missing_docs)]
160
161use anyhow::{Context, Result};
162use clap::Parser;
163use lazy_static::lazy_static;
164use notify::{Event, EventHandler, RecursiveMode, Watcher};
165use std::{
166    env, io,
167    path::{Path, PathBuf},
168    process::{Child, Command, ExitStatus},
169    sync::{mpsc, Arc, Mutex},
170    thread,
171    time::{Duration, Instant},
172};
173
174pub use anyhow;
175pub use cargo_metadata;
176pub use cargo_metadata::camino;
177pub use clap;
178
179/// Fetch the metadata of the crate.
180pub fn metadata() -> &'static cargo_metadata::Metadata {
181    lazy_static! {
182        static ref METADATA: cargo_metadata::Metadata = cargo_metadata::MetadataCommand::new()
183            .exec()
184            .expect("cannot get crate's metadata");
185    }
186
187    &METADATA
188}
189
190/// Fetch information of a package in the current crate.
191pub fn package(name: &str) -> Option<&cargo_metadata::Package> {
192    metadata().packages.iter().find(|x| x.name == name)
193}
194
195/// Return a [`std::process::Command`] of the xtask command currently running.
196pub fn xtask_command() -> Command {
197    Command::new(env::args_os().next().unwrap())
198}
199
200/// Watches over your project's source code, relaunching a given command when
201/// changes are detected.
202#[non_exhaustive]
203#[derive(Clone, Debug, Default, Parser)]
204#[clap(about = "Watches over your project's source code.")]
205pub struct Watch {
206    /// Shell command(s) to execute on changes.
207    #[clap(long = "shell", short = 's')]
208    pub shell_commands: Vec<String>,
209    /// Cargo command(s) to execute on changes.
210    ///
211    /// The default is `[ check ]`
212    #[clap(long = "exec", short = 'x')]
213    pub cargo_commands: Vec<String>,
214    /// Watch specific file(s) or folder(s).
215    ///
216    /// The default is the workspace root.
217    #[clap(long = "watch", short = 'w')]
218    pub watch_paths: Vec<PathBuf>,
219    /// Paths that will be excluded.
220    #[clap(long = "ignore", short = 'i')]
221    pub exclude_paths: Vec<PathBuf>,
222    /// Paths, relative to the workspace root, that will be excluded.
223    #[clap(skip)]
224    pub workspace_exclude_paths: Vec<PathBuf>,
225    /// Throttle events to prevent the command to be re-executed too early
226    /// right after an execution already occurred.
227    ///
228    /// The default is 2 seconds.
229    #[clap(skip = Duration::from_secs(2))]
230    pub debounce: Duration,
231}
232
233impl Watch {
234    /// Add a path to watch for changes.
235    pub fn watch_path(mut self, path: impl AsRef<Path>) -> Self {
236        self.watch_paths.push(path.as_ref().to_path_buf());
237        self
238    }
239
240    /// Add multiple paths to watch for changes.
241    pub fn watch_paths(mut self, paths: impl IntoIterator<Item = impl AsRef<Path>>) -> Self {
242        for path in paths {
243            self.watch_paths.push(path.as_ref().to_path_buf())
244        }
245        self
246    }
247
248    /// Add a path that will be ignored if changes are detected.
249    pub fn exclude_path(mut self, path: impl AsRef<Path>) -> Self {
250        self.exclude_paths.push(path.as_ref().to_path_buf());
251        self
252    }
253
254    /// Add multiple paths that will be ignored if changes are detected.
255    pub fn exclude_paths(mut self, paths: impl IntoIterator<Item = impl AsRef<Path>>) -> Self {
256        for path in paths {
257            self.exclude_paths.push(path.as_ref().to_path_buf());
258        }
259        self
260    }
261
262    /// Add a path, relative to the workspace, that will be ignored if changes
263    /// are detected.
264    pub fn exclude_workspace_path(mut self, path: impl AsRef<Path>) -> Self {
265        self.workspace_exclude_paths
266            .push(path.as_ref().to_path_buf());
267        self
268    }
269
270    /// Add multiple paths, relative to the workspace, that will be ignored if
271    /// changes are detected.
272    pub fn exclude_workspace_paths(
273        mut self,
274        paths: impl IntoIterator<Item = impl AsRef<Path>>,
275    ) -> Self {
276        for path in paths {
277            self.workspace_exclude_paths
278                .push(path.as_ref().to_path_buf());
279        }
280        self
281    }
282
283    /// Set the debounce duration after relaunching the command.
284    pub fn debounce(mut self, duration: Duration) -> Self {
285        self.debounce = duration;
286        self
287    }
288
289    /// Run the given `command`, monitor the watched paths and relaunch the
290    /// command when changes are detected.
291    ///
292    /// Workspace's `target` directory and hidden paths are excluded by default.
293    pub fn run(mut self, commands: impl Into<CommandList>) -> Result<()> {
294        let metadata = metadata();
295        let list = commands.into();
296
297        {
298            let mut commands = list.commands.lock().expect("not poisoned");
299
300            commands.extend(self.shell_commands.iter().map(|x| {
301                let mut command =
302                    Command::new(env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()));
303                command.arg("-c");
304                command.arg(x);
305
306                command
307            }));
308
309            commands.extend(self.cargo_commands.iter().map(|x| {
310                let mut command =
311                    Command::new(env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()));
312                command.arg("-c");
313                command.arg(format!("cargo {x}"));
314
315                command
316            }));
317        }
318
319        self.exclude_paths
320            .push(metadata.target_directory.clone().into_std_path_buf());
321
322        self.exclude_paths = self
323            .exclude_paths
324            .into_iter()
325            .map(|x| {
326                x.canonicalize()
327                    .with_context(|| format!("can't find {}", x.display()))
328            })
329            .collect::<Result<Vec<_>, _>>()?;
330
331        if self.watch_paths.is_empty() {
332            self.watch_paths
333                .push(metadata.workspace_root.clone().into_std_path_buf());
334        }
335
336        self.watch_paths = self
337            .watch_paths
338            .into_iter()
339            .map(|x| {
340                x.canonicalize()
341                    .with_context(|| format!("can't find {}", x.display()))
342            })
343            .collect::<Result<Vec<_>, _>>()?;
344
345        let (tx, rx) = mpsc::channel();
346
347        let handler = WatchEventHandler {
348            watch: self.clone(),
349            tx,
350            command_start: Instant::now(),
351        };
352
353        let mut watcher =
354            notify::recommended_watcher(handler).context("could not initialize watcher")?;
355
356        for path in &self.watch_paths {
357            match watcher.watch(path, RecursiveMode::Recursive) {
358                Ok(()) => log::trace!("Watching {}", path.display()),
359                Err(err) => log::error!("cannot watch {}: {err}", path.display()),
360            }
361        }
362
363        let mut current_child = SharedChild::new();
364        loop {
365            {
366                log::info!("Re-running command");
367                let mut current_child = current_child.clone();
368                let mut list = list.clone();
369                thread::spawn(move || {
370                    let mut status = ExitStatus::default();
371                    list.spawn(|res| match res {
372                        Err(err) => {
373                            log::error!("Could not execute command: {err}");
374                            false
375                        }
376                        Ok(child) => {
377                            log::trace!("new child: {}", child.id());
378                            current_child.replace(child);
379                            status = current_child.wait();
380                            status.success()
381                        }
382                    });
383                    if status.success() {
384                        log::info!("Command succeeded.");
385                    } else if let Some(code) = status.code() {
386                        log::error!("Command failed (exit code: {code})");
387                    } else {
388                        log::error!("Command failed.");
389                    }
390                });
391            }
392
393            let res = rx.recv();
394            if res.is_ok() {
395                log::trace!("Changes detected, re-generating");
396            }
397            current_child.terminate();
398            if res.is_err() {
399                break;
400            }
401        }
402
403        Ok(())
404    }
405
406    fn is_excluded_path(&self, path: &Path) -> bool {
407        if self.exclude_paths.iter().any(|x| path.starts_with(x)) {
408            return true;
409        }
410
411        if let Ok(stripped_path) = path.strip_prefix(metadata().workspace_root.as_std_path()) {
412            if self
413                .workspace_exclude_paths
414                .iter()
415                .any(|x| stripped_path.starts_with(x))
416            {
417                return true;
418            }
419        }
420
421        false
422    }
423
424    fn is_hidden_path(&self, path: &Path) -> bool {
425        self.watch_paths.iter().any(|x| {
426            path.strip_prefix(x)
427                .iter()
428                .any(|x| x.to_string_lossy().starts_with('.'))
429        })
430    }
431
432    fn is_backup_file(&self, path: &Path) -> bool {
433        self.watch_paths.iter().any(|x| {
434            path.strip_prefix(x)
435                .iter()
436                .any(|x| x.to_string_lossy().ends_with('~'))
437        })
438    }
439}
440
441struct WatchEventHandler {
442    watch: Watch,
443    tx: mpsc::Sender<()>,
444    command_start: Instant,
445}
446
447impl EventHandler for WatchEventHandler {
448    fn handle_event(&mut self, event: Result<Event, notify::Error>) {
449        match event {
450            Ok(event) => {
451                if (event.kind.is_modify() || event.kind.is_create() || event.kind.is_create())
452                    && event.paths.iter().any(|x| {
453                        !self.watch.is_excluded_path(x)
454                            && x.exists()
455                            && !self.watch.is_hidden_path(x)
456                            && !self.watch.is_backup_file(x)
457                            && self.command_start.elapsed() >= self.watch.debounce
458                    })
459                {
460                    log::trace!("Changes detected in {event:?}");
461                    self.command_start = Instant::now();
462
463                    self.tx.send(()).expect("can send");
464                } else {
465                    log::trace!("Ignoring changes in {event:?}");
466                }
467            }
468            Err(err) => log::error!("watch error: {err}"),
469        }
470    }
471}
472
473#[derive(Debug, Clone)]
474struct SharedChild {
475    child: Arc<Mutex<Option<Child>>>,
476}
477
478impl SharedChild {
479    fn new() -> Self {
480        Self {
481            child: Default::default(),
482        }
483    }
484
485    fn replace(&mut self, child: impl Into<Option<Child>>) {
486        *self.child.lock().expect("not poisoned") = child.into();
487    }
488
489    fn wait(&mut self) -> ExitStatus {
490        loop {
491            let mut child = self.child.lock().expect("not poisoned");
492            match child.as_mut().map(|child| child.try_wait()) {
493                Some(Ok(Some(status))) => {
494                    break status;
495                }
496                Some(Ok(None)) => {
497                    drop(child);
498                    thread::sleep(Duration::from_millis(10));
499                }
500                Some(Err(err)) => {
501                    log::error!("could not wait for child process: {err}");
502                    break Default::default();
503                }
504                None => {
505                    break Default::default();
506                }
507            }
508        }
509    }
510
511    fn terminate(&mut self) {
512        if let Some(child) = self.child.lock().expect("not poisoned").as_mut() {
513            #[cfg(unix)]
514            {
515                let killing_start = Instant::now();
516
517                unsafe {
518                    log::trace!("sending SIGTERM to {}", child.id());
519                    libc::kill(child.id() as _, libc::SIGTERM);
520                }
521
522                while killing_start.elapsed().as_secs() < 2 {
523                    std::thread::sleep(Duration::from_millis(200));
524                    if let Ok(Some(_)) = child.try_wait() {
525                        break;
526                    }
527                }
528            }
529
530            match child.try_wait() {
531                Ok(Some(_)) => {}
532                _ => {
533                    log::trace!("killing {}", child.id());
534                    let _ = child.kill();
535                    let _ = child.wait();
536                }
537            }
538        } else {
539            log::trace!("nothing to terminate");
540        }
541    }
542}
543
544/// A list of commands to run.
545#[derive(Debug, Clone)]
546pub struct CommandList {
547    commands: Arc<Mutex<Vec<Command>>>,
548}
549
550impl From<Command> for CommandList {
551    fn from(command: Command) -> Self {
552        Self {
553            commands: Arc::new(Mutex::new(vec![command])),
554        }
555    }
556}
557
558impl From<Vec<Command>> for CommandList {
559    fn from(commands: Vec<Command>) -> Self {
560        Self {
561            commands: Arc::new(Mutex::new(commands)),
562        }
563    }
564}
565
566impl<const SIZE: usize> From<[Command; SIZE]> for CommandList {
567    fn from(commands: [Command; SIZE]) -> Self {
568        Self {
569            commands: Arc::new(Mutex::new(Vec::from(commands))),
570        }
571    }
572}
573
574impl CommandList {
575    /// Returns `true` if the list is empty.
576    pub fn is_empty(&self) -> bool {
577        self.commands.lock().expect("not poisoned").is_empty()
578    }
579
580    /// Spawn each command of the list one after the other.
581    ///
582    /// The caller is responsible to wait the commands.
583    pub fn spawn(&mut self, mut callback: impl FnMut(io::Result<Child>) -> bool) {
584        for process in self.commands.lock().expect("not poisoned").iter_mut() {
585            if !callback(process.spawn()) {
586                break;
587            }
588        }
589    }
590
591    /// Run all the commands sequentially using [`std::process::Command::status`] and stop at the
592    /// first failure.
593    pub fn status(&mut self) -> io::Result<ExitStatus> {
594        for process in self.commands.lock().expect("not poisoned").iter_mut() {
595            let exit_status = process.status()?;
596            if !exit_status.success() {
597                return Ok(exit_status);
598            }
599        }
600        Ok(Default::default())
601    }
602}
603
604#[cfg(test)]
605mod test {
606    use super::*;
607
608    #[test]
609    fn exclude_relative_path() {
610        let watch = Watch {
611            shell_commands: Vec::new(),
612            cargo_commands: Vec::new(),
613            debounce: Default::default(),
614            watch_paths: Vec::new(),
615            exclude_paths: Vec::new(),
616            workspace_exclude_paths: vec![PathBuf::from("src/watch.rs")],
617        };
618
619        assert!(watch.is_excluded_path(
620            metadata()
621                .workspace_root
622                .join("src")
623                .join("watch.rs")
624                .as_std_path()
625        ));
626        assert!(!watch.is_excluded_path(metadata().workspace_root.join("src").as_std_path()));
627    }
628
629    #[test]
630    fn command_list_froms() {
631        let _: CommandList = Command::new("foo").into();
632        let _: CommandList = vec![Command::new("foo")].into();
633        let _: CommandList = [Command::new("foo")].into();
634    }
635}