nornir 0.5.0

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! Codeberg Pages publishing — assemble (and force-push) an orphan `pages`
//! branch that serves a repo's rendered `docs/book.pdf` at
//! `https://nordisk.codeberg.page/<repo>/`.
//!
//! The branch holds EXACTLY three files — no source, no history carry-over:
//!
//! ```text
//! .nojekyll     (empty marker — tells Codeberg Pages to skip Jekyll)
//! book.pdf      (a copy of <repo>/docs/book.pdf)
//! index.html    (a dark, full-viewport PDF-embed viewer)
//! ```
//!
//! This mirrors the layout of the hand-built `pages` branches already live for
//! `nornir`, `knut`, `znippy-zoomies` and `Skidbladnir`, so re-publishing any
//! repo through this path is byte-for-byte consistent with them.
//!
//! ## Pure-Rust, no push (nornir ethos)
//!
//! The tree + orphan commit are built **in-process via `gix`** (like
//! [`crate::gitio::commit_all`] and [`crate::release::publish::commit_release`]),
//! pointing `refs/heads/pages` at the new commit WITHOUT touching `HEAD` or the
//! working tree. nornir forbids `git` subprocesses and C deps (libgit2), and
//! `gix` (0.84) implements fetch but not send-pack — so, exactly like
//! [`crate::release::publish::push`], the actual force-push is left to the
//! operator/CI and the command prints the one line to run. See
//! [`force_push_cmd`].

use std::path::Path;

use anyhow::{anyhow, Context, Result};

/// The branch served by Codeberg Pages.
pub const PAGES_BRANCH: &str = "pages";

/// Fixed identity for `pages` commits — matches the hand-built branches already
/// live (so the authorship line stays uniform across every repo's `pages`).
const PAGES_NAME: &str = "nordisk-pages";
const PAGES_EMAIL: &str = "rickard@x14.se";

/// Commit subject for a pages publish.
pub const PAGES_COMMIT_MSG: &str = "pages: publish rendered docs book.pdf";

/// The empty `.nojekyll` marker's content (Codeberg Pages serves the tree
/// verbatim when this is present — no Jekyll pre-processing).
pub const NOJEKYLL: &str = "";

