use std::{
collections::BTreeMap,
env, fs,
path::{Path, PathBuf},
process::{Child, Command, Stdio},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
thread,
time::{Duration, Instant, SystemTime},
};
type Result<T> = std::result::Result<T, String>;
const POLL: Duration = Duration::from_millis(200);
const DEBOUNCE: Duration = Duration::from_millis(300);
#[cfg(unix)]
const SHUTDOWN: Duration = Duration::from_secs(5);
#[derive(Default)]
pub struct Options {
binary: Option<String>,
watch: Vec<PathBuf>,
arguments: Vec<String>,
}
impl Options {
pub fn parse(arguments: &[String]) -> Result<Self> {
let mut options = Self::default();
let mut arguments = arguments.iter();
while let Some(argument) = arguments.next() {
match argument.as_str() {
"--" => {
options.arguments.extend(arguments.cloned());
break;
}
"--bin" if options.binary.is_none() => {
let value = arguments
.next()
.filter(|v| !v.starts_with('-') && !v.is_empty())
.ok_or("--bin requires a binary name")?;
options.binary = Some(value.clone());
}
"--watch" => {
let value = arguments
.next()
.filter(|v| !v.starts_with('-') && !v.is_empty())
.ok_or("--watch requires a path")?;
options.watch.push(PathBuf::from(value));
}
_ => return Err(format!("unknown or duplicate dev option: {argument}")),
}
}
Ok(options)
}
}
type Snapshot = BTreeMap<PathBuf, (u64, SystemTime)>;
fn ignored_directory(name: &str) -> bool {
matches!(
name,
"target"
| ".git"
| ".hg"
| ".svn"
| "node_modules"
| "dist"
| "build"
| ".idea"
| ".vscode"
)
}
fn watched_file(path: &Path) -> bool {
let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
name == ".env"
|| name.starts_with(".env.")
|| name == "Cargo.lock"
|| matches!(
path.extension().and_then(|s| s.to_str()),
Some(
"rs" | "toml"
| "json"
| "yaml"
| "yml"
| "sql"
| "html"
| "css"
| "js"
| "ts"
| "graphql"
)
)
}
fn snapshot(roots: &[PathBuf], target: &Path) -> Result<Snapshot> {
fn visit(path: &Path, files: &mut Snapshot, explicit: bool, target: &Path) -> Result<()> {
if path.starts_with(target) {
return Ok(());
}
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(format!("cannot watch {}: {error}", path.display())),
};
if metadata.file_type().is_symlink() {
return Ok(());
}
if metadata.is_dir() {
for entry in
fs::read_dir(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?
{
let entry = entry.map_err(|e| e.to_string())?;
if !ignored_directory(&entry.file_name().to_string_lossy()) {
visit(&entry.path(), files, false, target)?;
}
}
} else if metadata.is_file() && (explicit || watched_file(path)) {
files.insert(
path.to_owned(),
(
metadata.len(),
metadata.modified().map_err(|e| e.to_string())?,
),
);
}
Ok(())
}
let mut files = BTreeMap::new();
for root in roots {
visit(root, &mut files, true, target)?;
}
Ok(files)
}
fn canonical_path(path: &Path) -> Result<PathBuf> {
if path.exists() {
return path.canonicalize().map_err(|e| e.to_string());
}
let parent = path.parent().ok_or("target directory has no parent")?;
let name = path.file_name().ok_or("target directory has no name")?;
Ok(canonical_path(parent)?.join(name))
}
struct Process(Child);
impl Process {
fn start(root: &Path, options: &Options) -> Result<Self> {
let mut command = Command::new("cargo");
command.current_dir(root).arg("run");
if let Some(binary) = &options.binary {
command.args(["--bin", binary]);
}
if !options.arguments.is_empty() {
command.arg("--").args(&options.arguments);
}
command.stdin(Stdio::null());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
command.process_group(0);
}
command
.spawn()
.map(Self)
.map_err(|e| format!("could not start cargo run: {e}"))
}
fn stop(&mut self) {
#[cfg(unix)]
{
use nix::{
sys::signal::{Signal, killpg},
unistd::Pid,
};
let group = Pid::from_raw(self.0.id() as i32);
let _ = killpg(group, Signal::SIGTERM);
let deadline = Instant::now() + SHUTDOWN;
loop {
let _ = self.0.try_wait();
if killpg(group, None).is_err() {
break;
}
if Instant::now() >= deadline {
let _ = killpg(group, Signal::SIGKILL);
break;
}
thread::sleep(Duration::from_millis(50));
}
}
#[cfg(windows)]
{
let _ = Command::new("taskkill")
.args(["/PID", &self.0.id().to_string(), "/T", "/F"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
let _ = self.0.kill();
let _ = self.0.wait();
}
}
impl Drop for Process {
fn drop(&mut self) {
self.stop();
}
}
pub fn run(options: Options) -> Result<()> {
let root = env::current_dir().map_err(|e| e.to_string())?;
if !root.join("Cargo.toml").is_file() {
return Err("run xecute run dev from a Rust project root containing Cargo.toml".into());
}
let mut roots = vec![root.clone()];
for path in &options.watch {
roots.push(
path.canonicalize()
.map_err(|e| format!("watch path {}: {e}", path.display()))?,
);
}
let metadata = Command::new("cargo")
.args(["metadata", "--no-deps", "--format-version", "1"])
.current_dir(&root)
.output()
.map_err(|e| format!("could not inspect Cargo project: {e}"))?;
if !metadata.status.success() {
return Err(format!(
"could not inspect Cargo project:\n{}",
String::from_utf8_lossy(&metadata.stderr)
));
}
let metadata: serde_json::Value =
serde_json::from_slice(&metadata.stdout).map_err(|e| e.to_string())?;
let target = PathBuf::from(
metadata["target_directory"]
.as_str()
.ok_or("Cargo metadata is missing target_directory")?,
);
let target = canonical_path(&target)?;
let mut previous = snapshot(&roots, &target)?;
let lock_path = root.join("Cargo.lock");
let mut awaiting_initial_lock = !lock_path.exists();
let running = Arc::new(AtomicBool::new(true));
let signal = running.clone();
ctrlc::set_handler(move || {
signal.store(false, Ordering::SeqCst);
})
.map_err(|e| format!("could not register shutdown handler: {e}"))?;
eprintln!(
"[xecute dev] Watching {} path(s). Save to rebuild/restart; Ctrl-C to stop.",
roots.len()
);
let mut child = Some(Process::start(&root, &options)?);
let mut changed = None;
while running.load(Ordering::SeqCst) {
thread::sleep(POLL);
let current = snapshot(&roots, &target)?;
if awaiting_initial_lock && let Some(stamp) = current.get(&lock_path) {
previous.insert(lock_path.clone(), *stamp);
awaiting_initial_lock = false;
}
if current != previous {
if let Some(path) = current
.iter()
.find(|(path, stamp)| previous.get(*path) != Some(*stamp))
.map(|(path, _)| path)
.or_else(|| previous.keys().find(|path| !current.contains_key(*path)))
{
eprintln!("[xecute dev] File changed: {}", path.display());
}
previous = current;
changed = Some(Instant::now());
}
if let Some(process) = &mut child
&& let Some(status) = process.0.try_wait().map_err(|e| e.to_string())?
{
eprintln!("[xecute dev] Process exited ({status}); waiting for changes.");
child = None;
}
if changed.is_some_and(|time| time.elapsed() >= DEBOUNCE) {
changed = None;
drop(child.take());
if !running.load(Ordering::SeqCst) {
break;
}
eprintln!("[xecute dev] Change detected; rebuilding and restarting...");
child = Some(Process::start(&root, &options)?);
}
}
eprintln!("[xecute dev] Stopping...");
drop(child);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn options_preserve_application_arguments_and_reject_invalid_flags() {
let args = [
"--bin",
"server",
"--watch",
"../runtime",
"--",
"--port",
"4000",
]
.map(String::from);
let options = Options::parse(&args).unwrap();
assert_eq!(options.binary.as_deref(), Some("server"));
assert_eq!(options.arguments, ["--port", "4000"]);
assert_eq!(options.watch, [PathBuf::from("../runtime")]);
for args in [
vec!["--watch"],
vec!["--bin"],
vec!["--bin", "a", "--bin", "b"],
vec!["--release"],
] {
assert!(
Options::parse(&args.into_iter().map(String::from).collect::<Vec<_>>()).is_err()
);
}
}
}