defter 0.3.5

Self-hosted Zotero sync and file storage server, with metadata in SQLite and attachments in S3.
//! Build the web UI's two assets and embed them in the binary:
//!
//! - the stylesheet, `ui/dist/app.css`, compiled from `ui/app.css` by the
//!   standalone Tailwind CLI, which scans `templates/` for classes. The built
//!   file is committed, so building defter needs only Rust; Tailwind (e.g.
//!   `brew install tailwindcss`, or `$TAILWINDCSS`) is needed only to change
//!   the templates' styling;
//! - the script: the vendored htmx followed by `ui/main.js`.
//!
//! Each is served under a name carrying its content hash, so browsers can
//! cache it for good. `$OUT_DIR/ui_assets.rs` holds both and their URLs.

use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::SystemTime;

const STYLESHEET_SOURCE: &str = "ui/app.css";
const STYLESHEET: &str = "ui/dist/app.css";
const HTMX: &str = "ui/vendor/htmx-2.0.11.min.js";
const SCRIPT: &str = "ui/main.js";
const TEMPLATES: &str = "templates";

fn main() {
    let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
    let out = PathBuf::from(std::env::var("OUT_DIR").unwrap());
    for path in [STYLESHEET_SOURCE, STYLESHEET, HTMX, SCRIPT, TEMPLATES] {
        println!("cargo:rerun-if-changed={path}");
    }
    println!("cargo:rerun-if-env-changed=TAILWINDCSS");

    build_stylesheet(&manifest, &out);

    let stylesheet = read(&manifest.join(STYLESHEET));
    let mut script = read(&manifest.join(HTMX));
    script.extend_from_slice(b"\n;\n");
    script.extend_from_slice(&read(&manifest.join(SCRIPT)));
    let script_path = out.join("app.js");
    write(&script_path, &script);

    let css_name = hashed_name("app", "css", &stylesheet);
    let js_name = hashed_name("app", "js", &script);
    let generated = format!(
        "pub const STYLESHEET: &str = \"/assets/{css_name}\";\n\
         pub const SCRIPT: &str = \"/assets/{js_name}\";\n\
         pub static UI_ASSETS: &[(&str, &[u8])] = &[\n    \
         ({css_name:?}, include_bytes!({:?})),\n    \
         ({js_name:?}, include_bytes!({:?})),\n];\n",
        manifest.join(STYLESHEET).display().to_string(),
        script_path.display().to_string(),
    );
    write(&out.join("ui_assets.rs"), generated.as_bytes());
}

/// Compile the stylesheet with Tailwind when it is installed. Without it, the
/// committed `ui/dist/app.css` is used, with a warning when the templates or
/// the source look newer.
fn build_stylesheet(manifest: &Path, out: &Path) {
    let built = manifest.join(STYLESHEET);
    // A packaged crate (from crates.io, or `cargo publish` verifying one)
    // ships the built stylesheet and must not change its sources. Cargo adds
    // Cargo.toml.orig to every package.
    if manifest.join("Cargo.toml.orig").exists() {
        return;
    }
    let Some(tailwind) = find_tailwind() else {
        if !built.exists() {
            panic!(
                "{STYLESHEET} is missing and the Tailwind CLI was not found; install it \
                 (e.g. `brew install tailwindcss`) or set TAILWINDCSS to its path"
            );
        }
        let sources = newest([manifest.join(STYLESHEET_SOURCE), manifest.join(TEMPLATES)]);
        if sources > modified(&built) {
            println!(
                "cargo:warning=the Tailwind CLI was not found, so {STYLESHEET} was not rebuilt \
                 and may lack classes the templates use; install it (e.g. `brew install \
                 tailwindcss`) or set TAILWINDCSS"
            );
        }
        return;
    };

    let fresh = out.join("app.css");
    let output = Command::new(&tailwind)
        .current_dir(manifest)
        .args(["--input", STYLESHEET_SOURCE, "--output"])
        .arg(&fresh)
        .arg("--minify")
        .output()
        .unwrap_or_else(|e| panic!("running {}: {e}", tailwind.display()));
    if !output.status.success() {
        panic!(
            "{} failed:\n{}",
            tailwind.display(),
            String::from_utf8_lossy(&output.stderr)
        );
    }
    // Write only a changed stylesheet: rewriting an unchanged one would make
    // the next build rerun this script for nothing.
    let fresh = read(&fresh);
    if std::fs::read(&built).ok().as_deref() != Some(fresh.as_slice()) {
        if let Some(dir) = built.parent() {
            std::fs::create_dir_all(dir).unwrap();
        }
        write(&built, &fresh);
    }
}

/// `$TAILWINDCSS`, or `tailwindcss` on the PATH or in Homebrew's prefixes.
fn find_tailwind() -> Option<PathBuf> {
    if let Some(path) = std::env::var_os("TAILWINDCSS").filter(|p| !p.is_empty()) {
        return Some(PathBuf::from(path));
    }
    let path = std::env::var_os("PATH").unwrap_or_default();
    std::env::split_paths(&path)
        .chain(["/opt/homebrew/bin", "/usr/local/bin"].map(PathBuf::from))
        .map(|dir| dir.join("tailwindcss"))
        .find(|candidate| candidate.is_file())
}

/// `<stem>-<first 10 hex digits of the md5>.<extension>`.
fn hashed_name(stem: &str, extension: &str, bytes: &[u8]) -> String {
    let hash = format!("{:x}", md5::compute(bytes));
    format!("{stem}-{}.{extension}", &hash[..10])
}

fn modified(path: &Path) -> SystemTime {
    std::fs::metadata(path)
        .and_then(|m| m.modified())
        .unwrap_or(SystemTime::UNIX_EPOCH)
}

/// The latest modification time of these files and the files in these
/// directories (one level deep, as the templates are).
fn newest(paths: impl IntoIterator<Item = PathBuf>) -> SystemTime {
    paths
        .into_iter()
        .flat_map(|path| {
            let mut times = vec![modified(&path)];
            if let Ok(entries) = std::fs::read_dir(&path) {
                times.extend(entries.flatten().map(|e| modified(&e.path())));
            }
            times
        })
        .max()
        .unwrap_or(SystemTime::UNIX_EPOCH)
}

fn read(path: &Path) -> Vec<u8> {
    std::fs::read(path).unwrap_or_else(|e| panic!("reading {}: {e}", path.display()))
}

fn write(path: &Path, bytes: &[u8]) {
    std::fs::write(path, bytes).unwrap_or_else(|e| panic!("writing {}: {e}", path.display()));
}