AkurAI Build
Menu

Public repository / 802374bdcc6dcc0421804cba41a30f724cc4b6dc

AkurAI-Framework / crates/cli/src/cmd_serve.rs

Latest change e888012a38cfe0d4c47ddf4b3eecfba5ad6b8ac1 - Wire CSS engine + asset pipeline into serve/dev by Ólafur Búi Ólafsson

//! `akurai serve` / `akurai dev` — serve a project's frontend with a lean
//! built-in HTTP server.
//!
//! Client-first: static assets are served directly; the server only adds a few
//! JSON endpoints (`/api/health`, `/api/_meta`). Unknown non-API routes fall
//! back to `index.html` so client-side routing works (SPA fallback).

use crate::asset_pipeline;
use crate::auth;
use crate::collections_api::CollectionsApi;
use crate::css_build;
use crate::dev_reload;
use crate::guestbook;
use crate::mime;
use crate::routes;
use crate::seo;
use crate::store;
use akurai_http::{
    sse, MiddlewareStack, Reply, Request, RequestLogger, Response, SecurityHeaders, Server, Timing,
};
use akurai_json::Value;
use akurai_template::Engine;
use akurai_ws::{Message, WsConn};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::Duration;

const VERSION: &str = env!("CARGO_PKG_VERSION");

pub struct Config {
    pub host: String,
    pub port: u16,
    pub dir: PathBuf,
    /// `akurai dev` sets this; it turns on file-watching live-reload. `serve`
    /// leaves it false and the dev machinery stays completely dormant.
    pub dev: bool,
}

/// The CSS-engine + asset-pipeline state threaded through dispatch.
///
/// In `serve` the utility stylesheet is rendered once and cached, and the
/// fingerprint manifest is built once at startup. In `dev` the utilities are
/// regenerated fresh per request (so edits show up on reload) and the manifest
/// is empty (no far-future caching while iterating).
struct AssetState {
    /// Pre-rendered `utilities.css` for production; `None` in dev → regenerate.
    utilities: Option<String>,
    /// Fingerprint + minify manifest; empty in dev.
    manifest: asset_pipeline::Manifest,
}

impl AssetState {
    /// Build the state for a project rooted at `root`. Dev keeps assets fresh and
    /// un-hashed; production renders + fingerprints once.
    fn build(root: &Path, dev: bool) -> AssetState {
        if dev {
            return AssetState {
                utilities: None,
                manifest: asset_pipeline::Manifest::empty(),
            };
        }
        let utilities = css_build::utilities_css(root);
        // Fold the generated utilities.css into the manifest too, so it can be
        // referenced by a fingerprinted, immutably-cached URL like any asset.
        let manifest = asset_pipeline::Manifest::build(
            root,
            &[("/utilities.css".to_string(), utilities.clone().into_bytes())],
        );
        AssetState {
            utilities: Some(utilities),
            manifest,
        }
    }

    /// The current `utilities.css` body: the cached copy in production, or a
    /// freshly regenerated scan in dev.
    fn utilities_css(&self, root: &Path) -> String {
        match &self.utilities {
            Some(css) => css.clone(),
            None => css_build::utilities_css(root),
        }
    }

    /// Serve a static asset. A request for a known fingerprinted path is served
    /// from memory with the immutable cache header; anything else falls back to
    /// the plain on-disk file (backward compatible).
    fn serve_static(&self, root: &Path, req_path: &str) -> Response {
        if let Some(asset) = self.manifest.hashed(req_path) {
            return Response::ok()
                .with_body(asset.content_type, asset.bytes.to_vec())
                .with_header("Cache-Control", asset_pipeline::IMMUTABLE_CACHE);
        }
        serve_static(root, req_path)
    }
}

