From e5256e04e69f973511a8086151b33a7699ca10c3 Mon Sep 17 00:00:00 2001 From: gl0w Date: Thu, 10 Sep 2026 05:09:13 +0100 Subject: [PATCH] m1/code-tab: build cmd, index/files/raw, clone chain, bundle+tarball (25 tests) --- Cargo.lock | 195 +++++++++++++ Cargo.toml | 6 + src/build.rs | 689 +++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 103 ++++++- tests/build_cli.rs | 56 ++++ 5 files changed, 1044 insertions(+), 5 deletions(-) create mode 100644 src/build.rs create mode 100644 tests/build_cli.rs diff --git a/Cargo.lock b/Cargo.lock index 9569aba..d1596b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,201 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hu13" version = "0.1.0" +dependencies = [ + "pulldown-cmark", + "serde", + "serde_json", + "yaml_serde", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libyaml-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e126dda6f34391ab7b444f9922055facc83c07a910da3eb16f1e4d9c45dc777" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "yaml_serde" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b729a08a9a6be689bbad3e2bf8015926db54b6622cc89c3a5f7dc174b9e918" +dependencies = [ + "indexmap", + "itoa", + "libyaml-rs", + "ryu", + "serde", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index d163f7d..12c7eb7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,3 +4,9 @@ version = "0.1.0" edition = "2021" description = "tiny static-site generator + text-only inbox for one f0rge-managed repo" license = "MIT OR Apache-2.0" + +[dependencies] +pulldown-cmark = "=0.13.4" +serde = { version = "=1.0.229", features = ["derive"] } +serde_json = "=1.0.151" +yaml_serde = "=0.10.7" diff --git a/src/build.rs b/src/build.rs new file mode 100644 index 0000000..5280528 --- /dev/null +++ b/src/build.rs @@ -0,0 +1,689 @@ +//! `hu13 build` — code-tab static site (m1, f0rge issue #4). +//! +//! Reads one branch via [`crate::gitx`], emits `dist/`: +//! `index.html`, `files/.html`, `raw/` (byte-identical), +//! `repo.bundle` + `repo.tar.gz`, and `style.css`. +//! +//! Bodies from the repo are semi-trusted but treated as untrusted: +//! markdown goes through [`markdown_to_html`], which escapes raw HTML. + +use std::collections::BTreeMap; +use std::path::Path; + +use crate::gitx; +use crate::layout::esc; + +/// Files at or above this size get a placeholder page + download link. +pub const PREVIEW_MAX_BYTES: usize = 256 * 1024; + +/// Options for [`run_build`] (flags only, no config file). +#[derive(Debug, Clone)] +pub struct BuildOptions { + /// `--branch` flag (else `$HU13_BRANCH`, else main/master). + pub branch_flag: Option, + /// `--clone-url` flag (else git origin remote, else none). + pub clone_url_flag: Option, + /// `--title` site title override (else repo dir name). + pub title_override: Option, + /// `--max-log` (accepted for CLI parity; commits land in m2). + pub max_log: usize, +} + +/// Summary of one successful build (for logs/tests). +#[derive(Debug, Clone)] +pub struct BuildSummary { + /// Branch that was rendered. + pub branch: String, + /// Short sha of the branch head. + pub short_sha: String, + /// Number of files emitted under `raw/`. + pub file_count: usize, +} + +/// Render markdown to HTML, escaping raw HTML. +/// +/// `pulldown-cmark` passes `Html`/`InlineHtml` events through untouched +/// (R1 finding), so map them through [`esc`] first. Everything else +/// (code spans, emphasis, lists) renders normally. +#[must_use] +pub fn markdown_to_html(md: &str) -> String { + use pulldown_cmark::{html, Event, Options, Parser}; + let parser = Parser::new_ext(md, Options::empty()); + let mapped = parser.map(|ev| match ev { + Event::Html(h) => Event::Html(esc(&h).into()), + Event::InlineHtml(h) => Event::InlineHtml(esc(&h).into()), + other => other, + }); + let mut out = String::new(); + html::push_html(&mut out, mapped); + out +} + +/// Resolve the clone URL to advertise. +/// +/// Precedence: `--clone-url` flag > `git remote get-url origin` at build +/// time > none (`None`, and the index shows no URL line). +#[must_use] +pub fn resolve_clone_url(repo: &Path, flag: Option<&str>) -> Option { + if let Some(f) = flag.map(str::trim).filter(|s| !s.is_empty()) { + return Some(f.to_string()); + } + let out = std::process::Command::new("git") + .arg("-C") + .arg(repo) + .arg("remote") + .arg("get-url") + .arg("origin") + .output(); + match out { + Ok(o) if o.status.success() => { + let s = String::from_utf8_lossy(&o.stdout).trim().to_string(); + if s.is_empty() { + None + } else { + Some(s) + } + } + _ => None, + } +} + +/// README lookup chain (f0rge convention): repo-root `README.md` > +/// `readme.md` > `README`, else `.forge/wiki/README.md` > +/// `.forge/wiki/readme.md`, else none. +/// +/// Returns the winning path plus its blob. +pub fn find_readme(repo: &Path, branch: &str) -> Option<(String, gitx::Blob)> { + for candidate in [ + "README.md", + "readme.md", + "README", + ".forge/wiki/README.md", + ".forge/wiki/readme.md", + ] { + if let Ok(blob) = gitx::show(repo, branch, Path::new(candidate)) { + return Some((candidate.to_string(), blob)); + } + } + None +} + +/// `true` when a blob must not be inlined (binary or over the cap). +#[must_use] +pub fn is_placeholder(bytes: &[u8]) -> bool { + bytes.len() >= PREVIEW_MAX_BYTES || bytes.contains(&0) +} + +/// `true` for markdown-rendered paths (`.md`/`.markdown`, any case). +#[must_use] +pub fn is_markdown(path: &str) -> bool { + let lower = path.to_lowercase(); + lower.ends_with(".md") || lower.ends_with(".markdown") +} + +fn href_esc(path: &str) -> String { + esc(path) +} + +/// Render one text file body: escaped `
` with line numbers.
+fn render_text_pre(display: &str) -> String {
+    let mut out = String::from("
\n");
+    if display.is_empty() {
+        out.push_str("1 \n");
+    } else {
+        for (i, line) in display.split('\n').enumerate() {
+            out.push_str("");
+            out.push_str(&(i + 1).to_string());
+            out.push_str(" ");
+            out.push_str(&esc(line));
+            out.push_str("\n");
+        }
+    }
+    out.push_str("
\n"); + out +} + +fn nav_code() -> String { + "code " + .to_string() +} + +/// Write `out/style.css` from the embedded static file. +fn write_style(out: &Path) -> Result<(), String> { + let css = include_str!("../static/style.css"); + std::fs::write(out.join("style.css"), css).map_err(|e| format!("hu13: write style.css: {e}")) +} + +/// Build the full static site. Returns a summary on success. +pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result { + let _ = opts.max_log; // commits land in m2; flag accepted for CLI parity + let branch = gitx::resolve_branch(repo, opts.branch_flag.as_deref()) + .map_err(|e| format!("hu13: {e}"))?; + let short_sha = gitx::rev_parse_short(repo, &branch).map_err(|e| format!("hu13: {e}"))?; + let clone_url = resolve_clone_url(repo, opts.clone_url_flag.as_deref()); + + let name = match &opts.title_override { + Some(t) if !t.trim().is_empty() => t.trim().to_string(), + _ => repo + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| "repo".to_string()), + }; + + // Walk the whole tree (dirs-first order from gitx), remembering each + // dir's entries so dir pages need no second pass. + let mut dir_entries: BTreeMap> = BTreeMap::new(); + let mut dirs: Vec = vec![String::new()]; + let mut files: Vec = Vec::new(); + let mut queue: Vec = vec![String::new()]; + while let Some(dir) = queue.pop() { + let entries = gitx::ls_tree(repo, &branch, &dir).map_err(|e| format!("hu13: {e}"))?; + for e in &entries { + let full = if dir.is_empty() { + e.name.clone() + } else { + format!("{dir}/{}", e.name) + }; + if e.is_dir { + queue.push(full); + } else { + files.push(full); + } + } + dir_entries.insert(dir.clone(), entries); + let _ = dirs.len(); + } + dirs = dir_entries.keys().cloned().collect(); + + let readme = find_readme(repo, &branch); + let description = readme.as_ref().and_then(|(_, blob)| { + blob.display + .lines() + .map(str::trim) + .find(|l| !l.is_empty()) + .map(|l| { + let l = l.strip_prefix('#').unwrap_or(l).trim(); + if l.len() > 200 { + l[..200].to_string() + } else { + l.to_string() + } + }) + .filter(|l| !l.is_empty()) + }); + + std::fs::create_dir_all(out.join("files")) + .map_err(|e| format!("hu13: create files dir: {e}"))?; + std::fs::create_dir_all(out.join("raw")).map_err(|e| format!("hu13: create raw dir: {e}"))?; + write_style(out)?; + + // Raw files first: byte-identical copies. + let mut blobs: BTreeMap> = BTreeMap::new(); + for f in &files { + let blob = gitx::show(repo, &branch, Path::new(f)).map_err(|e| format!("hu13: {e}"))?; + let dest = out.join("raw").join(f); + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("hu13: create raw parent: {e}"))?; + } + std::fs::write(&dest, &blob.bytes).map_err(|e| format!("hu13: write raw {f}: {e}"))?; + blobs.insert(f.clone(), blob.bytes); + } + + // Per-path pages. + for dir in &dirs { + if dir.is_empty() { + continue; // root is index.html + } + let entries = dir_entries.get(dir).cloned().unwrap_or_default(); + let parent = match dir.rfind('/') { + Some(i) => dir[..i].to_string(), + None => String::new(), + }; + let parent_link = if parent.is_empty() { + ".. (root)".to_string() + } else { + format!( + ".. ({})", + href_esc(&parent), + esc(&parent) + ) + }; + let mut body = format!( + "

dir: {}

\n

{} · code

\n
    \n", + esc(dir), + parent_link + ); + for e in &entries { + let full = format!("{dir}/{}", e.name); + if e.is_dir { + body.push_str(&format!( + "
  • {}/
  • \n", + href_esc(&full), + esc(&e.name) + )); + } else { + body.push_str(&format!( + "
  • {} (raw)
  • \n", + href_esc(&full), + esc(&e.name), + href_esc(&full) + )); + } + } + body.push_str("
\n"); + let html = crate::layout::page(&format!("{dir} — {name}"), &nav_code(), &body); + let dest = out.join("files").join(format!("{dir}.html")); + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("hu13: create files parent: {e}"))?; + } + std::fs::write(&dest, html).map_err(|e| format!("hu13: write files page: {e}"))?; + } + + for f in &files { + let bytes = blobs.get(f).cloned().unwrap_or_default(); + let parent = match f.rfind('/') { + Some(i) => f[..i].to_string(), + None => String::new(), + }; + let parent_link = if parent.is_empty() { + ".. (root)".to_string() + } else { + format!( + ".. ({})", + href_esc(&parent), + esc(&parent) + ) + }; + let mut body = format!( + "

{}

\n

{} · raw · code

\n", + esc(f), + parent_link, + href_esc(f) + ); + if is_placeholder(&bytes) { + let kind = if bytes.len() >= PREVIEW_MAX_BYTES { + format!("too large to preview ({} bytes)", bytes.len()) + } else { + "binary file".to_string() + }; + body.push_str(&format!( + "

placeholder: {kind} — preview omitted. \ + download the raw file instead.

\n", + href_esc(f) + )); + } else if is_markdown(f) { + let display = String::from_utf8_lossy(&bytes).into_owned(); + body.push_str(&markdown_to_html(&display)); + body.push_str(&format!( + "

view raw

\n", + href_esc(f) + )); + } else { + let display = String::from_utf8_lossy(&bytes).into_owned(); + body.push_str(&render_text_pre(&display)); + } + let html = crate::layout::page(&format!("{f} — {name}"), &nav_code(), &body); + let dest = out.join("files").join(format!("{f}.html")); + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("hu13: create files parent: {e}"))?; + } + std::fs::write(&dest, html).map_err(|e| format!("hu13: write files page: {e}"))?; + } + + // Snapshot last so the index can link sizes. + let snap = gitx::snapshot(repo, &branch, out).map_err(|e| format!("hu13: {e}"))?; + let bundle_size = std::fs::metadata(&snap.bundle) + .map(|m| m.len()) + .unwrap_or(0); + let tarball_size = std::fs::metadata(&snap.tarball) + .map(|m| m.len()) + .unwrap_or(0); + + // index.html header. + let mut body = format!("

{}

\n", esc(&name)); + match &description { + Some(d) => body.push_str(&format!("

{}

\n", esc(d))), + None => body.push_str("

No description.

\n"), + } + if let Some(url) = &clone_url { + body.push_str(&format!( + "

Clone: git clone {}

\n", + esc(url) + )); + } + body.push_str(&format!( + "

Branch {} @ {}

\n", + esc(&branch), + esc(&short_sha) + )); + body.push_str( + "

issues (0) · pulls (0) · tasks (0)

\n", + ); + body.push_str("

CI: unknown (stub)

\n"); + body.push_str(&format!( + "

repo.bundle ({bundle_size} bytes) · \ + repo.tar.gz ({tarball_size} bytes)

\n" + )); + + body.push_str("

Files

\n
    \n"); + for e in dir_entries.get("").cloned().unwrap_or_default() { + if e.is_dir { + body.push_str(&format!( + "
  • {}/
  • \n", + href_esc(&e.name), + esc(&e.name) + )); + } else { + body.push_str(&format!( + "
  • {} (raw)
  • \n", + href_esc(&e.name), + esc(&e.name), + href_esc(&e.name) + )); + } + } + body.push_str("
\n"); + + body.push_str("

README

\n"); + match &readme { + Some((path, blob)) => { + body.push_str(&format!( + "

from {}

\n", + esc(path) + )); + if is_markdown(path) || path.to_lowercase().contains("readme") { + body.push_str(&markdown_to_html(&blob.display)); + } else { + body.push_str(&render_text_pre(&blob.display)); + } + } + None => body.push_str("

No README found.

\n"), + } + + let index = crate::layout::page(&name, &nav_code(), &body); + std::fs::write(out.join("index.html"), index) + .map_err(|e| format!("hu13: write index.html: {e}"))?; + + Ok(BuildSummary { + branch, + short_sha, + file_count: files.len(), + }) +} + +/// Collect every file path under `dir` (relative, `/`-separated). +#[cfg(test)] +pub fn test_all_files(dir: &Path) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![dir.to_path_buf()]; + while let Some(d) = stack.pop() { + let rd = std::fs::read_dir(&d).expect("read_dir"); + for ent in rd { + let ent = ent.expect("dir entry"); + let p = ent.path(); + if p.is_dir() { + stack.push(p); + } else { + out.push(p.strip_prefix(dir).expect("prefix").to_path_buf()); + } + } + } + out.sort(); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + use std::process::Command; + use std::sync::atomic::{AtomicU64, Ordering}; + + static COUNTER: AtomicU64 = AtomicU64::new(0); + + fn mktemp(prefix: &str) -> PathBuf { + let id = COUNTER.fetch_add(1, Ordering::SeqCst); + let dir = + std::env::temp_dir().join(format!("hu13-build-{prefix}-{}-{id}", std::process::id())); + std::fs::create_dir_all(&dir).expect("mktemp"); + dir + } + + fn git(repo: &Path, args: &[&str]) { + let st = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .status() + .expect("spawn git"); + assert!(st.success(), "git {args:?} failed in {}", repo.display()); + } + + fn opts() -> BuildOptions { + BuildOptions { + branch_flag: None, + clone_url_flag: None, + title_override: None, + max_log: 100, + } + } + + /// Fixture: 10 files, no `.forge/` (the no-forge case) — + /// README + subdir + binary + >256KB file. + fn fixture_repo() -> PathBuf { + let dir = mktemp("fixture"); + git(&dir, &["init", "-b", "main"]); + git(&dir, &["config", "user.name", "hu13-test"]); + git(&dir, &["config", "user.email", "hu13@test.local"]); + git(&dir, &["config", "commit.gpgsign", "false"]); + std::fs::write( + dir.join("README.md"), + "# Hello fixture\n\nWelcome to the **fixture** repo.\n\n\n", + ) + .unwrap(); + std::fs::create_dir_all(dir.join("src")).unwrap(); + std::fs::write( + dir.join("src").join("main.rs"), + "fn main() {\n println!(\"hi\");\n}\n", + ) + .unwrap(); + std::fs::write( + dir.join("src").join("lib.rs"), + "pub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n", + ) + .unwrap(); + std::fs::create_dir_all(dir.join("docs")).unwrap(); + std::fs::write( + dir.join("docs").join("guide.md"), + "# Guide\n\nSome *docs* here.\n", + ) + .unwrap(); + std::fs::write(dir.join("notes.txt"), "plain notes\nsecond line\n").unwrap(); + std::fs::create_dir_all(dir.join("assets")).unwrap(); + let mut bin: Vec = (0u8..=255).collect(); + bin.extend_from_slice(b"\x00\x01\x02binary tail"); + std::fs::write(dir.join("assets").join("logo.bin"), &bin).unwrap(); + std::fs::create_dir_all(dir.join("data")).unwrap(); + std::fs::write(dir.join("data").join("big.txt"), vec![b'a'; 300_000]).unwrap(); + std::fs::create_dir_all(dir.join("sub")).unwrap(); + std::fs::write(dir.join("sub").join("nested.txt"), "nested\n").unwrap(); + std::fs::write(dir.join("sub").join("deep.md"), "# Deep\n\nnested docs\n").unwrap(); + std::fs::write( + dir.join("LICENSE"), + "MIT License\n\nPermission is hereby granted.\n", + ) + .unwrap(); + git(&dir, &["add", "-A"]); + git(&dir, &["commit", "-m", "fixture commit"]); + dir + } + + fn build_fixture() -> (PathBuf, PathBuf) { + let repo = fixture_repo(); + let out = mktemp("out"); + let summary = run_build(&repo, &out, &opts()).expect("build fixture"); + assert_eq!(summary.branch, "main"); + assert_eq!(summary.file_count, 10); + assert!(!summary.short_sha.is_empty()); + (repo, out) + } + + #[test] + fn index_has_tree_and_readme_html() { + let (_repo, out) = build_fixture(); + let index = std::fs::read_to_string(out.join("index.html")).expect("index"); + // File tree (dirs-first flat root listing). + for name in ["src", "docs", "assets", "data", "sub"] { + assert!(index.contains(name), "tree should list {name}"); + } + for name in ["README.md", "notes.txt", "LICENSE"] { + assert!(index.contains(name), "tree should list {name}"); + } + // README rendered to HTML (markdown heading), raw HTML escaped. + assert!(index.contains("Hello fixture"), "README body rendered"); + assert!( + !index.contains(""), + "raw HTML escaped" + ); + assert!(index.contains("<script>"), "escaped HTML present"); + // Header: branch + short sha, f0rge counts stub, CI stub. + assert!(index.contains("main"), "branch shown"); + assert!(index.contains("TODO(m3)"), "f0rge-count TODO marker"); + assert!(index.contains("issues (0)"), "f0rge counts as 0"); + assert!(index.contains("CI:"), "CI verdict stub"); + // Snapshot links with sizes. + assert!(index.contains("repo.bundle"), "bundle linked"); + assert!(index.contains("repo.tar.gz"), "tarball linked"); + // No clone URL line when no origin remote is configured. + assert!(!index.contains("git clone"), "no URL line without remote"); + } + + #[test] + fn clone_url_flag_wins() { + let repo = fixture_repo(); + let out = mktemp("out-flag"); + let mut o = opts(); + o.clone_url_flag = Some("https://example.com/x.git".to_string()); + run_build(&repo, &out, &o).expect("build"); + let index = std::fs::read_to_string(out.join("index.html")).expect("index"); + assert!(index.contains("git clone https://example.com/x.git")); + } + + #[test] + fn raw_byte_identical_for_all_files() { + let (repo, out) = build_fixture(); + let raws = test_all_files(&out.join("raw")); + assert_eq!(raws.len(), 10, "10 raw files, got {raws:?}"); + for rel in &raws { + let want = std::fs::read(repo.join(rel)).expect("repo file"); + let got = std::fs::read(out.join("raw").join(rel)).expect("raw file"); + assert_eq!(got, want, "byte-identical: {}", rel.display()); + } + } + + #[test] + fn placeholder_for_binary_and_big_file() { + let (_repo, out) = build_fixture(); + for page in ["files/assets/logo.bin.html", "files/data/big.txt.html"] { + let html = std::fs::read_to_string(out.join(page)).expect(page); + assert!(html.contains("placeholder"), "{page} has placeholder"); + assert!(html.contains("/raw/"), "{page} links a download"); + } + // Binary bytes must never be inlined into the page. + let bin_page = + std::fs::read_to_string(out.join("files/assets/logo.bin.html")).expect("bin page"); + assert!(!bin_page.contains("binary tail"), "binary not inlined"); + // Small text file gets line numbers + raw link, no placeholder. + let txt = std::fs::read_to_string(out.join("files/notes.txt.html")).expect("txt page"); + assert!(txt.contains("/raw/notes.txt"), "raw link"); + assert!(!txt.contains("placeholder"), "text file inlined"); + assert!( + txt.contains("<") || txt.contains("plain notes"), + "escaped pre body" + ); + } + + #[test] + fn md_files_are_rendered() { + let (_repo, out) = build_fixture(); + let html = std::fs::read_to_string(out.join("files/docs/guide.md.html")).expect("md page"); + assert!( + html.contains("

Guide

"), + "markdown rendered, got: {html}" + ); + assert!(html.contains("/raw/docs/guide.md"), "raw link kept"); + } + + #[test] + fn bundle_clones() { + let (_repo, out) = build_fixture(); + assert!(out.join("repo.bundle").exists()); + assert!(out.join("repo.tar.gz").exists()); + let dest_parent = mktemp("clone"); + let dest = dest_parent.join("cloned"); + let st = Command::new("git") + .arg("clone") + .arg("-b") + .arg("main") + .arg(out.join("repo.bundle")) + .arg(&dest) + .status() + .expect("git clone"); + assert!(st.success(), "bundle clones"); + assert!(dest.join("README.md").exists()); + assert!(dest.join("sub").join("nested.txt").exists()); + } + + #[test] + fn no_forge_build_succeeds() { + // Fixture has no `.forge/` at all; build must still exit Ok (0). + let repo = fixture_repo(); + assert!(!repo.join(".forge").exists()); + let out = mktemp("out-noforge"); + let res = run_build(&repo, &out, &opts()); + assert!(res.is_ok(), "no-forge build exits 0, got {res:?}"); + assert!(out.join("index.html").exists()); + } + + #[test] + fn readme_chain_prefers_root_then_wiki() { + let dir = mktemp("readme-chain"); + git(&dir, &["init", "-b", "main"]); + git(&dir, &["config", "user.name", "hu13-test"]); + git(&dir, &["config", "user.email", "hu13@test.local"]); + git(&dir, &["config", "commit.gpgsign", "false"]); + // Wiki-only README first. + std::fs::create_dir_all(dir.join(".forge").join("wiki")).unwrap(); + std::fs::write( + dir.join(".forge").join("wiki").join("README.md"), + "# Wiki readme\n", + ) + .unwrap(); + std::fs::write(dir.join("notes.txt"), "x\n").unwrap(); + git(&dir, &["add", "-A"]); + git(&dir, &["commit", "-m", "wiki readme only"]); + let (path, _) = find_readme(&dir, "main").expect("wiki fallback"); + assert_eq!(path, ".forge/wiki/README.md"); + // Root README.md wins once added. + std::fs::write(dir.join("README.md"), "# Root readme\n").unwrap(); + git(&dir, &["add", "-A"]); + git(&dir, &["commit", "-m", "root readme"]); + let (path, _) = find_readme(&dir, "main").expect("root"); + assert_eq!(path, "README.md"); + } + + #[test] + fn markdown_escapes_raw_html() { + let html = markdown_to_html("# hi\n\n\n\n**bold**\n"); + assert!(html.contains("

hi

"), "heading renders"); + assert!(!html.contains("