nomoreide-daemon 0.6.0

The NoMoreIDE daemon: the local HTTP server, its route registry, and the embedded web dashboard.
Documentation
//! Bakes the built dashboard into the binary.
//!
//! The daemon used to find its dashboard only on disk, which made the *layout*
//! of an install load-bearing: `asset_roots()` looks under
//! `<exe dir>/../share/nomoreide/web/client`, and an archive that unpacked one
//! level off served every page as a 500. That class of bug cannot happen to a
//! file that ships inside the executable.
//!
//! It is also what a `cargo install nomoreide-cli` needs. crates.io runs no
//! npm, so a crate that expects `dist/web/client` beside it installs a daemon
//! with no UI; embedding turns the dashboard into crate content.
//!
//! Where the files come from, first match winning:
//!
//! 1. `NOMOREIDE_EMBED_WEB_ROOT` — an explicit override, for a packager who
//!    builds the client somewhere this cannot guess.
//! 2. `dist/web/client`, walking up from this crate to the workspace root —
//!    the normal case in a checkout, right after `npm run build`.
//! 3. `<crate>/web-client` — the vendored copy, which only a published crate
//!    reaches, there being no `dist/` above it. `scripts/vendor-crate-assets.mjs`
//!    writes it before `cargo publish`, and `include` in Cargo.toml is what
//!    carries it into the package past its gitignore.
//!
//! Finding nothing is not an error. A Rust-only contributor who has never run
//! `npm run build` still gets a daemon that compiles, serves its whole API, and
//! reports the dashboard as missing exactly the way it did before.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

fn main() {
    println!("cargo:rerun-if-env-changed=NOMOREIDE_EMBED_WEB_ROOT");

    let out = PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR is set by cargo"));
    let generated = out.join("embedded_dashboard.rs");

    let root = locate_client();
    let mut files = BTreeMap::new();
    if let Some(root) = root.as_deref() {
        // Watch the directory itself as well as its contents, so a rebuild
        // that adds or removes a content-hashed asset is noticed rather than
        // leaving the old name embedded.
        println!("cargo:rerun-if-changed={}", root.display());
        collect(root, root, &mut files);
    }

    if files.is_empty() {
        println!(
            "cargo:warning=no built dashboard found; \
             this binary will serve the API but no UI. Run `npm run build`."
        );
    }

    let mut source = String::from(
        "// @generated by build.rs — the dashboard, as it stood at compile time.\n\
         pub(crate) static EMBEDDED_DASHBOARD: &[(&str, &[u8])] = &[\n",
    );
    for (relative, absolute) in &files {
        // `include_bytes!` takes a literal, and these paths are ours rather
        // than a user's, but a path with a quote or a backslash in it would
        // otherwise produce source that does not parse.
        source.push_str(&format!(
            "    ({}, include_bytes!({})),\n",
            escape(relative),
            escape(&absolute.to_string_lossy())
        ));
    }
    source.push_str("];\n");

    std::fs::write(&generated, source).expect("writing the generated asset table");
}

/// The built client, or `None` when this tree has never built one.
fn locate_client() -> Option<PathBuf> {
    if let Some(configured) = std::env::var_os("NOMOREIDE_EMBED_WEB_ROOT") {
        let path = PathBuf::from(configured);
        // An override that points nowhere is a packaging mistake, and silently
        // shipping a UI-less binary is exactly what it must not do.
        assert!(
            path.join("index.html").is_file(),
            "NOMOREIDE_EMBED_WEB_ROOT={} holds no index.html",
            path.display()
        );
        return Some(path);
    }

    let manifest = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR")?);

    // `dist/` first, so a checkout always compiles what `npm run build` last
    // wrote. The vendored copy is deliberately second: it is a publish
    // artefact, and if it won here it would go stale the moment someone
    // rebuilt the dashboard without re-vendoring. A *published* crate has no
    // `dist/` above it, so the fallback is what it gets.
    let mut current = Some(manifest.as_path());
    while let Some(directory) = current {
        let candidate = directory.join("dist/web/client");
        if candidate.join("index.html").is_file() {
            return Some(candidate);
        }
        current = directory.parent();
    }

    let vendored = manifest.join("web-client");
    if vendored.join("index.html").is_file() {
        return Some(vendored);
    }
    None
}

/// Every file under `root`, keyed by its slash-separated path relative to it —
/// which is the shape a request arrives in.
fn collect(root: &Path, directory: &Path, files: &mut BTreeMap<String, PathBuf>) {
    let Ok(entries) = std::fs::read_dir(directory) else {
        return;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path.is_dir() {
            println!("cargo:rerun-if-changed={}", path.display());
            collect(root, &path, files);
        } else if let Ok(relative) = path.strip_prefix(root) {
            println!("cargo:rerun-if-changed={}", path.display());
            let key = relative
                .components()
                .map(|component| component.as_os_str().to_string_lossy())
                .collect::<Vec<_>>()
                .join("/");
            files.insert(key, path.clone());
        }
    }
}

fn escape(value: &str) -> String {
    format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
}