pub fn run(cfg: Config) -> io::Result<()> {
    if !cfg.dir.is_dir() {
        return Err(io::Error::new(
            io::ErrorKind::NotFound,
            format!("frontend directory not found: {}", cfg.dir.display()),
        ));
    }

    let addr = format!("{}:{}", cfg.host, cfg.port);
    let server = Server::bind(&addr)?;
    let local = server.local_addr()?;
    let root = cfg.dir.clone();

    // Dev mode only: watch the frontend, content, and backend dirs and push a
    // reload to connected browsers on any change. `None` in `serve` mode.
    let live = if cfg.dev {
        let mut dirs = vec![root.clone()];
        if let Some(parent) = root.parent() {
            dirs.push(parent.join("content"));
            dirs.push(parent.join("backend"));
        }
        Some(dev_reload::start(dirs))
    } else {
        None
    };

    // Durable record store for the guestbook form-actions demo, under the
    // project's `data/` dir (a sibling of `frontend/`). Created if absent.
    let data_dir = root
        .parent()
        .map(|p| p.join("data"))
        .unwrap_or_else(|| root.join("data"));
    fs::create_dir_all(&data_dir)?;
    let store = store::Store::open(data_dir.join("store.db"))?;
    let guestbook = guestbook::State::new(store);

    // Cookie-based session auth: durable session + user stores under `data/`,
    // with the demo user seeded so the login flow works out of the box.
    let auth = auth::State::open(&data_dir)?;

    // Auto-generated REST API, driven by `backend/collections.toml`. Absent file
    // → zero collections and the server behaves exactly as before. A malformed
    // schema fails `serve` loudly here rather than silently dropping routes.
    let project_root = root.parent().map(Path::to_path_buf).unwrap_or(root.clone());
    let collections_api = CollectionsApi::open(&project_root).map_err(|msg| {
        io::Error::new(
            io::ErrorKind::InvalidData,
            format!("collections.toml: {msg}"),
        )
    })?;

    // CSS engine + asset pipeline. Production renders utilities.css and builds
    // the fingerprint manifest once; dev regenerates utilities per request and
    // skips fingerprinting. Either way `/utilities.css` is available to link.
    let assets = AssetState::build(&root, cfg.dev);

    println!("AkurAI-Framework v{VERSION}");
    println!("  serving {}", root.display());
    println!("  → http://{local}");
    println!("  utilities.css: served at /utilities.css");
    if !cfg.dev {
        println!(
            "  asset pipeline: {} fingerprinted asset(s), manifest at /api/_assets",
            assets.manifest.len()
        );
    }
    if live.is_some() {
        println!("  live-reload: on (watching for changes)");
    }

    // Every response passes out through these (outermost first): security
    // headers stamp even short-circuited replies, then request logging + timing.
    let middleware = MiddlewareStack::new()
        .push(SecurityHeaders)
        .push(RequestLogger)
        .push(Timing);

    server.run_with(middleware, move |req: &Request| match &live {
        // Dev: serve the reload stream and inject the client script into HTML.
        Some(lr) if req.path == "/_dev/reload" => dev_reload::endpoint(lr),
        Some(_) => inject_livereload(dispatch(
            &root,
            &guestbook,
            &auth,
            &collections_api,
            &assets,
            req,
        )),
        // Production: untouched.
        None => dispatch(&root, &guestbook, &auth, &collections_api, &assets, req),
    })
}

/// In dev, splice the live-reload `<script>` into HTML responses; leave every
/// other response (and all upgrades) exactly as-is.
fn inject_livereload(reply: Reply) -> Reply {
    let Reply::Response(resp) = reply else {
        return reply; // SSE/WS upgrades are never HTML
    };
    let is_html = resp
        .headers
        .iter()
        .any(|(k, v)| k.eq_ignore_ascii_case("content-type") && v.contains("text/html"));
    if !is_html {
        return Reply::Response(resp);
    }
    match String::from_utf8(resp.body.clone()) {
        Ok(html) => Reply::Response(resp.with_body(
            "text/html; charset=utf-8",
            dev_reload::inject(&html).into_bytes(),
        )),
        Err(_) => Reply::Response(resp),
    }
}

