1#![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
179pub 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
190pub fn package(name: &str) -> Option<&cargo_metadata::Package> {
192 metadata().packages.iter().find(|x| x.name == name)
193}
194
195pub fn xtask_command() -> Command {
197 Command::new(env::args_os().next().unwrap())
198}
199
200#[non_exhaustive]
203#[derive(Clone, Debug, Default, Parser)]
204#[clap(about = "Watches over your project's source code.")]
205pub struct Watch {
206 #[clap(long = "shell", short = 's')]
208 pub shell_commands: Vec<String>,
209 #[clap(long = "exec", short = 'x')]
213 pub cargo_commands: Vec<String>,
214 #[clap(long = "watch", short = 'w')]
218 pub watch_paths: Vec<PathBuf>,
219 #[clap(long = "ignore", short = 'i')]
221 pub exclude_paths: Vec<PathBuf>,
222 #[clap(skip)]
224 pub workspace_exclude_paths: Vec<PathBuf>,
225 #[clap(skip = Duration::from_secs(2))]
230 pub debounce: Duration,
231}
232
233impl Watch {
234 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 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 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 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 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 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 pub fn debounce(mut self, duration: Duration) -> Self {
285 self.debounce = duration;
286 self
287 }
288
289 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#[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 pub fn is_empty(&self) -> bool {
577 self.commands.lock().expect("not poisoned").is_empty()
578 }
579
580 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 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}