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    /// Watch specific file(s) or folder(s).
207    ///
208    /// The default is the workspace root.
209    #[clap(long = "watch", short = 'w')]
210    pub watch_paths: Vec<PathBuf>,
211    /// Paths that will be excluded.
212    #[clap(long = "ignore", short = 'i')]
213    pub exclude_paths: Vec<PathBuf>,
214    /// Paths, relative to the workspace root, that will be excluded.
215    #[clap(skip)]
216    pub workspace_exclude_paths: Vec<PathBuf>,
217    /// Throttle events to prevent the command to be re-executed too early
218    /// right after an execution already occurred.
219    ///
220    /// The default is 2 seconds.
221    #[clap(skip = Duration::from_secs(2))]
222    pub debounce: Duration,
223}
224
225impl Watch {
226    /// Add a path to watch for changes.
227    pub fn watch_path(mut self, path: impl AsRef<Path>) -> Self {
228        self.watch_paths.push(path.as_ref().to_path_buf());
229        self
230    }
231
232    /// Add multiple paths to watch for changes.
233    pub fn watch_paths(mut self, paths: impl IntoIterator<Item = impl AsRef<Path>>) -> Self {
234        for path in paths {
235            self.watch_paths.push(path.as_ref().to_path_buf())
236        }
237        self
238    }
239
240    /// Add a path that will be ignored if changes are detected.
241    pub fn exclude_path(mut self, path: impl AsRef<Path>) -> Self {
242        self.exclude_paths.push(path.as_ref().to_path_buf());
243        self
244    }
245
246    /// Add multiple paths that will be ignored if changes are detected.
247    pub fn exclude_paths(mut self, paths: impl IntoIterator<Item = impl AsRef<Path>>) -> Self {
248        for path in paths {
249            self.exclude_paths.push(path.as_ref().to_path_buf());
250        }
251        self
252    }
253
254    /// Add a path, relative to the workspace, that will be ignored if changes
255    /// are detected.
256    pub fn exclude_workspace_path(mut self, path: impl AsRef<Path>) -> Self {
257        self.workspace_exclude_paths
258            .push(path.as_ref().to_path_buf());
259        self
260    }
261
262    /// Add multiple paths, relative to the workspace, that will be ignored if
263    /// changes are detected.
264    pub fn exclude_workspace_paths(
265        mut self,
266        paths: impl IntoIterator<Item = impl AsRef<Path>>,
267    ) -> Self {
268        for path in paths {
269            self.workspace_exclude_paths
270                .push(path.as_ref().to_path_buf());
271        }
272        self
273    }
274
275    /// Set the debounce duration after relaunching the command.
276    pub fn debounce(mut self, duration: Duration) -> Self {
277        self.debounce = duration;
278        self
279    }
280
281    /// Run the given `command`, monitor the watched paths and relaunch the
282    /// command when changes are detected.
283    ///
284    /// Workspace's `target` directory and hidden paths are excluded by default.
285    pub fn run(mut self, commands: impl Into<CommandList>) -> Result<()> {
286        let commands = commands.into();
287        let metadata = metadata();
288
289        self.exclude_paths
290            .push(metadata.target_directory.clone().into_std_path_buf());
291
292        self.exclude_paths = self
293            .exclude_paths
294            .into_iter()
295            .map(|x| {
296                x.canonicalize()
297                    .with_context(|| format!("can't find {}", x.display()))
298            })
299            .collect::<Result<Vec<_>, _>>()?;
300
301        if self.watch_paths.is_empty() {
302            self.watch_paths
303                .push(metadata.workspace_root.clone().into_std_path_buf());
304        }
305
306        self.watch_paths = self
307            .watch_paths
308            .into_iter()
309            .map(|x| {
310                x.canonicalize()
311                    .with_context(|| format!("can't find {}", x.display()))
312            })
313            .collect::<Result<Vec<_>, _>>()?;
314
315        let (tx, rx) = mpsc::channel();
316
317        let handler = WatchEventHandler {
318            watch: self.clone(),
319            tx,
320            command_start: Instant::now(),
321        };
322
323        let mut watcher =
324            notify::recommended_watcher(handler).context("could not initialize watcher")?;
325
326        for path in &self.watch_paths {
327            match watcher.watch(path, RecursiveMode::Recursive) {
328                Ok(()) => log::trace!("Watching {}", path.display()),
329                Err(err) => log::error!("cannot watch {}: {err}", path.display()),
330            }
331        }
332
333        let mut current_child = SharedChild::new();
334        loop {
335            {
336                log::info!("Re-running command");
337                let mut current_child = current_child.clone();
338                let mut commands = commands.clone();
339                thread::spawn(move || {
340                    let mut status = ExitStatus::default();
341                    commands.spawn(|res| match res {
342                        Err(err) => {
343                            log::error!("Could not execute command: {err}");
344                            false
345                        }
346                        Ok(child) => {
347                            log::trace!("new child: {}", child.id());
348                            current_child.replace(child);
349                            status = current_child.wait();
350                            status.success()
351                        }
352                    });
353                    if status.success() {
354                        log::info!("Command succeeded.");
355                    } else if let Some(code) = status.code() {
356                        log::error!("Command failed (exit code: {code})");
357                    } else {
358                        log::error!("Command failed.");
359                    }
360                });
361            }
362
363            let res = rx.recv();
364            if res.is_ok() {
365                log::trace!("changes detected");
366            }
367            current_child.terminate();
368            if res.is_err() {
369                break;
370            }
371        }
372
373        Ok(())
374    }
375
376    fn is_excluded_path(&self, path: &Path) -> bool {
377        if self.exclude_paths.iter().any(|x| path.starts_with(x)) {
378            return true;
379        }
380
381        if let Ok(stripped_path) = path.strip_prefix(metadata().workspace_root.as_std_path()) {
382            if self
383                .workspace_exclude_paths
384                .iter()
385                .any(|x| stripped_path.starts_with(x))
386            {
387                return true;
388            }
389        }
390
391        false
392    }
393
394    fn is_hidden_path(&self, path: &Path) -> bool {
395        self.watch_paths.iter().any(|x| {
396            path.strip_prefix(x)
397                .iter()
398                .any(|x| x.to_string_lossy().starts_with('.'))
399        })
400    }
401
402    fn is_backup_file(&self, path: &Path) -> bool {
403        self.watch_paths.iter().any(|x| {
404            path.strip_prefix(x)
405                .iter()
406                .any(|x| x.to_string_lossy().ends_with('~'))
407        })
408    }
409}
410
411struct WatchEventHandler {
412    watch: Watch,
413    tx: mpsc::Sender<()>,
414    command_start: Instant,
415}
416
417impl EventHandler for WatchEventHandler {
418    fn handle_event(&mut self, event: Result<Event, notify::Error>) {
419        match event {
420            Ok(event) => {
421                if event.paths.iter().any(|x| {
422                    !self.watch.is_excluded_path(x)
423                        && x.exists()
424                        && !self.watch.is_hidden_path(x)
425                        && !self.watch.is_backup_file(x)
426                        && event.kind != notify::EventKind::Create(notify::event::CreateKind::Any)
427                        && event.kind
428                            != notify::EventKind::Modify(notify::event::ModifyKind::Name(
429                                notify::event::RenameMode::Any,
430                            ))
431                        && self.command_start.elapsed() >= self.watch.debounce
432                }) {
433                    log::trace!("Changes detected in {event:?}");
434                    self.command_start = Instant::now();
435
436                    self.tx.send(()).expect("can send");
437                } else {
438                    log::trace!("Ignoring changes in {event:?}");
439                }
440            }
441            Err(err) => log::error!("watch error: {err}"),
442        }
443    }
444}
445
446#[derive(Debug, Clone)]
447struct SharedChild {
448    child: Arc<Mutex<Option<Child>>>,
449}
450
451impl SharedChild {
452    fn new() -> Self {
453        Self {
454            child: Default::default(),
455        }
456    }
457
458    fn replace(&mut self, child: impl Into<Option<Child>>) {
459        *self.child.lock().expect("not poisoned") = child.into();
460    }
461
462    fn wait(&mut self) -> ExitStatus {
463        loop {
464            let mut child = self.child.lock().expect("not poisoned");
465            match child.as_mut().map(|child| child.try_wait()) {
466                Some(Ok(Some(status))) => {
467                    break status;
468                }
469                Some(Ok(None)) => {
470                    drop(child);
471                    thread::sleep(Duration::from_millis(10));
472                }
473                Some(Err(err)) => {
474                    log::error!("could not wait for child process: {err}");
475                    break Default::default();
476                }
477                None => {
478                    break Default::default();
479                }
480            }
481        }
482    }
483
484    fn terminate(&mut self) {
485        if let Some(child) = self.child.lock().expect("not poisoned").as_mut() {
486            #[cfg(unix)]
487            {
488                let killing_start = Instant::now();
489
490                unsafe {
491                    log::trace!("sending SIGTERM to {}", child.id());
492                    libc::kill(child.id() as _, libc::SIGTERM);
493                }
494
495                while killing_start.elapsed().as_secs() < 2 {
496                    std::thread::sleep(Duration::from_millis(200));
497                    if let Ok(Some(_)) = child.try_wait() {
498                        break;
499                    }
500                }
501            }
502
503            match child.try_wait() {
504                Ok(Some(_)) => {}
505                _ => {
506                    log::trace!("killing {}", child.id());
507                    let _ = child.kill();
508                    let _ = child.wait();
509                }
510            }
511        } else {
512            log::trace!("nothing to terminate");
513        }
514    }
515}
516
517/// A list of commands to run.
518#[derive(Debug, Clone)]
519pub struct CommandList {
520    commands: Arc<Mutex<Vec<Command>>>,
521}
522
523impl From<Command> for CommandList {
524    fn from(command: Command) -> Self {
525        Self {
526            commands: Arc::new(Mutex::new(vec![command])),
527        }
528    }
529}
530
531impl From<Vec<Command>> for CommandList {
532    fn from(commands: Vec<Command>) -> Self {
533        Self {
534            commands: Arc::new(Mutex::new(commands)),
535        }
536    }
537}
538
539impl<const SIZE: usize> From<[Command; SIZE]> for CommandList {
540    fn from(commands: [Command; SIZE]) -> Self {
541        Self {
542            commands: Arc::new(Mutex::new(Vec::from(commands))),
543        }
544    }
545}
546
547impl CommandList {
548    /// Returns `true` if the list is empty.
549    pub fn is_empty(&self) -> bool {
550        self.commands.lock().expect("not poisoned").is_empty()
551    }
552
553    /// Spawn each command of the list one after the other.
554    ///
555    /// The caller is responsible to wait the commands.
556    pub fn spawn(&mut self, mut callback: impl FnMut(io::Result<Child>) -> bool) {
557        for process in self.commands.lock().expect("not poisoned").iter_mut() {
558            if !callback(process.spawn()) {
559                break;
560            }
561        }
562    }
563
564    /// Run all the commands sequentially using [`std::process::Command::status`] and stop at the
565    /// first failure.
566    pub fn status(&mut self) -> io::Result<ExitStatus> {
567        for process in self.commands.lock().expect("not poisoned").iter_mut() {
568            let exit_status = process.status()?;
569            if !exit_status.success() {
570                return Ok(exit_status);
571            }
572        }
573        Ok(Default::default())
574    }
575}
576
577#[cfg(test)]
578mod test {
579    use super::*;
580
581    #[test]
582    fn exclude_relative_path() {
583        let watch = Watch {
584            debounce: Default::default(),
585            watch_paths: Vec::new(),
586            exclude_paths: Vec::new(),
587            workspace_exclude_paths: vec![PathBuf::from("src/watch.rs")],
588        };
589
590        assert!(watch.is_excluded_path(
591            metadata()
592                .workspace_root
593                .join("src")
594                .join("watch.rs")
595                .as_std_path()
596        ));
597        assert!(!watch.is_excluded_path(metadata().workspace_root.join("src").as_std_path()));
598    }
599
600    #[test]
601    fn command_list_froms() {
602        let _: CommandList = Command::new("foo").into();
603        let _: CommandList = vec![Command::new("foo")].into();
604        let _: CommandList = [Command::new("foo")].into();
605    }
606}