/// Top-level dispatch. Realtime endpoints upgrade the connection and so return
/// a [`Reply`]; everything else is an ordinary buffered [`Response`].
fn dispatch(
    root: &Path,
    guestbook: &guestbook::State,
    auth: &auth::State,
    collections_api: &CollectionsApi,
    assets: &AssetState,
    req: &Request,
) -> Reply {
    // Auto-API: `/api/collections...` is handled here, before the generic
    // 404-for-`/api/*` arm in `route`. A non-collections shape returns `None`
    // and falls through to the normal routing below.
    if req.path == "/api/collections" || req.path.starts_with("/api/collections/") {
        let rest = req.path.trim_start_matches("/api/collections");
        let segments: Vec<&str> = rest.split('/').filter(|s| !s.is_empty()).collect();
        let body = req.body_str();
        let body = if req.body.is_empty() {
            None
        } else {
            Some(body.as_ref())
        };
        if let Some((status, value)) =
            collections_api.dispatch(&req.method, &segments, req.query.as_deref(), body)
        {
            return Reply::Response(json(status, &value));
        }
    }

    match req.path.as_str() {
        // A WebSocket echo: send a text/binary frame, get it back. Verifies the
        // RFC 6455 stack end to end.
        "/api/ws" => akurai_ws::upgrade(req, ws_echo),
        // A Server-Sent Events stream: a `tick` event every second, counting up.
        "/api/events" => sse_clock(),
        // Form-actions demo: GET lists entries, POST validates + stores + redirects.
        "/guestbook" => guestbook::handle(guestbook, root, req),
        // Cookie-based session auth: login form, protected page, logout.
        "/login" => auth::login(auth, root, req),
        "/account" => auth::account(auth, root, req),
        "/logout" => auth::logout(auth, req),
        // The atomic CSS engine's generated stylesheet. Cached in production,
        // regenerated fresh per request in dev. Always available to link, even
        // when empty (a project that uses no utility classes).
        "/utilities.css" => Reply::Response(Response::ok().with_body(
            "text/css; charset=utf-8",
            assets.utilities_css(root).into_bytes(),
        )),
        // Asset fingerprint manifest: plain path → hashed path. Empty in dev.
        "/api/_assets" => Reply::Response(json(200, &assets.manifest.to_json())),
        // SEO essentials, derived from backend/routes.json + page.json.
        "/robots.txt" => Reply::Response(seo::robots(root)),
        "/sitemap.xml" => Reply::Response(seo::sitemap(root)),
        // `/_dev/*` is a reserved internal namespace. In `dev` the run loop
        // intercepts `/_dev/reload` before dispatch; anything else here (and the
        // whole namespace under `serve`) is a hard 404, never the SPA fallback.
        p if p.starts_with("/_dev/") => Reply::Response(Response::not_found()),
        _ => Reply::Response(route(root, collections_api.collections(), assets, req)),
    }
}

/// Echo every text/binary message back to the client until it closes.
fn ws_echo(mut conn: WsConn) -> io::Result<()> {
    while let Some(msg) = conn.recv()? {
        match msg {
            Message::Text(text) => conn.send_text(&text)?,
            Message::Binary(data) => conn.send_binary(&data)?,
            Message::Close(_) => break,
            _ => {} // ping/pong handled inside recv
        }
    }
    Ok(())
}

/// Stream an incrementing counter as a named SSE event once per second. Loops
/// until the client disconnects, at which point the write fails and the
/// dedicated connection thread ends.
fn sse_clock() -> Reply {
    sse(|mut sink| {
        let mut n: u64 = 0;
        loop {
            n += 1;
            sink.event("tick", &n.to_string())?;
            std::thread::sleep(Duration::from_secs(1));
        }
    })
}

fn route(
    root: &Path,
    collections: &[akurai_collections::Collection],
    assets: &AssetState,
    req: &Request,
) -> Response {
    match req.path.as_str() {
        "/api/health" => json(200, &health()),
        "/api/_meta" => json(200, &meta_manifest(collections)),
        p if p.starts_with("/api/") => json(
            404,
            &Value::Object(vec![("error".into(), Value::Str("not found".into()))]),
        ),
        // Markdown content pages, rendered into the "docs" layout.
        "/changelog" => render_content(root, "changelog", "changelog"),
        p if p == "/docs" || p.starts_with("/docs/") => {
            let slug = p.trim_start_matches("/docs").trim_matches('/');
            let slug = if slug.is_empty() {
                "getting-started"
            } else {
                slug
            };
            render_content(root, &format!("docs/{slug}"), slug)
        }
        // Static assets (anything with a non-.html extension). A fingerprinted
        // path is served from memory with an immutable cache header; a plain path
        // resolves to the raw file on disk exactly as before.
        p if is_static_asset(p) => assets.serve_static(root, p),
        // Everything else is a page, routed through the declarative router.
        p => render_page_routed(root, p),
    }
}

