autumn-cli 0.6.0

CLI tool for the Autumn web framework
fn main() {
    println!("cargo:rerun-if-changed=src/");

    // Bake build + git provenance into the binary so `/actuator/info` can
    // report exactly which commit/build is running (deploy/rollback checks).
    // The JSON-first API scaffold has no Tailwind/CSS compilation step.
    emit_build_provenance();
}

/// Emit `AUTUMN_BUILD_*` compile-time env vars consumed by
/// `#[autumn_web::main]` and surfaced on `/actuator/info`.
///
/// Provenance is sourced with a two-tier strategy so both local checkouts and
/// containerized (production) builds report real values:
///
/// 1. **Build-arg passthrough** — if the corresponding `AUTUMN_BUILD_*` env var
///    is set (e.g. Docker `--build-arg` surfaced as `ENV`), it wins. The
///    scaffolded Dockerfile deliberately excludes `/.git` from the build
///    context, so a container build has no repository to inspect; the deploy/CI
///    supplies the SHA/branch/etc. explicitly instead.
/// 2. **Git fallback** — otherwise fields are best-effort from `git`. Outside a
///    checkout (tarball / CI cache with no passthrough) they are simply omitted
///    and the framework reports them as `null` rather than failing the build.
///
/// The build timestamp is always emitted (passthrough `AUTUMN_BUILD_TIMESTAMP`
/// wins, else `SOURCE_DATE_EPOCH` for reproducible builds, else wall clock).
///
/// The `dirty` flag is three-state: an explicit `AUTUMN_BUILD_GIT_DIRTY` build
/// arg wins; else the real `git status` (a *clean* tree is `false`); else — no
/// arg and no git — it is genuinely UNKNOWN and no var is emitted, so
/// `/actuator/info` reports `null` instead of a misleading `false`.
///
/// Note: the git-derived `dirty` flag is a best-effort snapshot taken when this
/// script last re-ran. Cargo only re-runs it on the declared `rerun-if-changed`
/// inputs, so after an edit that doesn't touch a watched path the baked flag may
/// lag until the next rebuild of a watched input.
fn emit_build_provenance() {
    // Re-run whenever a passthrough build arg changes so a new deploy re-bakes
    // the provenance instead of reusing a cached, stale value.
    for var in [
        "AUTUMN_BUILD_GIT_SHA",
        "AUTUMN_BUILD_GIT_SHA_SHORT",
        "AUTUMN_BUILD_GIT_BRANCH",
        "AUTUMN_BUILD_GIT_DIRTY",
        "AUTUMN_BUILD_TIMESTAMP",
    ] {
        println!("cargo:rerun-if-env-changed={var}");
    }

    let timestamp = build_arg("AUTUMN_BUILD_TIMESTAMP").unwrap_or_else(build_timestamp);
    println!("cargo:rustc-env=AUTUMN_BUILD_TIMESTAMP={timestamp}");

    // Prefer the passthrough SHA (container build); fall back to git (local
    // checkout). If neither yields a SHA, emit no git fields → reported `null`.
    if let Some(sha) = build_arg("AUTUMN_BUILD_GIT_SHA").or_else(|| git(&["rev-parse", "HEAD"])) {
        let short = build_arg("AUTUMN_BUILD_GIT_SHA_SHORT")
            .or_else(|| git(&["rev-parse", "--short", "HEAD"]))
            .unwrap_or_else(|| sha.chars().take(7).collect());
        let branch = build_arg("AUTUMN_BUILD_GIT_BRANCH")
            .or_else(|| git(&["rev-parse", "--abbrev-ref", "HEAD"]))
            .unwrap_or_else(|| "unknown".into());
        // Dirty is a THREE-state value: an explicit passthrough arg wins;
        // otherwise the real `git status` result (a *clean* tree is `false`, not
        // unknown); otherwise — no arg AND no git, the common case for a
        // container build with `/.git` excluded — the dirty state is genuinely
        // UNKNOWN. In that case emit no `AUTUMN_BUILD_GIT_DIRTY` at all, so
        // `/actuator/info` reports `null` rather than a misleading `false`.
        let dirty =
            build_arg("AUTUMN_BUILD_GIT_DIRTY").or_else(|| git_dirty().map(|d| d.to_string()));
        println!("cargo:rustc-env=AUTUMN_BUILD_GIT_SHA={sha}");
        println!("cargo:rustc-env=AUTUMN_BUILD_GIT_SHA_SHORT={short}");
        println!("cargo:rustc-env=AUTUMN_BUILD_GIT_BRANCH={branch}");
        if let Some(dirty) = dirty {
            println!("cargo:rustc-env=AUTUMN_BUILD_GIT_DIRTY={dirty}");
        }
    }

    // Re-run when the ref is checked out (HEAD), the index changes (staging /
    // index), or HEAD *moves* — commit, `--amend`, `reset --soft` all rewrite
    // `logs/HEAD` even when `HEAD` itself is untouched.
    emit_git_rerun_triggers();

    // Reproducible builds: re-run if the source date is pinned or changed.
    println!("cargo:rerun-if-env-changed=SOURCE_DATE_EPOCH");
}

/// Read a provenance build arg passed through the environment (Docker
/// `ARG`/`ENV`), trimming surrounding whitespace. Unset or blank values return
/// `None` so detection falls through to git — an unpopulated `ARG` surfaces as
/// an empty string, which must not shadow the git fallback.
fn build_arg(name: &str) -> Option<String> {
    let value = std::env::var(name).ok()?;
    let trimmed = value.trim();
    if trimmed.is_empty() {
        None
    } else {
        Some(trimmed.to_string())
    }
}

