treease-cli 1.0.0

Command-line interface for querying, transforming, and previewing structured documents with Treease
Documentation
use std::env;
use std::fs;
use std::path::{Path, PathBuf};

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

    let dist = resolve_web_dist();
    println!("cargo:rerun-if-changed={}", dist.display());

    let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR should be set"));
    let generated_path = out_dir.join("treease_web_assets.rs");

    if !dist.is_dir() {
        if env::var("TREEASE_REQUIRE_WEB_ASSETS").as_deref() == Ok("1") {
            panic!(
                "web assets are required but missing at {}. Run `cd apps/web && pnpm build` or set TREEASE_WEB_DIST.",
                dist.display()
            );
        }
        fs::write(&generated_path, unavailable_manifest())
            .expect("generated web asset manifest should be writable");
        return;
    }

    let dist = fs::canonicalize(&dist).unwrap_or_else(|error| {
        panic!(
            "failed to resolve web asset directory {}: {error}",
            dist.display()
        )
    });

    let mut assets = Vec::new();
    collect_assets(&dist, &dist, &mut assets);
    assets.sort_by(|left, right| left.route_path.cmp(&right.route_path));

    fs::write(&generated_path, available_manifest(&assets))
        .expect("generated web asset manifest should be writable");
}

fn resolve_web_dist() -> PathBuf {
    if let Some(dist) = env::var_os("TREEASE_WEB_DIST") {
        return PathBuf::from(dist);
    }

    let embedded = PathBuf::from("embedded-web");
    if embedded.is_dir() {
        return embedded;
    }

    PathBuf::from("../../apps/web/build")
}

#[derive(Debug)]
struct AssetEntry {
    route_path: String,
    content_type: &'static str,
    source_path: PathBuf,
}

fn collect_assets(root: &Path, dir: &Path, assets: &mut Vec<AssetEntry>) {
    let entries = fs::read_dir(dir).unwrap_or_else(|error| {
        panic!(
            "failed to read web asset directory {}: {error}",
            dir.display()
        )
    });

    for entry in entries {
        let entry = entry.unwrap_or_else(|error| {
            panic!(
                "failed to read web asset entry under {}: {error}",
                dir.display()
            )
        });
        let path = entry.path();
        let file_type = entry.file_type().unwrap_or_else(|error| {
            panic!("failed to inspect web asset {}: {error}", path.display())
        });

        if file_type.is_dir() {
            if is_hidden_path(&path) {
                continue;
            }
            collect_assets(root, &path, assets);
        } else if file_type.is_file() {
            if is_hidden_path(&path) {
                continue;
            }
            let relative_path = path
                .strip_prefix(root)
                .expect("asset path should be under web dist root");
            let route_path = format!("/{}", relative_path.to_string_lossy().replace('\\', "/"));
            assets.push(AssetEntry {
                content_type: content_type_for(&path),
                route_path,
                source_path: path,
            });
        }
    }
}

fn unavailable_manifest() -> String {
    [
        "pub(super) const WEB_ASSETS_AVAILABLE: bool = false;\n",
        "pub(super) static WEB_ASSETS: &[EmbeddedAsset] = &[];\n",
    ]
    .concat()
}

fn available_manifest(assets: &[AssetEntry]) -> String {
    let mut generated = String::from("pub(super) const WEB_ASSETS_AVAILABLE: bool = true;\n");
    generated.push_str("pub(super) static WEB_ASSETS: &[EmbeddedAsset] = &[\n");
    for asset in assets {
        generated.push_str("    EmbeddedAsset {\n");
        generated.push_str(&format!("        path: {:?},\n", asset.route_path));
        generated.push_str(&format!(
            "        content_type: {:?},\n",
            asset.content_type
        ));
        generated.push_str(&format!(
            "        bytes: include_bytes!({:?}),\n",
            asset.source_path.display().to_string()
        ));
        generated.push_str("    },\n");
    }
    generated.push_str("];\n");
    generated
}

fn is_hidden_path(path: &Path) -> bool {
    path.file_name()
        .and_then(|name| name.to_str())
        .is_some_and(|name| name.starts_with('.'))
}

fn content_type_for(path: &Path) -> &'static str {
    match path.extension().and_then(|extension| extension.to_str()) {
        Some("html") => "text/html; charset=utf-8",
        Some("js") => "text/javascript; charset=utf-8",
        Some("css") => "text/css; charset=utf-8",
        Some("wasm") => "application/wasm",
        Some("json") => "application/json; charset=utf-8",
        Some("svg") => "image/svg+xml",
        Some("png") => "image/png",
        Some("ico") => "image/x-icon",
        _ => "application/octet-stream",
    }
}