/// Resolve a page path. If `backend/routes.json` exists, match it through the
/// router (capturing `:param`/`*wildcard` segments into the context); otherwise
/// fall back to the legacy "template named after the last path segment". An
/// unmatched path renders `index` (client-side routing picks it up from there).
fn render_page_routed(root: &Path, path: &str) -> Response {
    match routes::load(root) {
        Some(router) => match router.match_path(path) {
            // Inject any per-route `<head>` metadata (meta_title/meta_description)
            // declared for this path; absent fields leave rendering unchanged.
            Some(matched) => render_page_with(
                root,
                &matched.value.template,
                matched.value.layout.as_deref(),
                matched.value.data.as_deref(),
                &matched.params,
                &seo::route_meta(root, path),
            ),
            None => render_page_with(root, "index", None, None, &[], &[]),
        },
        None => render_page(root, &template_name(path)),
    }
}

/// Render a Markdown file from `content/<rel>.md` into the `docs` layout
/// template. `active` marks the current sidebar entry. 404 if the file is
/// missing; that keeps unknown `/docs/*` routes honest rather than blank.
fn render_content(root: &Path, rel: &str, active: &str) -> Response {
    let Some(content_dir) = root.parent().map(|p| p.join("content")) else {
        return Response::not_found();
    };
    let md_path = content_dir.join(format!("{rel}.md"));
    let markdown = match fs::read_to_string(&md_path) {
        Ok(text) => text,
        Err(_) => {
            return Response::new(404).with_html(&error_page(&format!("no content: {rel}.md")))
        }
    };
    let html = akurai_markdown::to_html(&markdown);

    let engine = match build_engine(root) {
        Ok(engine) => engine,
        Err(msg) => return Response::new(500).with_html(&error_page(&msg)),
    };
    let mut context = load_context(root);
    if let Value::Object(pairs) = &mut context {
        pairs.push(("content".into(), Value::Str(html)));
        pairs.push(("active".into(), Value::Str(active.to_string())));
    }
    match engine.render("docs", &context) {
        Ok(page) => Response::ok().with_html(&page),
        Err(e) => Response::new(500).with_html(&error_page(&e.message)),
    }
}

fn serve_static(root: &Path, req_path: &str) -> Response {
    match resolve(root, req_path) {
        Some(path) if path.is_file() => read_file(&path),
        _ => Response::not_found(),
    }
}

/// A request is a static asset if its last segment has an extension other than
/// `.html` (e.g. `.css`, `.js`, `.png`).
fn is_static_asset(path: &str) -> bool {
    let last = path.rsplit('/').next().unwrap_or("");
    match last.rsplit_once('.') {
        Some((_, ext)) => !ext.eq_ignore_ascii_case("html"),
        None => false,
    }
}

/// Map a request path to a template name: `/` → `index`, `/about` or
/// `/about.html` → `about`.
fn template_name(path: &str) -> String {
    let trimmed = path.trim_matches('/');
    if trimmed.is_empty() {
        return "index".to_string();
    }
    let last = trimmed.rsplit('/').next().unwrap_or("index");
    last.strip_suffix(".html").unwrap_or(last).to_string()
}

/// Render a page server-side: register every `frontend/*.html` as a template,
/// load the JSON context, and render `name` (falling back to `index` for
/// unknown routes — SPA-style). Template/parse errors surface as a 500 so they
/// are visible during development.
fn render_page(root: &Path, name: &str) -> Response {
    render_page_with(root, name, None, None, &[], &[])
}

