glacier-web-cli 0.1.1

Command-line tool for creating and running Glacier applications.
//! `glacier server`: build, run, and rebuild + restart on changes.
//!
//! Templates, routes and models are all compiled, so a change means a
//! rebuild. If a rebuild fails, the compiler errors are shown and the
//! previous version keeps serving, so the app is never down while you fix a
//! typo.

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};

/// What a change in any of these means a rebuild. `public/` is served from
/// disk and needs none.
const WATCHED: &[&str] = &["src", "config", "Cargo.toml"];
const POLL: Duration = Duration::from_millis(300);
/// How long the files must stay unchanged before rebuilding, so a burst of
/// saves (a formatter, a branch switch) triggers one build.
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, &[]);
    }

    // Stop the app when we're stopped, even by a signal sent only to us
    // (an IDE's stop button), so it doesn't linger holding the port.
    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, &current);
        if changed.is_empty() {
            continue;
        }
        let current = settle(current);
        let changed = changes(&known, &current);
        say(&format!("changed: {}", summarize(&changed)));

        let started = Instant::now();
        let built = build(cargo_args);
        // Compiling .glacier files rewrote their .rs siblings; that's not a
        // new change.
        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}");
}

/// Compiles `.glacier` files and builds, mapping errors back to them.
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()))
}

/// Asks the app to shut down gracefully (SIGINT, like Ctrl-C), then kills it
/// if it hasn't exited after the grace period.
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();
}

/// Waits until the files stop changing, then returns the final snapshot.
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();
            // Editor swap files and hidden directories (including the
            // `.glacier/` build output) aren't sources.
            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()));
    }
}

/// Paths added, removed or modified between two snapshots.
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(", "),
    }
}

/// The app's executable, from `cargo metadata`.
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");
    }
}