Skip to main content

xtask_watch/
lib.rs

1#![doc = include_str!("../README.md")]
2#![deny(missing_docs)]
3
4use anyhow::{Context, Result};
5use clap::Parser;
6use glob::Pattern;
7use lazy_static::lazy_static;
8use notify::Watcher as _;
9use std::{
10    env, io,
11    path::{Path, PathBuf},
12    process::{Child, Command, ExitStatus},
13    sync::{Arc, Mutex, RwLock, RwLockReadGuard, RwLockWriteGuard, mpsc},
14    thread,
15    time::{Duration, Instant},
16};
17
18pub use anyhow;
19pub use cargo_metadata;
20pub use cargo_metadata::camino;
21pub use clap;
22
23/// Fetch the metadata of the crate.
24pub fn metadata() -> &'static cargo_metadata::Metadata {
25    lazy_static! {
26        static ref METADATA: cargo_metadata::Metadata = cargo_metadata::MetadataCommand::new()
27            .exec()
28            .expect("cannot get crate's metadata");
29    }
30
31    &METADATA
32}
33
34/// Fetch information of a package in the current crate.
35pub fn package(name: &str) -> Option<&cargo_metadata::Package> {
36    metadata().packages.iter().find(|x| x.name == name)
37}
38
39/// Return a [`std::process::Command`] of the xtask command currently running.
40pub fn xtask_command() -> Command {
41    Command::new(env::args_os().next().unwrap())
42}
43
44/// Watches over your project's source code, relaunching a given command when
45/// changes are detected.
46///
47/// Use [`Watch::lock`] to obtain a [`WatchLock`] that can be shared with external
48/// code (e.g. an HTTP server) to coordinate reads with ongoing rebuilds.
49#[non_exhaustive]
50#[derive(Clone, Debug, Default, Parser)]
51#[clap(about = "Watches over your project's source code.")]
52pub struct Watch {
53    /// Shell command(s) to execute on changes.
54    #[clap(long = "shell", short = 's')]
55    pub shell_commands: Vec<String>,
56    /// Cargo command(s) to execute on changes.
57    ///
58    /// The default is `[ check ]`
59    #[clap(long = "exec", short = 'x')]
60    pub cargo_commands: Vec<String>,
61    /// Watch specific file(s) or folder(s).
62    ///
63    /// The default is the workspace root.
64    #[clap(long = "watch", short = 'w')]
65    pub watch_paths: Vec<PathBuf>,
66    /// Paths or glob patterns that will be excluded.
67    ///
68    /// Relative values are resolved from the current working directory.
69    #[clap(long = "ignore", short = 'i')]
70    pub exclude_paths: Vec<PathBuf>,
71    /// Paths or glob patterns, relative to the workspace root, that will be excluded.
72    #[clap(skip)]
73    pub workspace_exclude_paths: Vec<PathBuf>,
74    /// Throttle events to prevent the command to be re-executed too early
75    /// right after an execution already occurred.
76    ///
77    /// The default is 2 seconds.
78    #[clap(skip = Duration::from_secs(2))]
79    pub debounce: Duration,
80    #[clap(skip)]
81    exclude_globs: Vec<Pattern>,
82    #[clap(skip)]
83    workspace_exclude_globs: Vec<Pattern>,
84    #[clap(skip)]
85    watch_lock: WatchLock,
86}
87
88impl Watch {
89    /// Add a path to watch for changes.
90    pub fn watch_path(mut self, path: impl AsRef<Path>) -> Self {
91        self.watch_paths.push(path.as_ref().to_path_buf());
92        self
93    }
94
95    /// Add multiple paths to watch for changes.
96    pub fn watch_paths(mut self, paths: impl IntoIterator<Item = impl AsRef<Path>>) -> Self {
97        for path in paths {
98            self.watch_paths.push(path.as_ref().to_path_buf())
99        }
100        self
101    }
102
103    /// Add a path that will be ignored if changes are detected.
104    pub fn exclude_path(mut self, path: impl AsRef<Path>) -> Self {
105        self.exclude_paths.push(path.as_ref().to_path_buf());
106        self
107    }
108
109    /// Add multiple paths that will be ignored if changes are detected.
110    pub fn exclude_paths(mut self, paths: impl IntoIterator<Item = impl AsRef<Path>>) -> Self {
111        for path in paths {
112            self.exclude_paths.push(path.as_ref().to_path_buf());
113        }
114        self
115    }
116
117    /// Add a path, relative to the workspace, that will be ignored if changes
118    /// are detected.
119    pub fn exclude_workspace_path(mut self, path: impl AsRef<Path>) -> Self {
120        self.workspace_exclude_paths
121            .push(path.as_ref().to_path_buf());
122        self
123    }
124
125    /// Add multiple paths, relative to the workspace, that will be ignored if
126    /// changes are detected.
127    pub fn exclude_workspace_paths(
128        mut self,
129        paths: impl IntoIterator<Item = impl AsRef<Path>>,
130    ) -> Self {
131        for path in paths {
132            self.workspace_exclude_paths
133                .push(path.as_ref().to_path_buf());
134        }
135        self
136    }
137
138    /// Return the shared lock used by this watcher.
139    ///
140    /// Clone and share this lock with external code (e.g. HTTP handlers) to coordinate with
141    /// watch-driven command execution.
142    ///
143    /// # Lock lifecycle
144    ///
145    /// [`run`](Self::run) acquires the **write lock immediately** when it is called — before the
146    /// first command is even spawned. Any code that calls [`WatchLock::acquire`] will therefore
147    /// block until the first build completes. This is intentional: it prevents readers from
148    /// observing an empty or incomplete dist directory before the initial build has finished.
149    /// The write lock is then re-acquired on every subsequent rebuild and released once the
150    /// command sequence succeeds.
151    #[must_use = "store and share the lock with readers that must coordinate with rebuilds"]
152    pub fn lock(&self) -> WatchLock {
153        self.watch_lock.clone()
154    }
155
156    /// Set the debounce duration after relaunching the command.
157    pub fn debounce(mut self, duration: Duration) -> Self {
158        self.debounce = duration;
159        self
160    }
161
162    /// Run the given `command`, monitor the watched paths and relaunch the
163    /// command when changes are detected.
164    ///
165    /// Workspace's `target` directory and hidden paths are excluded by default.
166    pub fn run(mut self, commands: impl Into<CommandList>) -> Result<()> {
167        let metadata = metadata();
168        let list = commands.into();
169
170        {
171            let mut commands = list
172                .commands
173                .lock()
174                .expect("no panic-prone code runs while this lock is held");
175
176            commands.extend(self.shell_commands.iter().map(|x| {
177                let mut command =
178                    Command::new(env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()));
179                command.arg("-c");
180                command.arg(x);
181
182                command
183            }));
184
185            commands.extend(self.cargo_commands.iter().map(|x| {
186                let mut command =
187                    Command::new(env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()));
188                command.arg("-c");
189                command.arg(format!("cargo {x}"));
190
191                command
192            }));
193        }
194
195        self.prepare_excludes()?;
196
197        if self.watch_paths.is_empty() {
198            self.watch_paths
199                .push(metadata.workspace_root.clone().into_std_path_buf());
200        }
201
202        self.watch_paths = self
203            .watch_paths
204            .into_iter()
205            .map(|x| {
206                x.canonicalize()
207                    .with_context(|| format!("can't find {}", x.display()))
208            })
209            .collect::<Result<Vec<_>, _>>()?;
210
211        let (tx, rx) = mpsc::channel();
212
213        let handler = WatchEventHandler {
214            watch: self.clone(),
215            tx: tx.clone(),
216            command_start: Instant::now(),
217        };
218
219        let mut watcher =
220            notify::recommended_watcher(handler).context("could not initialize watcher")?;
221
222        for path in &self.watch_paths {
223            match watcher.watch(path, notify::RecursiveMode::Recursive) {
224                Ok(()) => log::trace!("Watching {}", path.display()),
225                Err(err) => log::error!("cannot watch {}: {err}", path.display()),
226            }
227        }
228
229        let mut current_child = SharedChild::new();
230        let mut lock_guard = Some(self.watch_lock.write());
231        let mut generation: u64 = 0;
232        loop {
233            if lock_guard.is_some() {
234                log::info!("Running command");
235                let mut current_child = current_child.clone();
236                let mut list = list.clone();
237                let tx = tx.clone();
238                let build_id = generation;
239                thread::spawn(move || {
240                    let mut status = ExitStatus::default();
241
242                    list.spawn(|res| match res {
243                        Err(err) => {
244                            log::error!("Could not execute command: {err}");
245                            false
246                        }
247                        Ok(child) => {
248                            log::trace!("Child spawned PID: {}", child.id());
249                            current_child.replace(child);
250                            status = current_child.wait();
251                            status.success()
252                        }
253                    });
254
255                    if status.success() {
256                        log::info!("Command succeeded.");
257                        tx.send(Event::CommandSucceeded(build_id))
258                            .expect("can send");
259                    } else if let Some(code) = status.code() {
260                        log::error!("Command failed (exit code: {code})");
261                    } else {
262                        log::error!("Command failed.");
263                    }
264                });
265            }
266
267            match rx.recv() {
268                Ok(Event::ChangeDetected) => {
269                    log::trace!("Changes detected, re-generating");
270                    if lock_guard.is_none() {
271                        lock_guard = Some(self.watch_lock.write());
272                    }
273                    generation += 1;
274                    current_child.terminate();
275                }
276                Ok(Event::CommandSucceeded(build_id)) if build_id == generation => {
277                    lock_guard.take();
278                }
279                Ok(Event::CommandSucceeded(build_id)) => {
280                    log::trace!(
281                        "Ignoring stale success from build {build_id} (current: {generation})"
282                    );
283                }
284                Err(_) => {
285                    current_child.terminate();
286                    break;
287                }
288            }
289        }
290
291        Ok(())
292    }
293
294    fn is_excluded_path(&self, path: &Path) -> bool {
295        if self.exclude_paths.iter().any(|x| path.starts_with(x)) {
296            return true;
297        }
298
299        if self.exclude_globs.iter().any(|p| p.matches_path(path)) {
300            return true;
301        }
302
303        if let Ok(stripped_path) = path.strip_prefix(metadata().workspace_root.as_std_path()) {
304            if self
305                .workspace_exclude_paths
306                .iter()
307                .any(|x| stripped_path.starts_with(x))
308            {
309                return true;
310            }
311
312            if self
313                .workspace_exclude_globs
314                .iter()
315                .any(|p| p.matches_path(stripped_path))
316            {
317                return true;
318            }
319        }
320
321        false
322    }
323
324    fn is_hidden_path(&self, path: &Path) -> bool {
325        self.watch_paths.iter().any(|x| {
326            path.strip_prefix(x)
327                .iter()
328                .any(|x| x.to_string_lossy().starts_with('.'))
329        })
330    }
331
332    fn is_backup_file(&self, path: &Path) -> bool {
333        self.watch_paths.iter().any(|x| {
334            path.strip_prefix(x)
335                .iter()
336                .any(|x| x.to_string_lossy().ends_with('~'))
337        })
338    }
339
340    fn is_glob_pattern(path: &Path) -> bool {
341        let s = path.as_os_str().to_string_lossy();
342        s.contains('*') || s.contains('?') || (!cfg!(windows) && s.contains('['))
343    }
344
345    fn compile_glob(path: &Path) -> Result<Pattern> {
346        let pattern = path
347            .to_str()
348            .with_context(|| format!("glob pattern must be valid UTF-8: {}", path.display()))?;
349
350        Pattern::new(pattern).with_context(|| format!("invalid glob pattern: `{}`", path.display()))
351    }
352
353    fn prepare_excludes(&mut self) -> Result<()> {
354        let metadata = metadata();
355        self.exclude_paths
356            .push(metadata.target_directory.clone().into_std_path_buf());
357
358        let current_dir = env::current_dir().context("failed to get current directory")?;
359        let mut exclude_paths = Vec::new();
360        for path in self.exclude_paths.iter() {
361            if Self::is_glob_pattern(path) {
362                let absolute = if path.is_absolute() {
363                    path.to_path_buf()
364                } else {
365                    current_dir.join(path)
366                };
367                self.exclude_globs.push(Self::compile_glob(&absolute)?);
368            } else {
369                let canonical = path
370                    .canonicalize()
371                    .with_context(|| format!("can't find `{}`", path.display()))?;
372                exclude_paths.push(canonical);
373            }
374        }
375        self.exclude_paths = exclude_paths;
376
377        let workspace_root = metadata.workspace_root.as_std_path();
378        let mut workspace_exclude_paths = Vec::new();
379        for path in self.workspace_exclude_paths.iter() {
380            let path = if path.is_absolute() {
381                path.strip_prefix(workspace_root)
382                    .with_context(|| {
383                        format!(
384                            "workspace exclude path must be inside workspace root: `{}`",
385                            path.display()
386                        )
387                    })?
388                    .to_path_buf()
389            } else {
390                path.to_path_buf()
391            };
392
393            if Self::is_glob_pattern(&path) {
394                self.workspace_exclude_globs
395                    .push(Self::compile_glob(&path)?);
396            } else {
397                workspace_exclude_paths.push(path);
398            }
399        }
400        self.workspace_exclude_paths = workspace_exclude_paths;
401
402        Ok(())
403    }
404}
405
406struct WatchEventHandler {
407    watch: Watch,
408    tx: mpsc::Sender<Event>,
409    command_start: Instant,
410}
411
412impl notify::EventHandler for WatchEventHandler {
413    fn handle_event(&mut self, event: Result<notify::Event, notify::Error>) {
414        match event {
415            Ok(event) => {
416                if (event.kind.is_modify() || event.kind.is_create())
417                    && event.paths.iter().any(|x| {
418                        !self.watch.is_excluded_path(x)
419                            && x.exists()
420                            && !self.watch.is_hidden_path(x)
421                            && !self.watch.is_backup_file(x)
422                            && self.command_start.elapsed() >= self.watch.debounce
423                    })
424                {
425                    log::trace!("Changes detected in {event:?}");
426                    self.command_start = Instant::now();
427
428                    self.tx.send(Event::ChangeDetected).expect("can send");
429                } else {
430                    log::trace!("Ignoring changes in {event:?}");
431                }
432            }
433            Err(err) => log::error!("watch error: {err}"),
434        }
435    }
436}
437
438#[derive(Debug, Clone)]
439struct SharedChild {
440    child: Arc<Mutex<Option<Child>>>,
441}
442
443impl SharedChild {
444    fn new() -> Self {
445        Self {
446            child: Default::default(),
447        }
448    }
449
450    fn replace(&mut self, child: impl Into<Option<Child>>) {
451        *self
452            .child
453            .lock()
454            .expect("no panic-prone code runs while this lock is held") = child.into();
455    }
456
457    fn wait(&mut self) -> ExitStatus {
458        loop {
459            let mut child = self
460                .child
461                .lock()
462                .expect("no panic-prone code runs while this lock is held");
463            match child.as_mut().map(|child| child.try_wait()) {
464                Some(Ok(Some(status))) => {
465                    break status;
466                }
467                Some(Ok(None)) => {
468                    drop(child);
469                    thread::sleep(Duration::from_millis(10));
470                }
471                Some(Err(err)) => {
472                    log::error!("could not wait for child process: {err}");
473                    break Default::default();
474                }
475                None => {
476                    break Default::default();
477                }
478            }
479        }
480    }
481
482    fn terminate(&mut self) {
483        if let Some(child) = self
484            .child
485            .lock()
486            .expect("no panic-prone code runs while this lock is held")
487            .as_mut()
488        {
489            #[cfg(unix)]
490            {
491                let killing_start = Instant::now();
492
493                unsafe {
494                    log::trace!("sending SIGTERM to {}", child.id());
495                    libc::kill(child.id() as _, libc::SIGTERM);
496                }
497
498                while killing_start.elapsed().as_secs() < 2 {
499                    std::thread::sleep(Duration::from_millis(200));
500                    if let Ok(Some(_)) = child.try_wait() {
501                        break;
502                    }
503                }
504            }
505
506            match child.try_wait() {
507                Ok(Some(_)) => {}
508                _ => {
509                    log::trace!("killing {}", child.id());
510                    let _ = child.kill();
511                    let _ = child.wait();
512                }
513            }
514        } else {
515            log::trace!("nothing to terminate");
516        }
517    }
518}
519
520/// A list of commands to run.
521#[derive(Debug, Clone)]
522pub struct CommandList {
523    commands: Arc<Mutex<Vec<Command>>>,
524}
525
526impl From<Command> for CommandList {
527    fn from(command: Command) -> Self {
528        Self {
529            commands: Arc::new(Mutex::new(vec![command])),
530        }
531    }
532}
533
534impl From<Vec<Command>> for CommandList {
535    fn from(commands: Vec<Command>) -> Self {
536        Self {
537            commands: Arc::new(Mutex::new(commands)),
538        }
539    }
540}
541
542impl<const SIZE: usize> From<[Command; SIZE]> for CommandList {
543    fn from(commands: [Command; SIZE]) -> Self {
544        Self {
545            commands: Arc::new(Mutex::new(Vec::from(commands))),
546        }
547    }
548}
549
550impl CommandList {
551    /// Returns `true` if the list is empty.
552    pub fn is_empty(&self) -> bool {
553        self.commands
554            .lock()
555            .expect("no panic-prone code runs while this lock is held")
556            .is_empty()
557    }
558
559    /// Spawn each command of the list one after the other.
560    ///
561    /// The caller is responsible to wait the commands.
562    pub fn spawn(&mut self, mut callback: impl FnMut(io::Result<Child>) -> bool) {
563        for process in self
564            .commands
565            .lock()
566            .expect("no panic-prone code runs while this lock is held")
567            .iter_mut()
568        {
569            if !callback(process.spawn()) {
570                break;
571            }
572        }
573    }
574
575    /// Run all the commands sequentially using [`std::process::Command::status`] and stop at the
576    /// first failure.
577    pub fn status(&mut self) -> io::Result<ExitStatus> {
578        for process in self
579            .commands
580            .lock()
581            .expect("no panic-prone code runs while this lock is held")
582            .iter_mut()
583        {
584            let exit_status = process.status()?;
585            if !exit_status.success() {
586                return Ok(exit_status);
587            }
588        }
589        Ok(Default::default())
590    }
591}
592
593/// Guard returned by [`WatchLock::acquire`].
594///
595/// Keep this value alive for the duration of the protected read section.
596/// The lock is released automatically when the guard is dropped.
597pub struct WatchLockGuard<'a> {
598    _guard: RwLockReadGuard<'a, ()>,
599}
600
601/// A lock handle used to coordinate file reads with watch-driven rebuilds.
602///
603/// Obtain it from [`Watch::lock`], clone it, and call [`WatchLock::acquire`] while
604/// reading files that must not race with rebuild writes.
605#[derive(Clone, Debug, Default)]
606pub struct WatchLock(Arc<RwLock<()>>);
607
608impl WatchLock {
609    /// Acquire shared access to the protected section.
610    ///
611    /// Multiple readers may hold this guard concurrently.
612    pub fn acquire(&self) -> WatchLockGuard<'_> {
613        WatchLockGuard {
614            // The inner value is `()` — there is no data to corrupt, so we can
615            // always recover from a poisoned lock.
616            _guard: self.0.read().unwrap_or_else(|e| e.into_inner()),
617        }
618    }
619
620    fn write(&self) -> RwLockWriteGuard<'_, ()> {
621        // The inner value is `()` — there is no data to corrupt, so we can
622        // always recover from a poisoned lock.
623        self.0.write().unwrap_or_else(|e| e.into_inner())
624    }
625}
626
627#[derive(Debug)]
628enum Event {
629    CommandSucceeded(u64),
630    ChangeDetected,
631}
632
633#[cfg(test)]
634mod test {
635    use super::*;
636
637    #[test]
638    fn exclude_relative_path() {
639        let watch = Watch::default().exclude_workspace_path("src/watch.rs");
640
641        assert!(
642            watch.is_excluded_path(
643                metadata()
644                    .workspace_root
645                    .join("src")
646                    .join("watch.rs")
647                    .as_std_path()
648            )
649        );
650        assert!(!watch.is_excluded_path(metadata().workspace_root.join("src").as_std_path()));
651    }
652
653    #[test]
654    fn exclude_absolute_glob_path() {
655        let absolute = metadata()
656            .workspace_root
657            .join("src")
658            .join("**")
659            .join("*.rs");
660
661        let mut watch = Watch::default().exclude_path(absolute);
662        watch
663            .prepare_excludes()
664            .expect("exclude parsing should succeed");
665        assert_eq!(watch.exclude_globs.len(), 1);
666
667        assert!(
668            watch.is_excluded_path(
669                metadata()
670                    .workspace_root
671                    .join("src")
672                    .join("lib.rs")
673                    .as_std_path()
674            )
675        );
676    }
677
678    #[test]
679    fn exclude_workspace_glob_path() {
680        let mut watch = Watch::default().exclude_workspace_path("src/**/*.rs");
681        watch
682            .prepare_excludes()
683            .expect("exclude parsing should succeed");
684        assert_eq!(watch.workspace_exclude_globs.len(), 1);
685
686        assert!(
687            watch.is_excluded_path(
688                metadata()
689                    .workspace_root
690                    .join("src")
691                    .join("lib.rs")
692                    .as_std_path()
693            )
694        );
695    }
696
697    #[test]
698    fn exclude_workspace_absolute_glob_path() {
699        let absolute = metadata()
700            .workspace_root
701            .join("src")
702            .join("**")
703            .join("*.rs");
704        let mut watch = Watch::default().exclude_workspace_path(absolute);
705        watch
706            .prepare_excludes()
707            .expect("exclude parsing should succeed");
708
709        assert_eq!(watch.workspace_exclude_globs.len(), 1);
710        assert!(
711            watch.is_excluded_path(
712                metadata()
713                    .workspace_root
714                    .join("src")
715                    .join("lib.rs")
716                    .as_std_path()
717            )
718        );
719    }
720
721    #[test]
722    fn exclude_workspace_glob_non_match() {
723        let mut watch = Watch::default().exclude_workspace_path("tests/**/*.rs");
724        watch
725            .prepare_excludes()
726            .expect("exclude parsing should succeed");
727
728        assert!(
729            !watch.is_excluded_path(
730                metadata()
731                    .workspace_root
732                    .join("src")
733                    .join("lib.rs")
734                    .as_std_path()
735            )
736        );
737    }
738
739    #[test]
740    fn glob_detection() {
741        assert!(Watch::is_glob_pattern(Path::new("src/**/*.rs")));
742        assert!(Watch::is_glob_pattern(Path::new("foo?.rs")));
743
744        #[cfg(not(windows))]
745        assert!(Watch::is_glob_pattern(Path::new("[ab].rs")));
746        #[cfg(windows)]
747        assert!(!Watch::is_glob_pattern(Path::new("[ab].rs")));
748
749        assert!(!Watch::is_glob_pattern(Path::new("src/lib.rs")));
750    }
751
752    #[test]
753    fn invalid_glob_pattern() {
754        let err = Watch::compile_glob(Path::new("[abc")).expect_err("should fail");
755        assert!(
756            err.to_string().contains("invalid glob pattern"),
757            "unexpected error: {err}"
758        );
759    }
760
761    #[test]
762    fn command_list_froms() {
763        let _: CommandList = Command::new("foo").into();
764        let _: CommandList = vec![Command::new("foo")].into();
765        let _: CommandList = [Command::new("foo")].into();
766    }
767}