/// Render template `name` with `params` (captured route segments) merged into
/// the context under `params`, so templates can read `{{ params.id }}`. Extra
/// context pairs (e.g. per-route `meta_title`/`meta_description`) are merged at
/// the top level. Falls back to the `index` template for unknown names
/// (SPA-style).
///
/// When `data` is `Some`, the named JSON file under `backend/` is loaded and
/// merged into the context before rendering (see [`load_route_data`] for the
/// merge rule): a loaded object's top-level keys are spread at the root and the
/// whole document is also exposed under `data`, so a template can both read
/// `{{ title }}` and iterate `{% for x in members %}`. `None` adds nothing.
///
/// When `layout` is `Some`, the page is rendered first and its HTML wrapped by
/// the layout shell: the page output is injected into the same context as
/// `content` and the layout template is rendered (consuming it with
/// `{{{ content }}}`). `None` renders the page directly, exactly as before.
fn render_page_with(
    root: &Path,
    name: &str,
    layout: Option<&str>,
    data: Option<&str>,
    params: &[(String, String)],
    extra: &[(String, Value)],
) -> Response {
    let engine = match build_engine(root) {
        Ok(engine) => engine,
        Err(msg) => return Response::new(500).with_html(&error_page(&msg)),
    };
    let mut context = load_context(root);
    if let Value::Object(pairs) = &mut context {
        // Declarative per-route data: spread an object's top-level keys at the
        // root (skipping the reserved `params`), then expose the whole document
        // under `data`. A non-object payload is exposed under `data` only.
        if let Some(rel) = data {
            if let Some(loaded) = load_route_data(root, rel) {
                if let Value::Object(loaded_pairs) = &loaded {
                    for (k, v) in loaded_pairs {
                        if k != "params" {
                            pairs.push((k.clone(), v.clone()));
                        }
                    }
                }
                pairs.push(("data".into(), loaded));
            }
        }
        pairs.push(("params".into(), routes::params_value(params)));
        pairs.extend(extra.iter().cloned());
    }

    // Render the page template (SPA-style fallback to `index` for unknown names).
    let page = match engine
        .render(name, &context)
        .or_else(|_| engine.render("index", &context))
    {
        Ok(html) => html,
        Err(e) => return Response::new(500).with_html(&error_page(&e.message)),
    };

    // No layout: the page output is the response. Otherwise wrap it in the shell.
    let Some(layout) = layout else {
        return Response::ok().with_html(&page);
    };
    if let Value::Object(pairs) = &mut context {
        pairs.push(("content".into(), Value::Str(page)));
    }
    match engine.render(layout, &context) {
        Ok(html) => Response::ok().with_html(&html),
        Err(e) => Response::new(500).with_html(&error_page(&e.message)),
    }
}

/// Build an engine from every `*.html` file in `root`, keyed by file stem.
pub(crate) fn build_engine(root: &Path) -> Result<Engine, String> {
    let mut engine = Engine::new();
    let entries = fs::read_dir(root).map_err(|e| format!("cannot read {}: {e}", root.display()))?;
    for entry in entries {
        let path = entry.map_err(|e| e.to_string())?.path();
        if path.extension().and_then(|e| e.to_str()) != Some("html") {
            continue;
        }
        let stem = match path.file_stem().and_then(|s| s.to_str()) {
            Some(s) => s.to_string(),
            None => continue,
        };
        let source = fs::read_to_string(&path).map_err(|e| e.to_string())?;
        engine
            .register(&stem, &source)
            .map_err(|e| format!("{stem}.html: {}", e.message))?;
    }
    Ok(engine)
}

/// Load the page context from `<project>/backend/page.json`, or an empty object
/// if it is missing or unparseable.
pub(crate) fn load_context(root: &Path) -> Value {
    let empty = Value::Object(vec![]);
    let Some(path) = root.parent().map(|p| p.join("backend/page.json")) else {
        return empty;
    };
    match fs::read_to_string(&path) {
        Ok(text) => akurai_json::parse(&text).unwrap_or(empty),
        Err(_) => empty,
    }
}

/// Load and parse a per-route data file named `rel`, resolved under the
/// project's `backend/` directory. Returns `None` if the path escapes `backend/`
/// (any `..`/`.` segment), the file is missing, or the JSON is unparseable —
/// rendering then proceeds with the data simply absent, never a crash. The
/// returned [`Value`] is merged into the render context by [`render_page_with`].
fn load_route_data(root: &Path, rel: &str) -> Option<Value> {
    // Reject traversal: every segment must be a plain name within `backend/`.
    let rel = rel.trim_start_matches('/');
    if rel.is_empty() || rel.split('/').any(|seg| seg == ".." || seg == ".") {
        return None;
    }
    let path = root.parent()?.join("backend").join(rel);
    let text = fs::read_to_string(&path).ok()?;
    akurai_json::parse(&text).ok()
}

pub(crate) fn error_page(message: &str) -> String {
    let mut escaped = String::new();
    for c in message.chars() {
        match c {
            '<' => escaped.push_str("&lt;"),
            '>' => escaped.push_str("&gt;"),
            '&' => escaped.push_str("&amp;"),
            c => escaped.push(c),
        }
    }
    format!(
        "<!doctype html><meta charset=utf-8><title>Template error</title>\
         <body style=\"font:16px system-ui;background:#0a0c11;color:#ff6b6b;padding:40px\">\
         <h1>Template error</h1><pre style=\"color:#e7ebf3\">{escaped}</pre></body>"
    )
}

/// Map a request path to a file under `root`, rejecting traversal (`..`).
fn resolve(root: &Path, req_path: &str) -> Option<PathBuf> {
    let rel = req_path.trim_start_matches('/');
    let rel = if rel.is_empty() { "index.html" } else { rel };
    if rel.split('/').any(|seg| seg == ".." || seg == ".") {
        return None;
    }
    Some(root.join(rel))
}

