use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread::sleep;
use std::time::{Duration, Instant, SystemTime};
const WATCHED: &[&str] = &["src", "config", "Cargo.toml"];
const POLL: Duration = Duration::from_millis(300);
const SETTLE: Duration = Duration::from_millis(250);
const STOP_GRACE: Duration = Duration::from_secs(10);
type Snapshot = BTreeMap<PathBuf, (SystemTime, u64)>;
pub fn run(cargo_args: &[String], watch: bool) -> Result<(), String> {
if !Path::new("Cargo.toml").exists() {
return Err("no Cargo.toml here; run this from your app's directory".into());
}
if !watch {
return crate::cargo_run(cargo_args, &[]);
}
let stopping = Arc::new(AtomicBool::new(false));
let flag = stopping.clone();
ctrlc::set_handler(move || flag.store(true, Ordering::SeqCst))
.map_err(|e| format!("installing the signal handler: {e}"))?;
let binary = binary_path(cargo_args)?;
if !build(cargo_args) {
return Err("the build failed; fix the errors above and run glacier server again".into());
}
let mut child = Some(start(&binary)?);
let mut known = snapshot();
say("watching src/ and config/ for changes (Ctrl-C to stop)");
loop {
sleep(POLL);
if stopping.load(Ordering::SeqCst) {
if let Some(running) = child.take() {
stop(running);
}
return Ok(());
}
if let Some(running) = child.as_mut()
&& let Ok(Some(status)) = running.try_wait()
{
say(&format!("the app exited ({status}); waiting for changes"));
child = None;
}
let current = snapshot();
let changed = changes(&known, ¤t);
if changed.is_empty() {
continue;
}
let current = settle(current);
let changed = changes(&known, ¤t);
say(&format!("changed: {}", summarize(&changed)));
let started = Instant::now();
let built = build(cargo_args);
known = snapshot();
if !built {
say("build failed; still running the previous version");
continue;
}
if let Some(old) = child.take() {
stop(old);
}
child = Some(start(&binary)?);
say(&format!(
"restarted in {:.1}s",
started.elapsed().as_secs_f64()
));
}
}
fn say(message: &str) {
eprintln!("\x1b[36m[glacier]\x1b[0m {message}");
}
fn build(cargo_args: &[String]) -> bool {
crate::dialect::cargo("build", cargo_args)
}
fn start(binary: &Path) -> Result<Child, String> {
Command::new(binary)
.stdin(Stdio::null())
.spawn()
.map_err(|e| format!("starting {}: {e}", binary.display()))
}
fn stop(mut child: Child) {
#[cfg(unix)]
{
let _ = Command::new("kill")
.args(["-INT", &child.id().to_string()])
.status();
let deadline = Instant::now() + STOP_GRACE;
while Instant::now() < deadline {
if matches!(child.try_wait(), Ok(Some(_))) {
return;
}
sleep(Duration::from_millis(50));
}
}
let _ = child.kill();
let _ = child.wait();
}
fn settle(mut current: Snapshot) -> Snapshot {
loop {
sleep(SETTLE);
let next = snapshot();
if next == current {
return next;
}
current = next;
}
}
fn snapshot() -> Snapshot {
let mut files = Snapshot::new();
for root in WATCHED {
walk(Path::new(root), &mut files);
}
files
}
fn walk(path: &Path, files: &mut Snapshot) {
let Ok(meta) = path.metadata() else { return };
if meta.is_dir() {
let Ok(entries) = path.read_dir() else { return };
for entry in entries.flatten() {
let name = entry.file_name();
if name.to_string_lossy().starts_with('.') {
continue;
}
walk(&entry.path(), files);
}
} else if let Ok(modified) = meta.modified() {
files.insert(path.to_path_buf(), (modified, meta.len()));
}
}
fn changes(before: &Snapshot, after: &Snapshot) -> Vec<PathBuf> {
let mut changed: Vec<PathBuf> = after
.iter()
.filter(|(path, stamp)| before.get(*path) != Some(*stamp))
.map(|(path, _)| path.clone())
.collect();
changed.extend(before.keys().filter(|p| !after.contains_key(*p)).cloned());
changed
}
fn summarize(paths: &[PathBuf]) -> String {
let shown: Vec<String> = paths
.iter()
.take(3)
.map(|p| p.display().to_string())
.collect();
match paths.len() {
n if n > 3 => format!("{} and {} more", shown.join(", "), n - 3),
_ => shown.join(", "),
}
}
fn binary_path(cargo_args: &[String]) -> Result<PathBuf, String> {
let output = Command::new("cargo")
.args(["metadata", "--format-version", "1", "--no-deps"])
.output()
.map_err(|e| format!("running cargo metadata: {e}"))?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).into_owned());
}
let metadata: serde_json::Value = serde_json::from_slice(&output.stdout)
.map_err(|e| format!("reading cargo metadata: {e}"))?;
let manifest = std::fs::canonicalize("Cargo.toml").map_err(|e| e.to_string())?;
let package = metadata["packages"]
.as_array()
.into_iter()
.flatten()
.find(|p| p["manifest_path"].as_str().map(Path::new) == Some(manifest.as_path()))
.ok_or("this Cargo.toml's package isn't in cargo metadata")?;
let bin = package["targets"]
.as_array()
.into_iter()
.flatten()
.find(|t| {
t["kind"]
.as_array()
.is_some_and(|kinds| kinds.iter().any(|k| k == "bin"))
})
.and_then(|t| t["name"].as_str())
.ok_or("this package has no binary to run")?;
let target_dir = std::env::var("CARGO_TARGET_DIR")
.ok()
.or_else(|| metadata["target_directory"].as_str().map(str::to_owned))
.ok_or("cargo metadata has no target directory")?;
let profile = if cargo_args.iter().any(|a| a == "--release") {
"release"
} else {
"debug"
};
Ok(Path::new(&target_dir)
.join(profile)
.join(format!("{bin}{}", std::env::consts::EXE_SUFFIX)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_added_removed_and_modified_files() {
let t0 = SystemTime::UNIX_EPOCH;
let t1 = t0 + Duration::from_secs(1);
let before: Snapshot = [
(PathBuf::from("src/a.rs"), (t0, 1)),
(PathBuf::from("src/b.rs"), (t0, 1)),
(PathBuf::from("src/c.rs"), (t0, 1)),
]
.into();
let after: Snapshot = [
(PathBuf::from("src/a.rs"), (t0, 1)),
(PathBuf::from("src/b.rs"), (t1, 1)),
(PathBuf::from("src/d.rs"), (t0, 1)),
]
.into();
let mut changed = changes(&before, &after);
changed.sort();
assert_eq!(
changed,
[
PathBuf::from("src/b.rs"),
PathBuf::from("src/c.rs"),
PathBuf::from("src/d.rs")
]
);
assert!(changes(&after, &after).is_empty());
}
#[test]
fn summarizes_long_lists() {
let paths: Vec<PathBuf> = (0..5).map(|i| PathBuf::from(format!("f{i}"))).collect();
assert_eq!(summarize(&paths), "f0, f1, f2 and 2 more");
}
}