glacier-web-cli 0.5.0

Command-line tool for creating and running Glacier applications.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
//! `glacier server`: build, run, and rebuild + restart on changes.
//!
//! Templates, routes and models are all compiled, so most changes mean 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.
//!
//! A template edited without changing its code (markup, classes, wording:
//! its skeleton is the one built) needs no rebuild: the running app reads
//! its text from the file (`glacier::view::reload`), Tailwind rebuilds the
//! stylesheet, and the page reloads itself, as it does for a change in
//! `public/` and after each restart.

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

/// What's watched. A change in `public/` (served from disk in debug
/// builds) or a template's text reloads the page; the rest means a
/// rebuild.
const WATCHED: &[&str] = &["src", "config", "Cargo.toml", "public"];
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)?;
    // Hot reloading is for debug builds: a release build embeds `public/`
    // and its templates' text.
    let release = cargo_args.iter().any(|a| a == "--release");
    let reload = (!release).then(|| Reload::new(&binary));
    let mut built = skeletons();
    // Vite's dev server, beside the app, for an app with a vite.config.js.
    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, &current);
        if changed.is_empty() {
            continue;
        }
        let current = settle(current);
        let changed = changes(&known, &current);

        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);
        // Compiling .glacier files rewrote their .rs siblings; that's not a
        // new change.
        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}");
}

/// 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, 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()))
}

/// Starts Vite's dev server if the app has a `vite.config.js`, on
/// `GLACIER_VITE_PORT` (5173 by default); the server and its address.
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
        }
    }
}

/// The file the app watches to reload its pages: a count, bumped by
/// [`Reload::signal`]. Beside the binary, in the target directory.
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());
    }
}

/// Where templates live: views, and components beside them.
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")
}

/// Each template's skeleton, as the next build compiles it.
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()
}

/// Whether these changes can be shown without a rebuild: each is in
/// `public/`, or a template whose skeleton is the one built. `Some(true)`
/// when templates changed (so Tailwind's classes may have).
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)
}

/// 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");
    }
}