use std::collections::{BTreeMap, HashMap};
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", "public"];
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)?;
let release = cargo_args.iter().any(|a| a == "--release");
let reload = (!release).then(|| Reload::new(&binary));
let mut built = skeletons();
let mut vite = if release { None } else { start_vite() };
let vite_url = vite.as_ref().map(|(_, url)| url.clone());
if !build(cargo_args) {
if let Some((server, _)) = vite.take() {
stop(server);
}
return Err("the build failed; fix the errors above and run glacier server again".into());
}
let mut child = Some(start(&binary, reload.as_ref(), vite_url.as_deref())?);
let mut known = snapshot();
say("watching src/, config/ and public/ for changes (Ctrl-C to stop)");
loop {
sleep(POLL);
if stopping.load(Ordering::SeqCst) {
if let Some(running) = child.take() {
stop(running);
}
if let Some((server, _)) = vite.take() {
stop(server);
}
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);
if let Some(reload) = &reload
&& let Some(templates) = hot(&changed, &built)
{
if templates
&& Path::new(glacier_dialect::tailwind::INPUT).exists()
&& let Err(err) = glacier_dialect::tailwind::build(Path::new("."), false)
{
say(&format!("Tailwind: {err}"));
}
reload.signal();
known = snapshot();
say(&format!(
"reloaded {} (no rebuild needed)",
summarize(&changed)
));
continue;
}
say(&format!("changed: {}", summarize(&changed)));
let started = Instant::now();
let next = skeletons();
let ok = build(cargo_args);
known = snapshot();
if !ok {
say("build failed; still running the previous version");
continue;
}
built = next;
if let Some(old) = child.take() {
stop(old);
}
child = Some(start(&binary, reload.as_ref(), vite_url.as_deref())?);
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, reload: Option<&Reload>, vite: Option<&str>) -> Result<Child, String> {
let mut command = Command::new(binary);
command.stdin(Stdio::null());
if let Some(reload) = reload {
command.env("GLACIER_RELOAD", &reload.file);
}
if let Some(url) = vite {
command.env("GLACIER_VITE", url);
}
command
.spawn()
.map_err(|e| format!("starting {}: {e}", binary.display()))
}
fn start_vite() -> Option<(Child, String)> {
glacier_dialect::vite::config(Path::new("."))?;
let binary = match glacier_dialect::vite::binary(Path::new(".")) {
Ok(binary) => binary,
Err(message) => {
say(&message);
return None;
}
};
let port = std::env::var("GLACIER_VITE_PORT").unwrap_or_else(|_| "5173".into());
match Command::new(binary)
.args(["--port", &port, "--strictPort"])
.stdin(Stdio::null())
.spawn()
{
Ok(server) => {
say(&format!("Vite's dev server on port {port}"));
Some((server, format!("http://localhost:{port}")))
}
Err(e) => {
say(&format!("starting Vite: {e}"));
None
}
}
}
struct Reload {
file: PathBuf,
count: std::cell::Cell<u64>,
}
impl Reload {
fn new(binary: &Path) -> Self {
let file = binary.with_file_name(".glacier-reload");
let _ = std::fs::write(&file, "0");
Self {
file,
count: std::cell::Cell::new(0),
}
}
fn signal(&self) {
self.count.set(self.count.get() + 1);
let _ = std::fs::write(&self.file, self.count.get().to_string());
}
}
const TEMPLATE_DIRS: &[&str] = &["src/views", "src/components"];
fn is_template(path: &Path) -> bool {
TEMPLATE_DIRS.iter().any(|dir| path.starts_with(dir))
&& path
.extension()
.is_some_and(|ext| ext == "html" || ext == "txt")
}
fn skeletons() -> HashMap<PathBuf, u64> {
let mut files = Snapshot::new();
for dir in TEMPLATE_DIRS {
walk(Path::new(dir), &mut files);
}
files
.into_keys()
.filter(|path| is_template(path))
.filter_map(|path| {
let code = std::fs::read_to_string(&path).ok()?;
Some((path, glacier_template::skeleton_of(&code)?))
})
.collect()
}
fn hot(changed: &[PathBuf], built: &HashMap<PathBuf, u64>) -> Option<bool> {
let mut templates = false;
for path in changed {
if path.starts_with("public") {
continue;
}
if !is_template(path) {
return None;
}
let code = std::fs::read_to_string(path).ok()?;
let skeleton = glacier_template::skeleton_of(&code)?;
if built.get(path) != Some(&skeleton) {
return None;
}
templates = true;
}
Some(templates)
}
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");
}
}