fn read_file(path: &Path) -> Response {
    match fs::read(path) {
        Ok(bytes) => {
            let ct = mime::for_path(&path.to_string_lossy());
            Response::ok().with_body(ct, bytes)
        }
        Err(_) => Response::not_found(),
    }
}

/// Serialize a JSON value into a response with the given status.
fn json(status: u16, value: &Value) -> Response {
    Response::new(status).with_body(
        "application/json; charset=utf-8",
        value.to_json().into_bytes(),
    )
}

fn health() -> Value {
    Value::Object(vec![
        ("framework".into(), Value::Str("AkurAI-Framework".into())),
        ("version".into(), Value::Str(VERSION.into())),
        ("status".into(), Value::Str("ok".into())),
    ])
}

/// A minimal self-describing manifest. The `collections` key carries the live
/// auto-API schema (the array from [`akurai_collections::meta`]), so it grows
/// automatically as `backend/collections.toml` declares more collections.
fn meta_manifest(collections: &[akurai_collections::Collection]) -> Value {
    let route = |method: &str, path: &str| {
        Value::Object(vec![
            ("method".into(), Value::Str(method.into())),
            ("path".into(), Value::Str(path.into())),
        ])
    };
    // `meta` returns `{ "collections": [...] }`; lift the array out so it nests
    // under our own `collections` key (not double-wrapped).
    let collections_value = akurai_collections::meta(collections)
        .get("collections")
        .cloned()
        .unwrap_or(Value::Array(vec![]));
    Value::Object(vec![
        ("framework".into(), Value::Str("AkurAI-Framework".into())),
        (
            "routes".into(),
            Value::Array(vec![
                route("GET", "/api/health"),
                route("GET", "/api/_meta"),
            ]),
        ),
        ("collections".into(), collections_value),
    ])
}

#[cfg(test)]
mod tests {
    use super::*;
    use akurai_http::Request;

    fn req(path: &str) -> Request {
        Request::parse_head(format!("GET {path} HTTP/1.1\r\n\r\n").as_bytes()).unwrap()
    }

    /// Scratch base: `CARGO_TARGET_TMPDIR` when present, else this crate's own
    /// `target/` — never `/tmp`. Mirrors the helper in `collections_api.rs`.
    fn scratch_base() -> PathBuf {
        match std::env::var_os("CARGO_TARGET_TMPDIR") {
            Some(dir) => PathBuf::from(dir),
            None => PathBuf::from(env!("CARGO_MANIFEST_DIR"))
                .join("target")
                .join("serve-test-tmp"),
        }
    }

    /// An empty asset state (no utilities, no fingerprint manifest) — the shape
    /// dev uses and a fine default for routing tests.
    fn empty_assets() -> AssetState {
        AssetState::build(Path::new("nonexistent"), true)
    }

    fn header(r: &Response, name: &str) -> Option<String> {
        r.headers
            .iter()
            .find(|(k, _)| k.eq_ignore_ascii_case(name))
            .map(|(_, v)| v.clone())
    }

    #[test]
    fn health_returns_version_json() {
        let root = Path::new("nonexistent");
        let r = route(root, &[], &empty_assets(), &req("/api/health"));
        let body = String::from_utf8(r.body.clone()).unwrap();
        assert_eq!(r.status, 200);
        assert!(body.contains("\"version\":\"") && body.contains(VERSION));
        assert!(body.contains("\"status\":\"ok\""));
    }

    #[test]
    fn unknown_api_route_is_404() {
        let r = route(
            Path::new("."),
            &[],
            &empty_assets(),
            &req("/api/does-not-exist"),
        );
        assert_eq!(r.status, 404);
    }