/// Render the dark, full-viewport PDF-embed `index.html` for `repo`.
///
/// Byte-for-byte identical to the template on the existing hand-built `pages`
/// branches (only the repo name is substituted, in the `<title>` and header).
pub fn index_html(repo: &str) -> String {
    format!(
        r#"<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>{repo} — documentation</title>
  <style>
    html, body {{ margin: 0; height: 100%; }}
    body {{ display: flex; flex-direction: column; font-family: system-ui, sans-serif; background: #0b0d10; color: #e6e6e6; }}
    header {{ padding: 10px 16px; border-bottom: 1px solid #222; }}
    header a {{ color: #7db4ff; text-decoration: none; }}
    embed {{ flex: 1 1 auto; border: 0; width: 100%; }}
  </style>
</head>
<body>
  <header><strong>{repo} — documentation</strong> &nbsp;·&nbsp; <a href="book.pdf">book.pdf</a></header>
  <embed src="book.pdf" type="application/pdf" />
</body>
</html>
"#
    )
}

/// The exact `git push` line the operator/CI runs to publish the staged
/// `pages` branch (force, since pages is an always-latest mirror).
pub fn force_push_cmd(repo_root: &Path, remote: &str) -> String {
    format!(
        "git -C {} push --force {remote} {PAGES_BRANCH}:refs/heads/{PAGES_BRANCH}",
        repo_root.display()
    )
}

/// Outcome of staging the `pages` branch locally.
#[derive(Debug, Clone)]
pub struct PagesBuild {
    /// Repo whose docs were published.
    pub repo: String,
    /// New orphan commit SHA that `refs/heads/pages` now points at.
    pub commit: String,
    /// Branch updated (always [`PAGES_BRANCH`]).
    pub branch: String,
    /// File names placed on the branch, sorted (`.nojekyll`, `book.pdf`, `index.html`).
    pub files: Vec<String>,
    /// Size of the embedded `book.pdf` in bytes.
    pub pdf_bytes: usize,
}

/// Assemble (or rebuild) the orphan `pages` branch for `repo` from
/// `book_pdf`, pointing `refs/heads/pages` at a fresh root commit that
/// contains ONLY `.nojekyll` + `book.pdf` + `index.html`.
///
/// Pure `gix`: writes blobs + a flat tree + an orphan (parent-less) commit,
/// then updates `refs/heads/pages` directly. `HEAD`, the working branch and the
/// working tree are never touched. Force-pushing the result is the caller's job
/// (see [`force_push_cmd`]) — `gix` has no in-process send-pack.
pub fn stage_pages_branch(repo_root: &Path, repo: &str, book_pdf: &Path) -> Result<PagesBuild> {
    use gix::object::tree::EntryKind;

    let pdf = std::fs::read(book_pdf)
        .with_context(|| format!("read book pdf {}", book_pdf.display()))?;
    if pdf.is_empty() {
        return Err(anyhow!(
            "{} is empty — render the book first (`nornir docs book {repo}`)",
            book_pdf.display()
        ));
    }
    let html = index_html(repo);

    let grepo =
        gix::open(repo_root).with_context(|| format!("gix::open {}", repo_root.display()))?;

    // Flat tree: build from the empty tree and upsert the three leaves.
    let empty = gix::ObjectId::empty_tree(grepo.object_hash());
    let mut editor = grepo.edit_tree(empty).context("seed empty tree editor")?;
    let entries: [(&str, &[u8]); 3] = [
        (".nojekyll", NOJEKYLL.as_bytes()),
        ("book.pdf", &pdf),
        ("index.html", html.as_bytes()),
    ];
    for (name, bytes) in entries {
        let blob = grepo.write_blob(bytes).with_context(|| format!("write blob {name}"))?;
        editor
            .upsert(gix::bstr::BStr::new(name), EntryKind::Blob, blob.detach())
            .with_context(|| format!("tree upsert {name}"))?;
    }
    let tree = editor.write().context("write pages tree")?.detach();

    // Orphan commit: NO parents → the branch carries no source history.
    let sig = gix::actor::SignatureRef {
        name: gix::bstr::BStr::new(PAGES_NAME),
        email: gix::bstr::BStr::new(PAGES_EMAIL),
        time: &now_git_time(),
    };
    let parents: Vec<gix::ObjectId> = Vec::new();
    let full_ref = format!("refs/heads/{PAGES_BRANCH}");
    let commit = grepo
        .commit_as(sig, sig, full_ref.as_str(), PAGES_COMMIT_MSG, tree, parents)
        .context("create orphan pages commit")?;

    Ok(PagesBuild {
        repo: repo.to_string(),
        commit: commit.to_string(),
        branch: PAGES_BRANCH.to_string(),
        files: vec![".nojekyll".into(), "book.pdf".into(), "index.html".into()],
        pdf_bytes: pdf.len(),
    })
}

/// Current time formatted as git's signature time (`<unix_secs> +0000`).
fn now_git_time() -> String {
    let secs = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    format!("{secs} +0000")
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The viewer template substitutes the repo name in BOTH the `<title>` and
    /// the header, links + embeds `book.pdf`, carries the `.nojekyll`-friendly
    /// dark style, and ends with a trailing newline (byte-parity with the
    /// hand-built branches).
    #[test]
    fn index_html_is_repo_named_and_embeds_the_pdf() {
        let html = index_html("knut");
        assert!(html.contains("<title>knut — documentation</title>"), "title names repo");
        assert!(
            html.contains("<strong>knut — documentation</strong>"),
            "header names repo"
        );
        assert!(html.contains(r#"<a href="book.pdf">book.pdf</a>"#), "links book.pdf");
        assert!(
            html.contains(r#"<embed src="book.pdf" type="application/pdf" />"#),
            "embeds book.pdf"
        );
        assert!(html.contains("background: #0b0d10"), "dark viewer style");
        assert!(html.ends_with("</html>\n"), "trailing newline (byte-parity)");
        // The known-good knut template is exactly 733 bytes.
        assert_eq!(html.len(), 733, "byte-for-byte parity with the live knut pages index.html");
    }

    /// LAW (inject-assert): build a real repo with a `main` commit, stage the
    /// pages branch from a fake `book.pdf`, and assert: (a) `refs/heads/pages`
    /// exists and is an ORPHAN (no parents), (b) its tree is EXACTLY the three
    /// pages files, (c) the embedded pdf round-trips, and (d) `HEAD`/`main` did
    /// NOT move — the working branch is untouched.
    #[test]
    fn stage_pages_branch_builds_orphan_three_file_tree_without_touching_head() {
        let td = tempfile::tempdir().expect("tempdir");
        let root = td.path().to_path_buf();
        crate::gitio::init(&root).expect("git init");
        std::fs::write(root.join("README.md"), b"hello\n").expect("write readme");
        let main_sha = crate::gitio::commit_all(&root, "initial").expect("commit main");
        let main_branch = crate::gitio::head_branch(&root).expect("head branch before");

        // A non-trivial fake book.pdf under docs/.
        std::fs::create_dir_all(root.join("docs")).expect("mkdir docs");
        let pdf_bytes = b"%PDF-1.7\n%fake book bytes\n".to_vec();
        let book = root.join("docs/book.pdf");
        std::fs::write(&book, &pdf_bytes).expect("write book.pdf");

        let built = stage_pages_branch(&root, "demo", &book).expect("stage pages");
        assert_eq!(built.branch, "pages");
        assert_eq!(built.files, vec![".nojekyll", "book.pdf", "index.html"]);
        assert_eq!(built.pdf_bytes, pdf_bytes.len());

        let repo = gix::open(&root).expect("reopen");

        // (a) refs/heads/pages exists and is an orphan.
        let pages_commit = repo
            .rev_parse_single("refs/heads/pages")
            .expect("resolve pages ref")
            .object()
            .expect("pages object")
            .try_into_commit()
            .expect("pages is a commit");
        assert_eq!(pages_commit.id.to_string(), built.commit, "ref points at reported commit");
        assert_eq!(
            pages_commit.parent_ids().count(),
            0,
            "pages must be an ORPHAN (no parents — carries no source history)"
        );

        // (b) tree is exactly the three pages files.
        let tree = pages_commit.tree().expect("pages tree");
        let mut names: Vec<String> =
            tree.iter().flatten().map(|e| e.filename().to_string()).collect();
        names.sort();
        assert_eq!(names, vec![".nojekyll", "book.pdf", "index.html"]);

        // (c) HEAD / main never moved — the working branch is untouched.
        let head_sha = crate::gitio::head_sha(&root).expect("head sha");
        assert_eq!(head_sha, main_sha, "HEAD must stay on the original main commit");
        assert_eq!(
            crate::gitio::head_branch(&root).expect("head branch after"),
            main_branch,
            "working branch is untouched"
        );
    }
}