#![deny(missing_docs)]
use anyhow::{Context, Result};
use clap::Parser;
use lazy_static::lazy_static;
use notify::{Event, EventHandler, RecursiveMode, Watcher};
use std::{
env, io,
path::{Path, PathBuf},
process::{Child, Command, ExitStatus},
sync::{mpsc, Arc, Mutex},
thread,
time::{Duration, Instant},
};
pub use anyhow;
pub use cargo_metadata;
pub use cargo_metadata::camino;
pub use clap;
pub fn metadata() -> &'static cargo_metadata::Metadata {
lazy_static! {
static ref METADATA: cargo_metadata::Metadata = cargo_metadata::MetadataCommand::new()
.exec()
.expect("cannot get crate's metadata");
}
&METADATA
}
pub fn package(name: &str) -> Option<&cargo_metadata::Package> {
metadata().packages.iter().find(|x| x.name == name)
}
pub fn xtask_command() -> Command {
Command::new(env::args_os().next().unwrap())
}
#[non_exhaustive]
#[derive(Clone, Debug, Default, Parser)]
#[clap(about = "Watches over your project's source code.")]
pub struct Watch {
#[clap(long = "shell", short = 's')]
pub shell_commands: Vec<String>,
#[clap(long = "exec", short = 'x')]
pub cargo_commands: Vec<String>,
#[clap(long = "watch", short = 'w')]
pub watch_paths: Vec<PathBuf>,
#[clap(long = "ignore", short = 'i')]
pub exclude_paths: Vec<PathBuf>,
#[clap(skip)]
pub workspace_exclude_paths: Vec<PathBuf>,
#[clap(skip = Duration::from_secs(2))]
pub debounce: Duration,
}
impl Watch {
pub fn watch_path(mut self, path: impl AsRef<Path>) -> Self {
self.watch_paths.push(path.as_ref().to_path_buf());
self
}
pub fn watch_paths(mut self, paths: impl IntoIterator<Item = impl AsRef<Path>>) -> Self {
for path in paths {
self.watch_paths.push(path.as_ref().to_path_buf())
}
self
}
pub fn exclude_path(mut self, path: impl AsRef<Path>) -> Self {
self.exclude_paths.push(path.as_ref().to_path_buf());
self
}
pub fn exclude_paths(mut self, paths: impl IntoIterator<Item = impl AsRef<Path>>) -> Self {
for path in paths {
self.exclude_paths.push(path.as_ref().to_path_buf());
}
self
}
pub fn exclude_workspace_path(mut self, path: impl AsRef<Path>) -> Self {
self.workspace_exclude_paths
.push(path.as_ref().to_path_buf());
self
}
pub fn exclude_workspace_paths(
mut self,
paths: impl IntoIterator<Item = impl AsRef<Path>>,
) -> Self {
for path in paths {
self.workspace_exclude_paths
.push(path.as_ref().to_path_buf());
}
self
}
pub fn debounce(mut self, duration: Duration) -> Self {
self.debounce = duration;
self
}
pub fn run(mut self, commands: impl Into<CommandList>) -> Result<()> {
let metadata = metadata();
let list = commands.into();
{
let mut commands = list.commands.lock().expect("not poisoned");
commands.extend(self.shell_commands.iter().map(|x| {
let mut command =
Command::new(env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()));
command.arg("-c");
command.arg(x);
command
}));
commands.extend(self.cargo_commands.iter().map(|x| {
let mut command =
Command::new(env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()));
command.arg("-c");
command.arg(format!("cargo {x}"));
command
}));
}
self.exclude_paths
.push(metadata.target_directory.clone().into_std_path_buf());
self.exclude_paths = self
.exclude_paths
.into_iter()
.map(|x| {
x.canonicalize()
.with_context(|| format!("can't find {}", x.display()))
})
.collect::<Result<Vec<_>, _>>()?;
if self.watch_paths.is_empty() {
self.watch_paths
.push(metadata.workspace_root.clone().into_std_path_buf());
}
self.watch_paths = self
.watch_paths
.into_iter()
.map(|x| {
x.canonicalize()
.with_context(|| format!("can't find {}", x.display()))
})
.collect::<Result<Vec<_>, _>>()?;
let (tx, rx) = mpsc::channel();
let handler = WatchEventHandler {
watch: self.clone(),
tx,
command_start: Instant::now(),
};
let mut watcher =
notify::recommended_watcher(handler).context("could not initialize watcher")?;
for path in &self.watch_paths {
match watcher.watch(path, RecursiveMode::Recursive) {
Ok(()) => log::trace!("Watching {}", path.display()),
Err(err) => log::error!("cannot watch {}: {err}", path.display()),
}
}
let mut current_child = SharedChild::new();
loop {
{
log::info!("Re-running command");
let mut current_child = current_child.clone();
let mut list = list.clone();
thread::spawn(move || {
let mut status = ExitStatus::default();
list.spawn(|res| match res {
Err(err) => {
log::error!("Could not execute command: {err}");
false
}
Ok(child) => {
log::trace!("new child: {}", child.id());
current_child.replace(child);
status = current_child.wait();
status.success()
}
});
if status.success() {
log::info!("Command succeeded.");
} else if let Some(code) = status.code() {
log::error!("Command failed (exit code: {code})");
} else {
log::error!("Command failed.");
}
});
}
let res = rx.recv();
if res.is_ok() {
log::trace!("Changes detected, re-generating");
}
current_child.terminate();
if res.is_err() {
break;
}
}
Ok(())
}
fn is_excluded_path(&self, path: &Path) -> bool {
if self.exclude_paths.iter().any(|x| path.starts_with(x)) {
return true;
}
if let Ok(stripped_path) = path.strip_prefix(metadata().workspace_root.as_std_path()) {
if self
.workspace_exclude_paths
.iter()
.any(|x| stripped_path.starts_with(x))
{
return true;
}
}
false
}
fn is_hidden_path(&self, path: &Path) -> bool {
self.watch_paths.iter().any(|x| {
path.strip_prefix(x)
.iter()
.any(|x| x.to_string_lossy().starts_with('.'))
})
}
fn is_backup_file(&self, path: &Path) -> bool {
self.watch_paths.iter().any(|x| {
path.strip_prefix(x)
.iter()
.any(|x| x.to_string_lossy().ends_with('~'))
})
}
}
struct WatchEventHandler {
watch: Watch,
tx: mpsc::Sender<()>,
command_start: Instant,
}
impl EventHandler for WatchEventHandler {
fn handle_event(&mut self, event: Result<Event, notify::Error>) {
match event {
Ok(event) => {
if (event.kind.is_modify() || event.kind.is_create() || event.kind.is_create())
&& event.paths.iter().any(|x| {
!self.watch.is_excluded_path(x)
&& x.exists()
&& !self.watch.is_hidden_path(x)
&& !self.watch.is_backup_file(x)
&& self.command_start.elapsed() >= self.watch.debounce
})
{
log::trace!("Changes detected in {event:?}");
self.command_start = Instant::now();
self.tx.send(()).expect("can send");
} else {
log::trace!("Ignoring changes in {event:?}");
}
}
Err(err) => log::error!("watch error: {err}"),
}
}
}
#[derive(Debug, Clone)]
struct SharedChild {
child: Arc<Mutex<Option<Child>>>,
}
impl SharedChild {
fn new() -> Self {
Self {
child: Default::default(),
}
}
fn replace(&mut self, child: impl Into<Option<Child>>) {
*self.child.lock().expect("not poisoned") = child.into();
}
fn wait(&mut self) -> ExitStatus {
loop {
let mut child = self.child.lock().expect("not poisoned");
match child.as_mut().map(|child| child.try_wait()) {
Some(Ok(Some(status))) => {
break status;
}
Some(Ok(None)) => {
drop(child);
thread::sleep(Duration::from_millis(10));
}
Some(Err(err)) => {
log::error!("could not wait for child process: {err}");
break Default::default();
}
None => {
break Default::default();
}
}
}
}
fn terminate(&mut self) {
if let Some(child) = self.child.lock().expect("not poisoned").as_mut() {
#[cfg(unix)]
{
let killing_start = Instant::now();
unsafe {
log::trace!("sending SIGTERM to {}", child.id());
libc::kill(child.id() as _, libc::SIGTERM);
}
while killing_start.elapsed().as_secs() < 2 {
std::thread::sleep(Duration::from_millis(200));
if let Ok(Some(_)) = child.try_wait() {
break;
}
}
}
match child.try_wait() {
Ok(Some(_)) => {}
_ => {
log::trace!("killing {}", child.id());
let _ = child.kill();
let _ = child.wait();
}
}
} else {
log::trace!("nothing to terminate");
}
}
}
#[derive(Debug, Clone)]
pub struct CommandList {
commands: Arc<Mutex<Vec<Command>>>,
}
impl From<Command> for CommandList {
fn from(command: Command) -> Self {
Self {
commands: Arc::new(Mutex::new(vec![command])),
}
}
}
impl From<Vec<Command>> for CommandList {
fn from(commands: Vec<Command>) -> Self {
Self {
commands: Arc::new(Mutex::new(commands)),
}
}
}
impl<const SIZE: usize> From<[Command; SIZE]> for CommandList {
fn from(commands: [Command; SIZE]) -> Self {
Self {
commands: Arc::new(Mutex::new(Vec::from(commands))),
}
}
}
impl CommandList {
pub fn is_empty(&self) -> bool {
self.commands.lock().expect("not poisoned").is_empty()
}
pub fn spawn(&mut self, mut callback: impl FnMut(io::Result<Child>) -> bool) {
for process in self.commands.lock().expect("not poisoned").iter_mut() {
if !callback(process.spawn()) {
break;
}
}
}
pub fn status(&mut self) -> io::Result<ExitStatus> {
for process in self.commands.lock().expect("not poisoned").iter_mut() {
let exit_status = process.status()?;
if !exit_status.success() {
return Ok(exit_status);
}
}
Ok(Default::default())
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn exclude_relative_path() {
let watch = Watch {
shell_commands: Vec::new(),
cargo_commands: Vec::new(),
debounce: Default::default(),
watch_paths: Vec::new(),
exclude_paths: Vec::new(),
workspace_exclude_paths: vec![PathBuf::from("src/watch.rs")],
};
assert!(watch.is_excluded_path(
metadata()
.workspace_root
.join("src")
.join("watch.rs")
.as_std_path()
));
assert!(!watch.is_excluded_path(metadata().workspace_root.join("src").as_std_path()));
}
#[test]
fn command_list_froms() {
let _: CommandList = Command::new("foo").into();
let _: CommandList = vec![Command::new("foo")].into();
let _: CommandList = [Command::new("foo")].into();
}
}