zenops 0.20.0

Declarative system configuration management for shell config and dotfiles.
//! Documentation server: serves the embedded mdBook output over HTTP.
//!
//! Backs the `zenops docs` subcommand. Site bytes are baked at compile
//! time via [`include_dir!`], so the server runs offline — the only
//! network operation is the localhost bind and accept loop. The build
//! pipeline that populates the embedded directory lives in
//! `just docs-build` and the project's GitHub Pages workflow.

use std::process::Command;

use include_dir::{Dir, include_dir};
use tiny_http::{Header, Response, Server};

use crate::error::Error;

/// mdBook output baked into the binary. `just docs-build` populates this
/// directory; `cargo build` snapshots its current contents. A `build.rs`
/// ensures the directory exists so `cargo build` works even before
/// `mdbook build` has been run (the served site is just empty in that
/// case, and `run` short-circuits with an explanatory error).
static SITE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/docs/book/book");

/// Bind a localhost server on `port` (`0` = random ephemeral) and serve
/// the embedded site. If `open` is set, launch the bound URL in the
/// platform's default browser before blocking on the request loop.
pub fn run(port: u16, open: bool) -> Result<(), Error> {
    if SITE.get_file("index.html").is_none() {
        return Err(Error::DocsNotBuilt);
    }
    let bind = format!("127.0.0.1:{port}");
    let server = Server::http(&bind).map_err(|e| Error::DocsBindFailed {
        bind: bind.clone(),
        reason: e.to_string(),
    })?;
    let addr = server.server_addr();
    let url = format!("http://{addr}/");
    println!("Serving docs at {url}");
    println!("Press Ctrl-C to stop.");

    if open && let Err(e) = open_url(&url) {
        log::warn!("failed to open browser: {e}");
    }

    for req in server.incoming_requests() {
        if let Err(e) = handle_request(req) {
            log::warn!("request error: {e}");
        }
    }
    Ok(())
}

fn handle_request(req: tiny_http::Request) -> std::io::Result<()> {
    let url = req.url().to_string();
    // Drop query string; only the path matters for static-file routing.
    let raw_path = url
        .split('?')
        .next()
        .unwrap_or(&url)
        .trim_start_matches('/');
    let candidate = if raw_path.is_empty() || raw_path.ends_with('/') {
        format!("{raw_path}index.html")
    } else {
        raw_path.to_string()
    };

    // Try the exact path first, then fall back to `<path>/index.html` so
    // a directory URL without a trailing slash still resolves.
    let file = SITE
        .get_file(&candidate)
        .or_else(|| SITE.get_file(format!("{raw_path}/index.html")));

    match file {
        Some(f) => {
            let mime = mime_for(&candidate);
            let response = Response::from_data(f.contents()).with_header(
                Header::from_bytes(&b"Content-Type"[..], mime.as_bytes())
                    .expect("static mime header is well-formed"),
            );
            req.respond(response)
        }
        None => match SITE.get_file("404.html") {
            Some(page) => req.respond(
                Response::from_data(page.contents())
                    .with_status_code(404)
                    .with_header(
                        Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..])
                            .expect("static mime header is well-formed"),
                    ),
            ),
            None => {
                let body = format!("404 Not Found: /{raw_path}");
                req.respond(Response::from_string(body).with_status_code(404))
            }
        },
    }
}

fn mime_for(path: &str) -> &'static str {
    // Small, fixed table sized to mdBook's output. Anything mdBook doesn't
    // ship gets the conservative `octet-stream` fallback.
    match path.rsplit_once('.').map(|(_, ext)| ext) {
        Some("html") => "text/html; charset=utf-8",
        Some("js") => "application/javascript; charset=utf-8",
        Some("css") => "text/css; charset=utf-8",
        Some("json") => "application/json; charset=utf-8",
        Some("svg") => "image/svg+xml",
        Some("png") => "image/png",
        Some("woff") => "font/woff",
        Some("woff2") => "font/woff2",
        Some("ttf") => "font/ttf",
        Some("ico") => "image/x-icon",
        Some("txt") => "text/plain; charset=utf-8",
        _ => "application/octet-stream",
    }
}

fn open_url(url: &str) -> std::io::Result<()> {
    let cmd = if cfg!(target_os = "macos") {
        "open"
    } else if cfg!(target_os = "linux") {
        "xdg-open"
    } else {
        return Err(std::io::Error::new(
            std::io::ErrorKind::Unsupported,
            "no known URL opener for this OS",
        ));
    };
    Command::new(cmd).arg(url).spawn().map(|_| ())
}