/// Register `rerun-if-changed` triggers on the git ref state so a commit /
/// amend / reset re-runs this script and re-bakes the SHA and dirty flag.
///
/// The gitdir is resolved by asking git itself (`git rev-parse --git-dir`)
/// rather than looking for a `.git` entry in the package root. That matters for
/// apps generated *inside* an existing repo/monorepo: the package root has no
/// `.git`, but `git` still resolves the parent checkout — so we must register
/// the parent's `HEAD`/`logs/HEAD`/`index`, or Cargo would never re-run after a
/// commit and `/actuator/info` would report a stale SHA.
///
/// `--git-common-dir` covers linked worktrees, where per-worktree state lives
/// in `--git-dir` but shared history (`logs/HEAD`) lives in the common dir. In
/// a linked worktree a `commit --amend`/`reset` rewrites the *per-worktree*
/// reflog (`<git-dir>/logs/HEAD`) while leaving the common-dir reflog untouched,
/// so both reflogs are watched (deduped for a normal, non-worktree checkout).
///
/// Graceful degradation: if git is absent (tarball / CI cache), `git rev-parse`
/// fails, we register no triggers, and the build still succeeds.
fn emit_git_rerun_triggers() {
    // git prints paths relative to the current dir; resolve them against the
    // crate dir so they are valid regardless of Cargo's working directory.
    let crate_dir = std::env::var_os("CARGO_MANIFEST_DIR")
        .map(std::path::PathBuf::from)
        .unwrap_or_default();
    let resolve = |flag: &str| -> Option<std::path::PathBuf> {
        let raw = git(&["rev-parse", flag])?;
        let path = std::path::PathBuf::from(&raw);
        Some(if path.is_absolute() {
            path
        } else {
            crate_dir.join(path)
        })
    };

    let Some(git_dir) = resolve("--git-dir") else {
        return;
    };
    // Linked worktrees keep shared `logs/HEAD` in the common dir; when there is
    // no separate common dir this is the same as `git_dir`.
    let common_dir = resolve("--git-common-dir").unwrap_or_else(|| git_dir.clone());

    let mut seen: Vec<std::path::PathBuf> = Vec::new();
    for path in [
        git_dir.join("HEAD"),
        git_dir.join("index"),
        // Per-worktree reflog: `--amend`/`reset` in a linked worktree rewrites
        // `<git-dir>/logs/HEAD` but leaves the shared common-dir reflog
        // untouched, so watch both. Deduped when git_dir == common_dir.
        git_dir.join("logs/HEAD"),
        common_dir.join("logs/HEAD"),
    ] {
        if seen.contains(&path) {
            continue; // dedupe when git_dir == common_dir
        }
        if path.exists() {
            println!("cargo:rerun-if-changed={}", path.display());
        }
        seen.push(path);
    }
}

/// Best-effort working-tree dirtiness, distinguishing *clean* from *unknown*.
///
/// Returns `Some(true)`/`Some(false)` only when `git status` actually ran — a
/// clean tree is `Some(false)`, uncommitted changes are `Some(true)`. Returns
/// `None` when git is absent or the command fails (e.g. a container build with
/// `/.git` excluded), so the caller can represent an *unknown* dirty state as
/// absent (`/actuator/info` → `null`) rather than a misleading `false`.
///
/// This cannot reuse `git()`, which collapses empty stdout to `None` and would
/// make a clean tree indistinguishable from git being unavailable.
fn git_dirty() -> Option<bool> {
    let output = std::process::Command::new("git")
        .args(["status", "--porcelain"])
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    let text = String::from_utf8(output.stdout).ok()?;
    Some(!text.trim().is_empty())
}

/// Run a git subcommand, returning trimmed stdout on success.
fn git(args: &[&str]) -> Option<String> {
    let output = std::process::Command::new("git").args(args).output().ok()?;
    if !output.status.success() {
        return None;
    }
    let text = String::from_utf8(output.stdout).ok()?.trim().to_string();
    if text.is_empty() { None } else { Some(text) }
}

/// Build timestamp as ISO-8601 UTC. Honors `SOURCE_DATE_EPOCH` (a Unix seconds
/// count) when set so reproducible builds get a deterministic timestamp;
/// otherwise falls back to the current wall-clock time.
fn build_timestamp() -> String {
    let secs = std::env::var("SOURCE_DATE_EPOCH")
        .ok()
        .and_then(|value| value.trim().parse::<u64>().ok())
        .unwrap_or_else(unix_now_secs);
    iso8601_utc(secs)
}

/// Current time as whole Unix seconds (0 if the clock predates the epoch).
fn unix_now_secs() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

/// Format Unix `secs` as ISO-8601 UTC (`YYYY-MM-DDTHH:MM:SSZ`), computed with
/// no external crates via the civil-from-days algorithm.
fn iso8601_utc(secs: u64) -> String {
    let days = (secs / 86_400) as i64;
    let rem = secs % 86_400;
    let (hh, mm, ss) = (rem / 3600, (rem % 3600) / 60, rem % 60);

    // Howard Hinnant's civil_from_days, epoch 1970-01-01.
    let z = days + 719_468;
    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
    let doe = z - era * 146_097; // [0, 146096]
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
    let year = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
    let mp = (5 * doy + 2) / 153; // [0, 11]
    let day = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
    let month = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
    let year = if month <= 2 { year + 1 } else { year };

    format!("{year:04}-{month:02}-{day:02}T{hh:02}:{mm:02}:{ss:02}Z")
}