    #[test]
    fn utilities_css_is_served() {
        // A frontend referencing utility classes yields a non-empty utilities.css
        // at /utilities.css with the right content type.
        let dir = scratch_base().join("serve_utilities");
        let _ = fs::remove_dir_all(&dir);
        fs::create_dir_all(&dir).unwrap();
        fs::write(dir.join("index.html"), r#"<div class="flex p-2">hi</div>"#).unwrap();

        let assets = AssetState::build(&dir, false);
        let reply = dispatch(
            &dir,
            &guestbook::State::new(store::Store::open(dir.join("s.db")).unwrap()),
            &auth::State::open(&dir).unwrap(),
            &CollectionsApi::open(&dir).unwrap(),
            &assets,
            &req("/utilities.css"),
        );
        let Reply::Response(r) = reply else {
            panic!("expected a buffered response")
        };
        assert_eq!(r.status, 200);
        assert_eq!(
            header(&r, "Content-Type").as_deref(),
            Some("text/css; charset=utf-8")
        );
        let body = String::from_utf8(r.body).unwrap();
        assert!(body.contains(".flex{display:flex}"), "got: {body}");
    }

    #[test]
    fn fingerprinted_asset_gets_immutable_cache() {
        // A fingerprinted static request resolves from the manifest with the
        // immutable cache header; the plain path still resolves off disk.
        let dir = scratch_base().join("serve_fingerprint");
        let _ = fs::remove_dir_all(&dir);
        fs::create_dir_all(&dir).unwrap();
        fs::write(dir.join("styles.css"), b"a {\n  color: red;\n}\n").unwrap();

        let assets = AssetState::build(&dir, false);
        let hashed = assets.manifest.to_json().to_json(); // JSON string contains the hashed path
        assert!(hashed.contains("/styles.css"));

        // Resolve the hashed path from the manifest and request it.
        let hashed_path = {
            // Pull the hashed value out of the manifest JSON object directly.
            let Value::Object(pairs) = assets.manifest.to_json() else {
                panic!("manifest is an object")
            };
            let (_, v) = pairs
                .into_iter()
                .find(|(k, _)| k == "/styles.css")
                .expect("styles.css in manifest");
            match v {
                Value::Str(s) => s,
                _ => panic!("hashed path is a string"),
            }
        };

        let r = assets.serve_static(&dir, &hashed_path);
        assert_eq!(r.status, 200);
        assert_eq!(
            header(&r, "Cache-Control").as_deref(),
            Some(asset_pipeline::IMMUTABLE_CACHE)
        );
        assert_eq!(
            header(&r, "Content-Type").as_deref(),
            Some("text/css; charset=utf-8")
        );
        // Minified bytes are what gets served.
        assert_eq!(String::from_utf8(r.body).unwrap(), "a{color:red}");

        // Backward compatible: the plain path resolves the raw file, no immutable.
        let plain = assets.serve_static(&dir, "/styles.css");
        assert_eq!(plain.status, 200);
        assert!(header(&plain, "Cache-Control").is_none());
    }

    #[test]
    fn dev_utilities_regenerate_per_request() {
        // In dev (empty AssetState), utilities are regenerated from disk each call,
        // so an edit between requests is reflected without a restart.
        let dir = scratch_base().join("serve_dev_regen");
        let _ = fs::remove_dir_all(&dir);
        fs::create_dir_all(&dir).unwrap();
        fs::write(dir.join("index.html"), r#"<div class="flex">x</div>"#).unwrap();

        let assets = AssetState::build(&dir, true); // dev: utilities = None
        let first = assets.utilities_css(&dir);
        assert!(first.contains(".flex{display:flex}"));
        assert!(!first.contains("text-center"));

        fs::write(
            dir.join("index.html"),
            r#"<div class="flex text-center">x</div>"#,
        )
        .unwrap();
        let second = assets.utilities_css(&dir);
        assert!(
            second.contains(".text-center{text-align:center}"),
            "got: {second}"
        );
    }

    #[test]
    fn rejects_path_traversal() {
        assert!(resolve(Path::new("/srv/app"), "/../../etc/passwd").is_none());
        assert!(resolve(Path::new("/srv/app"), "/ok/file.js").is_some());
    }

    #[test]
    fn root_maps_to_index() {
        let p = resolve(Path::new("/srv/app"), "/").unwrap();
        assert!(p.ends_with("index.html"));
    }

    #[test]
    fn route_data_rejects_traversal() {
        // Escaping `backend/` is refused; an empty name is too.
        assert!(load_route_data(Path::new("/srv/app/frontend"), "../secrets.json").is_none());
        assert!(load_route_data(Path::new("/srv/app/frontend"), "a/../../b.json").is_none());
        assert!(load_route_data(Path::new("/srv/app/frontend"), "").is_none());
        // A safe, non-existent name simply yields None (no panic).
        assert!(load_route_data(Path::new("/srv/app/frontend"), "data/missing.json").is_none());
    }
}