.forge/prs/c455884a-764a-4202-9bb8-298b284dc27a/patch.diff 38.3 KiB 1128 lines Raw
1 From e5256e04e69f973511a8086151b33a7699ca10c3 Mon Sep 17 00:00:00 20012 From: gl0w <gl0w@world>3 Date: Thu, 10 Sep 2026 05:09:13 +01004 Subject: [PATCH] m1/code-tab: build cmd, index/files/raw, clone chain,5  bundle+tarball (25 tests)6 7 ---8  Cargo.lock         | 195 +++++++++++++9  Cargo.toml         |   6 +10  src/build.rs       | 689 +++++++++++++++++++++++++++++++++++++++++++++11  src/main.rs        | 103 ++++++-12  tests/build_cli.rs |  56 ++++13  5 files changed, 1044 insertions(+), 5 deletions(-)14  create mode 100644 src/build.rs15  create mode 100644 tests/build_cli.rs16 17 diff --git a/Cargo.lock b/Cargo.lock18 index 9569aba..d1596b8 10064419 --- a/Cargo.lock20 +++ b/Cargo.lock21 @@ -2,6 +2,201 @@22  # It is not intended for manual editing.23  version = 424  25 +[[package]]26 +name = "bitflags"27 +version = "2.13.2"28 +source = "registry+https://github.com/rust-lang/crates.io-index"29 +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"30 +31 +[[package]]32 +name = "equivalent"33 +version = "1.0.2"34 +source = "registry+https://github.com/rust-lang/crates.io-index"35 +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"36 +37 +[[package]]38 +name = "getopts"39 +version = "0.2.24"40 +source = "registry+https://github.com/rust-lang/crates.io-index"41 +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"42 +dependencies = [43 + "unicode-width",44 +]45 +46 +[[package]]47 +name = "hashbrown"48 +version = "0.17.1"49 +source = "registry+https://github.com/rust-lang/crates.io-index"50 +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"51 +52  [[package]]53  name = "hu13"54  version = "0.1.0"55 +dependencies = [56 + "pulldown-cmark",57 + "serde",58 + "serde_json",59 + "yaml_serde",60 +]61 +62 +[[package]]63 +name = "indexmap"64 +version = "2.14.2"65 +source = "registry+https://github.com/rust-lang/crates.io-index"66 +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"67 +dependencies = [68 + "equivalent",69 + "hashbrown",70 +]71 +72 +[[package]]73 +name = "itoa"74 +version = "1.0.18"75 +source = "registry+https://github.com/rust-lang/crates.io-index"76 +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"77 +78 +[[package]]79 +name = "libyaml-rs"80 +version = "0.3.0"81 +source = "registry+https://github.com/rust-lang/crates.io-index"82 +checksum = "2e126dda6f34391ab7b444f9922055facc83c07a910da3eb16f1e4d9c45dc777"83 +84 +[[package]]85 +name = "memchr"86 +version = "2.8.3"87 +source = "registry+https://github.com/rust-lang/crates.io-index"88 +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"89 +90 +[[package]]91 +name = "proc-macro2"92 +version = "1.0.107"93 +source = "registry+https://github.com/rust-lang/crates.io-index"94 +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"95 +dependencies = [96 + "unicode-ident",97 +]98 +99 +[[package]]100 +name = "pulldown-cmark"101 +version = "0.13.4"102 +source = "registry+https://github.com/rust-lang/crates.io-index"103 +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"104 +dependencies = [105 + "bitflags",106 + "getopts",107 + "memchr",108 + "pulldown-cmark-escape",109 + "unicase",110 +]111 +112 +[[package]]113 +name = "pulldown-cmark-escape"114 +version = "0.11.0"115 +source = "registry+https://github.com/rust-lang/crates.io-index"116 +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"117 +118 +[[package]]119 +name = "quote"120 +version = "1.0.47"121 +source = "registry+https://github.com/rust-lang/crates.io-index"122 +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"123 +dependencies = [124 + "proc-macro2",125 +]126 +127 +[[package]]128 +name = "ryu"129 +version = "1.0.23"130 +source = "registry+https://github.com/rust-lang/crates.io-index"131 +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"132 +133 +[[package]]134 +name = "serde"135 +version = "1.0.229"136 +source = "registry+https://github.com/rust-lang/crates.io-index"137 +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"138 +dependencies = [139 + "serde_core",140 + "serde_derive",141 +]142 +143 +[[package]]144 +name = "serde_core"145 +version = "1.0.229"146 +source = "registry+https://github.com/rust-lang/crates.io-index"147 +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"148 +dependencies = [149 + "serde_derive",150 +]151 +152 +[[package]]153 +name = "serde_derive"154 +version = "1.0.229"155 +source = "registry+https://github.com/rust-lang/crates.io-index"156 +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"157 +dependencies = [158 + "proc-macro2",159 + "quote",160 + "syn",161 +]162 +163 +[[package]]164 +name = "serde_json"165 +version = "1.0.151"166 +source = "registry+https://github.com/rust-lang/crates.io-index"167 +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"168 +dependencies = [169 + "itoa",170 + "memchr",171 + "serde",172 + "serde_core",173 + "zmij",174 +]175 +176 +[[package]]177 +name = "syn"178 +version = "3.0.5"179 +source = "registry+https://github.com/rust-lang/crates.io-index"180 +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"181 +dependencies = [182 + "proc-macro2",183 + "quote",184 + "unicode-ident",185 +]186 +187 +[[package]]188 +name = "unicase"189 +version = "2.9.0"190 +source = "registry+https://github.com/rust-lang/crates.io-index"191 +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"192 +193 +[[package]]194 +name = "unicode-ident"195 +version = "1.0.24"196 +source = "registry+https://github.com/rust-lang/crates.io-index"197 +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"198 +199 +[[package]]200 +name = "unicode-width"201 +version = "0.2.2"202 +source = "registry+https://github.com/rust-lang/crates.io-index"203 +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"204 +205 +[[package]]206 +name = "yaml_serde"207 +version = "0.10.7"208 +source = "registry+https://github.com/rust-lang/crates.io-index"209 +checksum = "33b729a08a9a6be689bbad3e2bf8015926db54b6622cc89c3a5f7dc174b9e918"210 +dependencies = [211 + "indexmap",212 + "itoa",213 + "libyaml-rs",214 + "ryu",215 + "serde",216 +]217 +218 +[[package]]219 +name = "zmij"220 +version = "1.0.23"221 +source = "registry+https://github.com/rust-lang/crates.io-index"222 +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"223 diff --git a/Cargo.toml b/Cargo.toml224 index d163f7d..12c7eb7 100644225 --- a/Cargo.toml226 +++ b/Cargo.toml227 @@ -4,3 +4,9 @@ version = "0.1.0"228  edition = "2021"229  description = "tiny static-site generator + text-only inbox for one f0rge-managed repo"230  license = "MIT OR Apache-2.0"231 +232 +[dependencies]233 +pulldown-cmark = "=0.13.4"234 +serde = { version = "=1.0.229", features = ["derive"] }235 +serde_json = "=1.0.151"236 +yaml_serde = "=0.10.7"237 diff --git a/src/build.rs b/src/build.rs238 new file mode 100644239 index 0000000..5280528240 --- /dev/null241 +++ b/src/build.rs242 @@ -0,0 +1,689 @@243 +//! `hu13 build` — code-tab static site (m1, f0rge issue #4).244 +//!245 +//! Reads one branch via [`crate::gitx`], emits `dist/`:246 +//! `index.html`, `files/<path>.html`, `raw/<path>` (byte-identical),247 +//! `repo.bundle` + `repo.tar.gz`, and `style.css`.248 +//!249 +//! Bodies from the repo are semi-trusted but treated as untrusted:250 +//! markdown goes through [`markdown_to_html`], which escapes raw HTML.251 +252 +use std::collections::BTreeMap;253 +use std::path::Path;254 +255 +use crate::gitx;256 +use crate::layout::esc;257 +258 +/// Files at or above this size get a placeholder page + download link.259 +pub const PREVIEW_MAX_BYTES: usize = 256 * 1024;260 +261 +/// Options for [`run_build`] (flags only, no config file).262 +#[derive(Debug, Clone)]263 +pub struct BuildOptions {264 +    /// `--branch` flag (else `$HU13_BRANCH`, else main/master).265 +    pub branch_flag: Option<String>,266 +    /// `--clone-url` flag (else git origin remote, else none).267 +    pub clone_url_flag: Option<String>,268 +    /// `--title` site title override (else repo dir name).269 +    pub title_override: Option<String>,270 +    /// `--max-log` (accepted for CLI parity; commits land in m2).271 +    pub max_log: usize,272 +}273 +274 +/// Summary of one successful build (for logs/tests).275 +#[derive(Debug, Clone)]276 +pub struct BuildSummary {277 +    /// Branch that was rendered.278 +    pub branch: String,279 +    /// Short sha of the branch head.280 +    pub short_sha: String,281 +    /// Number of files emitted under `raw/`.282 +    pub file_count: usize,283 +}284 +285 +/// Render markdown to HTML, escaping raw HTML.286 +///287 +/// `pulldown-cmark` passes `Html`/`InlineHtml` events through untouched288 +/// (R1 finding), so map them through [`esc`] first. Everything else289 +/// (code spans, emphasis, lists) renders normally.290 +#[must_use]291 +pub fn markdown_to_html(md: &str) -> String {292 +    use pulldown_cmark::{html, Event, Options, Parser};293 +    let parser = Parser::new_ext(md, Options::empty());294 +    let mapped = parser.map(|ev| match ev {295 +        Event::Html(h) => Event::Html(esc(&h).into()),296 +        Event::InlineHtml(h) => Event::InlineHtml(esc(&h).into()),297 +        other => other,298 +    });299 +    let mut out = String::new();300 +    html::push_html(&mut out, mapped);301 +    out302 +}303 +304 +/// Resolve the clone URL to advertise.305 +///306 +/// Precedence: `--clone-url` flag > `git remote get-url origin` at build307 +/// time > none (`None`, and the index shows no URL line).308 +#[must_use]309 +pub fn resolve_clone_url(repo: &Path, flag: Option<&str>) -> Option<String> {310 +    if let Some(f) = flag.map(str::trim).filter(|s| !s.is_empty()) {311 +        return Some(f.to_string());312 +    }313 +    let out = std::process::Command::new("git")314 +        .arg("-C")315 +        .arg(repo)316 +        .arg("remote")317 +        .arg("get-url")318 +        .arg("origin")319 +        .output();320 +    match out {321 +        Ok(o) if o.status.success() => {322 +            let s = String::from_utf8_lossy(&o.stdout).trim().to_string();323 +            if s.is_empty() {324 +                None325 +            } else {326 +                Some(s)327 +            }328 +        }329 +        _ => None,330 +    }331 +}332 +333 +/// README lookup chain (f0rge convention): repo-root `README.md` >334 +/// `readme.md` > `README`, else `.forge/wiki/README.md` >335 +/// `.forge/wiki/readme.md`, else none.336 +///337 +/// Returns the winning path plus its blob.338 +pub fn find_readme(repo: &Path, branch: &str) -> Option<(String, gitx::Blob)> {339 +    for candidate in [340 +        "README.md",341 +        "readme.md",342 +        "README",343 +        ".forge/wiki/README.md",344 +        ".forge/wiki/readme.md",345 +    ] {346 +        if let Ok(blob) = gitx::show(repo, branch, Path::new(candidate)) {347 +            return Some((candidate.to_string(), blob));348 +        }349 +    }350 +    None351 +}352 +353 +/// `true` when a blob must not be inlined (binary or over the cap).354 +#[must_use]355 +pub fn is_placeholder(bytes: &[u8]) -> bool {356 +    bytes.len() >= PREVIEW_MAX_BYTES || bytes.contains(&0)357 +}358 +359 +/// `true` for markdown-rendered paths (`.md`/`.markdown`, any case).360 +#[must_use]361 +pub fn is_markdown(path: &str) -> bool {362 +    let lower = path.to_lowercase();363 +    lower.ends_with(".md") || lower.ends_with(".markdown")364 +}365 +366 +fn href_esc(path: &str) -> String {367 +    esc(path)368 +}369 +370 +/// Render one text file body: escaped `<pre>` with line numbers.371 +fn render_text_pre(display: &str) -> String {372 +    let mut out = String::from("<pre class=\"code\">\n");373 +    if display.is_empty() {374 +        out.push_str("<span class=\"line\"><span class=\"ln\">1</span> </span>\n");375 +    } else {376 +        for (i, line) in display.split('\n').enumerate() {377 +            out.push_str("<span class=\"line\"><span class=\"ln\">");378 +            out.push_str(&(i + 1).to_string());379 +            out.push_str("</span> ");380 +            out.push_str(&esc(line));381 +            out.push_str("</span>\n");382 +        }383 +    }384 +    out.push_str("</pre>\n");385 +    out386 +}387 +388 +fn nav_code() -> String {389 +    "<a href=\"/\">code</a> <!-- TODO(m3): commits/issues/pulls/tasks/ci/releases/wiki tabs -->"390 +        .to_string()391 +}392 +393 +/// Write `out/style.css` from the embedded static file.394 +fn write_style(out: &Path) -> Result<(), String> {395 +    let css = include_str!("../static/style.css");396 +    std::fs::write(out.join("style.css"), css).map_err(|e| format!("hu13: write style.css: {e}"))397 +}398 +399 +/// Build the full static site. Returns a summary on success.400 +pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSummary, String> {401 +    let _ = opts.max_log; // commits land in m2; flag accepted for CLI parity402 +    let branch = gitx::resolve_branch(repo, opts.branch_flag.as_deref())403 +        .map_err(|e| format!("hu13: {e}"))?;404 +    let short_sha = gitx::rev_parse_short(repo, &branch).map_err(|e| format!("hu13: {e}"))?;405 +    let clone_url = resolve_clone_url(repo, opts.clone_url_flag.as_deref());406 +407 +    let name = match &opts.title_override {408 +        Some(t) if !t.trim().is_empty() => t.trim().to_string(),409 +        _ => repo410 +            .file_name()411 +            .map(|n| n.to_string_lossy().into_owned())412 +            .filter(|n| !n.is_empty())413 +            .unwrap_or_else(|| "repo".to_string()),414 +    };415 +416 +    // Walk the whole tree (dirs-first order from gitx), remembering each417 +    // dir's entries so dir pages need no second pass.418 +    let mut dir_entries: BTreeMap<String, Vec<gitx::TreeEntry>> = BTreeMap::new();419 +    let mut dirs: Vec<String> = vec![String::new()];420 +    let mut files: Vec<String> = Vec::new();421 +    let mut queue: Vec<String> = vec![String::new()];422 +    while let Some(dir) = queue.pop() {423 +        let entries = gitx::ls_tree(repo, &branch, &dir).map_err(|e| format!("hu13: {e}"))?;424 +        for e in &entries {425 +            let full = if dir.is_empty() {426 +                e.name.clone()427 +            } else {428 +                format!("{dir}/{}", e.name)429 +            };430 +            if e.is_dir {431 +                queue.push(full);432 +            } else {433 +                files.push(full);434 +            }435 +        }436 +        dir_entries.insert(dir.clone(), entries);437 +        let _ = dirs.len();438 +    }439 +    dirs = dir_entries.keys().cloned().collect();440 +441 +    let readme = find_readme(repo, &branch);442 +    let description = readme.as_ref().and_then(|(_, blob)| {443 +        blob.display444 +            .lines()445 +            .map(str::trim)446 +            .find(|l| !l.is_empty())447 +            .map(|l| {448 +                let l = l.strip_prefix('#').unwrap_or(l).trim();449 +                if l.len() > 200 {450 +                    l[..200].to_string()451 +                } else {452 +                    l.to_string()453 +                }454 +            })455 +            .filter(|l| !l.is_empty())456 +    });457 +458 +    std::fs::create_dir_all(out.join("files"))459 +        .map_err(|e| format!("hu13: create files dir: {e}"))?;460 +    std::fs::create_dir_all(out.join("raw")).map_err(|e| format!("hu13: create raw dir: {e}"))?;461 +    write_style(out)?;462 +463 +    // Raw files first: byte-identical copies.464 +    let mut blobs: BTreeMap<String, Vec<u8>> = BTreeMap::new();465 +    for f in &files {466 +        let blob = gitx::show(repo, &branch, Path::new(f)).map_err(|e| format!("hu13: {e}"))?;467 +        let dest = out.join("raw").join(f);468 +        if let Some(parent) = dest.parent() {469 +            std::fs::create_dir_all(parent).map_err(|e| format!("hu13: create raw parent: {e}"))?;470 +        }471 +        std::fs::write(&dest, &blob.bytes).map_err(|e| format!("hu13: write raw {f}: {e}"))?;472 +        blobs.insert(f.clone(), blob.bytes);473 +    }474 +475 +    // Per-path pages.476 +    for dir in &dirs {477 +        if dir.is_empty() {478 +            continue; // root is index.html479 +        }480 +        let entries = dir_entries.get(dir).cloned().unwrap_or_default();481 +        let parent = match dir.rfind('/') {482 +            Some(i) => dir[..i].to_string(),483 +            None => String::new(),484 +        };485 +        let parent_link = if parent.is_empty() {486 +            "<a href=\"/\">.. (root)</a>".to_string()487 +        } else {488 +            format!(489 +                "<a href=\"/files/{}.html\">.. ({})</a>",490 +                href_esc(&parent),491 +                esc(&parent)492 +            )493 +        };494 +        let mut body = format!(495 +            "<h1>dir: {}</h1>\n<p>{} · <a href=\"/\">code</a></p>\n<ul class=\"tree\">\n",496 +            esc(dir),497 +            parent_link498 +        );499 +        for e in &entries {500 +            let full = format!("{dir}/{}", e.name);501 +            if e.is_dir {502 +                body.push_str(&format!(503 +                    "<li class=\"dir\"><a href=\"/files/{}.html\">{}/</a></li>\n",504 +                    href_esc(&full),505 +                    esc(&e.name)506 +                ));507 +            } else {508 +                body.push_str(&format!(509 +                    "<li class=\"file\"><a href=\"/files/{}.html\">{}</a> (<a href=\"/raw/{}\">raw</a>)</li>\n",510 +                    href_esc(&full),511 +                    esc(&e.name),512 +                    href_esc(&full)513 +                ));514 +            }515 +        }516 +        body.push_str("</ul>\n");517 +        let html = crate::layout::page(&format!("{dir} — {name}"), &nav_code(), &body);518 +        let dest = out.join("files").join(format!("{dir}.html"));519 +        if let Some(parent) = dest.parent() {520 +            std::fs::create_dir_all(parent)521 +                .map_err(|e| format!("hu13: create files parent: {e}"))?;522 +        }523 +        std::fs::write(&dest, html).map_err(|e| format!("hu13: write files page: {e}"))?;524 +    }525 +526 +    for f in &files {527 +        let bytes = blobs.get(f).cloned().unwrap_or_default();528 +        let parent = match f.rfind('/') {529 +            Some(i) => f[..i].to_string(),530 +            None => String::new(),531 +        };532 +        let parent_link = if parent.is_empty() {533 +            "<a href=\"/\">.. (root)</a>".to_string()534 +        } else {535 +            format!(536 +                "<a href=\"/files/{}.html\">.. ({})</a>",537 +                href_esc(&parent),538 +                esc(&parent)539 +            )540 +        };541 +        let mut body = format!(542 +            "<h1>{}</h1>\n<p>{} · <a href=\"/raw/{}\">raw</a> · <a href=\"/\">code</a></p>\n",543 +            esc(f),544 +            parent_link,545 +            href_esc(f)546 +        );547 +        if is_placeholder(&bytes) {548 +            let kind = if bytes.len() >= PREVIEW_MAX_BYTES {549 +                format!("too large to preview ({} bytes)", bytes.len())550 +            } else {551 +                "binary file".to_string()552 +            };553 +            body.push_str(&format!(554 +                "<p class=\"notice\">placeholder: {kind} — preview omitted. \555 +                 <a href=\"/raw/{}\">download the raw file</a> instead.</p>\n",556 +                href_esc(f)557 +            ));558 +        } else if is_markdown(f) {559 +            let display = String::from_utf8_lossy(&bytes).into_owned();560 +            body.push_str(&markdown_to_html(&display));561 +            body.push_str(&format!(562 +                "<p class=\"muted\"><a href=\"/raw/{}\">view raw</a></p>\n",563 +                href_esc(f)564 +            ));565 +        } else {566 +            let display = String::from_utf8_lossy(&bytes).into_owned();567 +            body.push_str(&render_text_pre(&display));568 +        }569 +        let html = crate::layout::page(&format!("{f} — {name}"), &nav_code(), &body);570 +        let dest = out.join("files").join(format!("{f}.html"));571 +        if let Some(parent) = dest.parent() {572 +            std::fs::create_dir_all(parent)573 +                .map_err(|e| format!("hu13: create files parent: {e}"))?;574 +        }575 +        std::fs::write(&dest, html).map_err(|e| format!("hu13: write files page: {e}"))?;576 +    }577 +578 +    // Snapshot last so the index can link sizes.579 +    let snap = gitx::snapshot(repo, &branch, out).map_err(|e| format!("hu13: {e}"))?;580 +    let bundle_size = std::fs::metadata(&snap.bundle)581 +        .map(|m| m.len())582 +        .unwrap_or(0);583 +    let tarball_size = std::fs::metadata(&snap.tarball)584 +        .map(|m| m.len())585 +        .unwrap_or(0);586 +587 +    // index.html header.588 +    let mut body = format!("<h1>{}</h1>\n", esc(&name));589 +    match &description {590 +        Some(d) => body.push_str(&format!("<p>{}</p>\n", esc(d))),591 +        None => body.push_str("<p class=\"muted\">No description.</p>\n"),592 +    }593 +    if let Some(url) = &clone_url {594 +        body.push_str(&format!(595 +            "<p>Clone: <code>git clone {}</code></p>\n",596 +            esc(url)597 +        ));598 +    }599 +    body.push_str(&format!(600 +        "<p>Branch <code>{}</code> @ <code>{}</code></p>\n",601 +        esc(&branch),602 +        esc(&short_sha)603 +    ));604 +    body.push_str(605 +        "<p>issues (0) · pulls (0) · tasks (0) <!-- TODO(m3): real f0rge counts --></p>\n",606 +    );607 +    body.push_str("<p>CI: unknown (stub)</p>\n");608 +    body.push_str(&format!(609 +        "<p><a href=\"/repo.bundle\">repo.bundle</a> ({bundle_size} bytes) · \610 +         <a href=\"/repo.tar.gz\">repo.tar.gz</a> ({tarball_size} bytes)</p>\n"611 +    ));612 +613 +    body.push_str("<h2>Files</h2>\n<ul class=\"tree\">\n");614 +    for e in dir_entries.get("").cloned().unwrap_or_default() {615 +        if e.is_dir {616 +            body.push_str(&format!(617 +                "<li class=\"dir\"><a href=\"/files/{}.html\">{}/</a></li>\n",618 +                href_esc(&e.name),619 +                esc(&e.name)620 +            ));621 +        } else {622 +            body.push_str(&format!(623 +                "<li class=\"file\"><a href=\"/files/{}.html\">{}</a> (<a href=\"/raw/{}\">raw</a>)</li>\n",624 +                href_esc(&e.name),625 +                esc(&e.name),626 +                href_esc(&e.name)627 +            ));628 +        }629 +    }630 +    body.push_str("</ul>\n");631 +632 +    body.push_str("<h2>README</h2>\n");633 +    match &readme {634 +        Some((path, blob)) => {635 +            body.push_str(&format!(636 +                "<p class=\"muted\">from <code>{}</code></p>\n",637 +                esc(path)638 +            ));639 +            if is_markdown(path) || path.to_lowercase().contains("readme") {640 +                body.push_str(&markdown_to_html(&blob.display));641 +            } else {642 +                body.push_str(&render_text_pre(&blob.display));643 +            }644 +        }645 +        None => body.push_str("<p class=\"muted\">No README found.</p>\n"),646 +    }647 +648 +    let index = crate::layout::page(&name, &nav_code(), &body);649 +    std::fs::write(out.join("index.html"), index)650 +        .map_err(|e| format!("hu13: write index.html: {e}"))?;651 +652 +    Ok(BuildSummary {653 +        branch,654 +        short_sha,655 +        file_count: files.len(),656 +    })657 +}658 +659 +/// Collect every file path under `dir` (relative, `/`-separated).660 +#[cfg(test)]661 +pub fn test_all_files(dir: &Path) -> Vec<std::path::PathBuf> {662 +    let mut out = Vec::new();663 +    let mut stack = vec![dir.to_path_buf()];664 +    while let Some(d) = stack.pop() {665 +        let rd = std::fs::read_dir(&d).expect("read_dir");666 +        for ent in rd {667 +            let ent = ent.expect("dir entry");668 +            let p = ent.path();669 +            if p.is_dir() {670 +                stack.push(p);671 +            } else {672 +                out.push(p.strip_prefix(dir).expect("prefix").to_path_buf());673 +            }674 +        }675 +    }676 +    out.sort();677 +    out678 +}679 +680 +#[cfg(test)]681 +mod tests {682 +    use super::*;683 +    use std::path::PathBuf;684 +    use std::process::Command;685 +    use std::sync::atomic::{AtomicU64, Ordering};686 +687 +    static COUNTER: AtomicU64 = AtomicU64::new(0);688 +689 +    fn mktemp(prefix: &str) -> PathBuf {690 +        let id = COUNTER.fetch_add(1, Ordering::SeqCst);691 +        let dir =692 +            std::env::temp_dir().join(format!("hu13-build-{prefix}-{}-{id}", std::process::id()));693 +        std::fs::create_dir_all(&dir).expect("mktemp");694 +        dir695 +    }696 +697 +    fn git(repo: &Path, args: &[&str]) {698 +        let st = Command::new("git")699 +            .arg("-C")700 +            .arg(repo)701 +            .args(args)702 +            .status()703 +            .expect("spawn git");704 +        assert!(st.success(), "git {args:?} failed in {}", repo.display());705 +    }706 +707 +    fn opts() -> BuildOptions {708 +        BuildOptions {709 +            branch_flag: None,710 +            clone_url_flag: None,711 +            title_override: None,712 +            max_log: 100,713 +        }714 +    }715 +716 +    /// Fixture: 10 files, no `.forge/` (the no-forge case) —717 +    /// README + subdir + binary + >256KB file.718 +    fn fixture_repo() -> PathBuf {719 +        let dir = mktemp("fixture");720 +        git(&dir, &["init", "-b", "main"]);721 +        git(&dir, &["config", "user.name", "hu13-test"]);722 +        git(&dir, &["config", "user.email", "[email protected]"]);723 +        git(&dir, &["config", "commit.gpgsign", "false"]);724 +        std::fs::write(725 +            dir.join("README.md"),726 +            "# Hello fixture\n\nWelcome to the **fixture** repo.\n\n<script>alert(1)</script>\n",727 +        )728 +        .unwrap();729 +        std::fs::create_dir_all(dir.join("src")).unwrap();730 +        std::fs::write(731 +            dir.join("src").join("main.rs"),732 +            "fn main() {\n    println!(\"hi\");\n}\n",733 +        )734 +        .unwrap();735 +        std::fs::write(736 +            dir.join("src").join("lib.rs"),737 +            "pub fn add(a: i32, b: i32) -> i32 {\n    a + b\n}\n",738 +        )739 +        .unwrap();740 +        std::fs::create_dir_all(dir.join("docs")).unwrap();741 +        std::fs::write(742 +            dir.join("docs").join("guide.md"),743 +            "# Guide\n\nSome *docs* here.\n",744 +        )745 +        .unwrap();746 +        std::fs::write(dir.join("notes.txt"), "plain notes\nsecond line\n").unwrap();747 +        std::fs::create_dir_all(dir.join("assets")).unwrap();748 +        let mut bin: Vec<u8> = (0u8..=255).collect();749 +        bin.extend_from_slice(b"\x00\x01\x02binary tail");750 +        std::fs::write(dir.join("assets").join("logo.bin"), &bin).unwrap();751 +        std::fs::create_dir_all(dir.join("data")).unwrap();752 +        std::fs::write(dir.join("data").join("big.txt"), vec![b'a'; 300_000]).unwrap();753 +        std::fs::create_dir_all(dir.join("sub")).unwrap();754 +        std::fs::write(dir.join("sub").join("nested.txt"), "nested\n").unwrap();755 +        std::fs::write(dir.join("sub").join("deep.md"), "# Deep\n\nnested docs\n").unwrap();756 +        std::fs::write(757 +            dir.join("LICENSE"),758 +            "MIT License\n\nPermission is hereby granted.\n",759 +        )760 +        .unwrap();761 +        git(&dir, &["add", "-A"]);762 +        git(&dir, &["commit", "-m", "fixture commit"]);763 +        dir764 +    }765 +766 +    fn build_fixture() -> (PathBuf, PathBuf) {767 +        let repo = fixture_repo();768 +        let out = mktemp("out");769 +        let summary = run_build(&repo, &out, &opts()).expect("build fixture");770 +        assert_eq!(summary.branch, "main");771 +        assert_eq!(summary.file_count, 10);772 +        assert!(!summary.short_sha.is_empty());773 +        (repo, out)774 +    }775 +776 +    #[test]777 +    fn index_has_tree_and_readme_html() {778 +        let (_repo, out) = build_fixture();779 +        let index = std::fs::read_to_string(out.join("index.html")).expect("index");780 +        // File tree (dirs-first flat root listing).781 +        for name in ["src", "docs", "assets", "data", "sub"] {782 +            assert!(index.contains(name), "tree should list {name}");783 +        }784 +        for name in ["README.md", "notes.txt", "LICENSE"] {785 +            assert!(index.contains(name), "tree should list {name}");786 +        }787 +        // README rendered to HTML (markdown heading), raw HTML escaped.788 +        assert!(index.contains("Hello fixture"), "README body rendered");789 +        assert!(790 +            !index.contains("<script>alert(1)</script>"),791 +            "raw HTML escaped"792 +        );793 +        assert!(index.contains("&lt;script&gt;"), "escaped HTML present");794 +        // Header: branch + short sha, f0rge counts stub, CI stub.795 +        assert!(index.contains("main"), "branch shown");796 +        assert!(index.contains("TODO(m3)"), "f0rge-count TODO marker");797 +        assert!(index.contains("issues (0)"), "f0rge counts as 0");798 +        assert!(index.contains("CI:"), "CI verdict stub");799 +        // Snapshot links with sizes.800 +        assert!(index.contains("repo.bundle"), "bundle linked");801 +        assert!(index.contains("repo.tar.gz"), "tarball linked");802 +        // No clone URL line when no origin remote is configured.803 +        assert!(!index.contains("git clone"), "no URL line without remote");804 +    }805 +806 +    #[test]807 +    fn clone_url_flag_wins() {808 +        let repo = fixture_repo();809 +        let out = mktemp("out-flag");810 +        let mut o = opts();811 +        o.clone_url_flag = Some("https://example.com/x.git".to_string());812 +        run_build(&repo, &out, &o).expect("build");813 +        let index = std::fs::read_to_string(out.join("index.html")).expect("index");814 +        assert!(index.contains("git clone https://example.com/x.git"));815 +    }816 +817 +    #[test]818 +    fn raw_byte_identical_for_all_files() {819 +        let (repo, out) = build_fixture();820 +        let raws = test_all_files(&out.join("raw"));821 +        assert_eq!(raws.len(), 10, "10 raw files, got {raws:?}");822 +        for rel in &raws {823 +            let want = std::fs::read(repo.join(rel)).expect("repo file");824 +            let got = std::fs::read(out.join("raw").join(rel)).expect("raw file");825 +            assert_eq!(got, want, "byte-identical: {}", rel.display());826 +        }827 +    }828 +829 +    #[test]830 +    fn placeholder_for_binary_and_big_file() {831 +        let (_repo, out) = build_fixture();832 +        for page in ["files/assets/logo.bin.html", "files/data/big.txt.html"] {833 +            let html = std::fs::read_to_string(out.join(page)).expect(page);834 +            assert!(html.contains("placeholder"), "{page} has placeholder");835 +            assert!(html.contains("/raw/"), "{page} links a download");836 +        }837 +        // Binary bytes must never be inlined into the page.838 +        let bin_page =839 +            std::fs::read_to_string(out.join("files/assets/logo.bin.html")).expect("bin page");840 +        assert!(!bin_page.contains("binary tail"), "binary not inlined");841 +        // Small text file gets line numbers + raw link, no placeholder.842 +        let txt = std::fs::read_to_string(out.join("files/notes.txt.html")).expect("txt page");843 +        assert!(txt.contains("/raw/notes.txt"), "raw link");844 +        assert!(!txt.contains("placeholder"), "text file inlined");845 +        assert!(846 +            txt.contains("&lt;") || txt.contains("plain notes"),847 +            "escaped pre body"848 +        );849 +    }850 +851 +    #[test]852 +    fn md_files_are_rendered() {853 +        let (_repo, out) = build_fixture();854 +        let html = std::fs::read_to_string(out.join("files/docs/guide.md.html")).expect("md page");855 +        assert!(856 +            html.contains("<h1>Guide</h1>"),857 +            "markdown rendered, got: {html}"858 +        );859 +        assert!(html.contains("/raw/docs/guide.md"), "raw link kept");860 +    }861 +862 +    #[test]863 +    fn bundle_clones() {864 +        let (_repo, out) = build_fixture();865 +        assert!(out.join("repo.bundle").exists());866 +        assert!(out.join("repo.tar.gz").exists());867 +        let dest_parent = mktemp("clone");868 +        let dest = dest_parent.join("cloned");869 +        let st = Command::new("git")870 +            .arg("clone")871 +            .arg("-b")872 +            .arg("main")873 +            .arg(out.join("repo.bundle"))874 +            .arg(&dest)875 +            .status()876 +            .expect("git clone");877 +        assert!(st.success(), "bundle clones");878 +        assert!(dest.join("README.md").exists());879 +        assert!(dest.join("sub").join("nested.txt").exists());880 +    }881 +882 +    #[test]883 +    fn no_forge_build_succeeds() {884 +        // Fixture has no `.forge/` at all; build must still exit Ok (0).885 +        let repo = fixture_repo();886 +        assert!(!repo.join(".forge").exists());887 +        let out = mktemp("out-noforge");888 +        let res = run_build(&repo, &out, &opts());889 +        assert!(res.is_ok(), "no-forge build exits 0, got {res:?}");890 +        assert!(out.join("index.html").exists());891 +    }892 +893 +    #[test]894 +    fn readme_chain_prefers_root_then_wiki() {895 +        let dir = mktemp("readme-chain");896 +        git(&dir, &["init", "-b", "main"]);897 +        git(&dir, &["config", "user.name", "hu13-test"]);898 +        git(&dir, &["config", "user.email", "[email protected]"]);899 +        git(&dir, &["config", "commit.gpgsign", "false"]);900 +        // Wiki-only README first.901 +        std::fs::create_dir_all(dir.join(".forge").join("wiki")).unwrap();902 +        std::fs::write(903 +            dir.join(".forge").join("wiki").join("README.md"),904 +            "# Wiki readme\n",905 +        )906 +        .unwrap();907 +        std::fs::write(dir.join("notes.txt"), "x\n").unwrap();908 +        git(&dir, &["add", "-A"]);909 +        git(&dir, &["commit", "-m", "wiki readme only"]);910 +        let (path, _) = find_readme(&dir, "main").expect("wiki fallback");911 +        assert_eq!(path, ".forge/wiki/README.md");912 +        // Root README.md wins once added.913 +        std::fs::write(dir.join("README.md"), "# Root readme\n").unwrap();914 +        git(&dir, &["add", "-A"]);915 +        git(&dir, &["commit", "-m", "root readme"]);916 +        let (path, _) = find_readme(&dir, "main").expect("root");917 +        assert_eq!(path, "README.md");918 +    }919 +920 +    #[test]921 +    fn markdown_escapes_raw_html() {922 +        let html = markdown_to_html("# hi\n\n<script>alert(1)</script>\n\n**bold**\n");923 +        assert!(html.contains("<h1>hi</h1>"), "heading renders");924 +        assert!(!html.contains("<script>"), "no raw passthrough");925 +        assert!(html.contains("&lt;script&gt;"), "escaped instead");926 +        assert!(927 +            html.contains("<strong>bold</strong>"),928 +            "legit markdown intact"929 +        );930 +    }931 +}932 diff --git a/src/main.rs b/src/main.rs933 index 100cafe..f7902e5 100644934 --- a/src/main.rs935 +++ b/src/main.rs936 @@ -1,3 +1,4 @@937 +mod build;938  mod gitx;939  mod layout;940  941 @@ -27,7 +28,7 @@ Run `hu13 <subcommand> --help` for subcommand help.942  fn print_build_help() {943      print!(944          "\945 -hu13-build — build a static site from a repo checkout (stub)946 +hu13-build — build a static site from a repo checkout947  948  USAGE:949      hu13 build <REPO> -o <OUT> [OPTIONS]950 @@ -42,8 +43,6 @@ OPTIONS:951          --title <S>        Site title override952          --max-log <N>      Max commits in log [default: 100]953      -h, --help             Print this help954 -955 -NOTE: m1/scaffold stub — prints help and exits 0, no output written yet.956  "957      );958  }959 @@ -98,8 +97,102 @@ fn run_build(args: &[String]) -> i32 {960          print_build_help();961          return 0;962      }963 -    print_build_help();964 -    0965 +    let mut repo: Option<String> = None;966 +    let mut out: Option<String> = None;967 +    let mut branch: Option<String> = None;968 +    let mut clone_url: Option<String> = None;969 +    let mut title: Option<String> = None;970 +    let mut max_log: usize = 100;971 +    let mut i = 0;972 +    while i < args.len() {973 +        match args[i].as_str() {974 +            "-o" | "--output" => {975 +                i += 1;976 +                if i >= args.len() {977 +                    eprintln!("hu13 build: -o needs a value");978 +                    return 2;979 +                }980 +                out = Some(args[i].clone());981 +            }982 +            "--branch" => {983 +                i += 1;984 +                if i >= args.len() {985 +                    eprintln!("hu13 build: --branch needs a value");986 +                    return 2;987 +                }988 +                branch = Some(args[i].clone());989 +            }990 +            "--clone-url" => {991 +                i += 1;992 +                if i >= args.len() {993 +                    eprintln!("hu13 build: --clone-url needs a value");994 +                    return 2;995 +                }996 +                clone_url = Some(args[i].clone());997 +            }998 +            "--title" => {999 +                i += 1;1000 +                if i >= args.len() {1001 +                    eprintln!("hu13 build: --title needs a value");1002 +                    return 2;1003 +                }1004 +                title = Some(args[i].clone());1005 +            }1006 +            "--max-log" => {1007 +                i += 1;1008 +                if i >= args.len() {1009 +                    eprintln!("hu13 build: --max-log needs a value");1010 +                    return 2;1011 +                }1012 +                match args[i].parse::<usize>() {1013 +                    Ok(n) => max_log = n,1014 +                    Err(_) => {1015 +                        eprintln!("hu13 build: --max-log must be a number");1016 +                        return 2;1017 +                    }1018 +                }1019 +            }1020 +            s if s.starts_with('-') => {1021 +                eprintln!("hu13 build: unknown option '{s}'");1022 +                return 2;1023 +            }1024 +            s => {1025 +                if repo.is_some() {1026 +                    eprintln!("hu13 build: unexpected argument '{s}'");1027 +                    return 2;1028 +                }1029 +                repo = Some(s.to_string());1030 +            }1031 +        }1032 +        i += 1;1033 +    }1034 +    let (Some(repo), Some(out)) = (repo, out) else {1035 +        eprintln!("hu13 build: usage: hu13 build <REPO> -o <OUT> [--branch X] [--clone-url URL] [--title S] [--max-log N]");1036 +        return 2;1037 +    };1038 +    let opts = build::BuildOptions {1039 +        branch_flag: branch,1040 +        clone_url_flag: clone_url,1041 +        title_override: title,1042 +        max_log,1043 +    };1044 +    match build::run_build(1045 +        std::path::Path::new(&repo),1046 +        std::path::Path::new(&out),1047 +        &opts,1048 +    ) {1049 +        Ok(summary) => {1050 +            println!(1051 +                "hu13: built branch {} @ {} ({} files) -> {}",1052 +                summary.branch, summary.short_sha, summary.file_count, out1053 +            );1054 +            01055 +        }1056 +        Err(e) => {1057 +            eprintln!("{e}");1058 +            11059 +        }1060 +    }1061  }1062  1063  fn run_serve(args: &[String]) -> i32 {1064 diff --git a/tests/build_cli.rs b/tests/build_cli.rs1065 new file mode 1006441066 index 0000000..652b59c1067 --- /dev/null1068 +++ b/tests/build_cli.rs1069 @@ -0,0 +1,56 @@1070 +//! CLI-level test: `hu13 build` on a repo without `.forge/` exits 0.1071 +1072 +use std::path::{Path, PathBuf};1073 +use std::process::Command;1074 +1075 +fn git(repo: &Path, args: &[&str]) {1076 +    let st = Command::new("git")1077 +        .arg("-C")1078 +        .arg(repo)1079 +        .args(args)1080 +        .status()1081 +        .expect("spawn git");1082 +    assert!(st.success(), "git {args:?} failed");1083 +}1084 +1085 +#[test]1086 +fn build_no_forge_repo_exits_zero() {1087 +    let dir = std::env::temp_dir().join(format!(1088 +        "hu13-cli-noforge-{}-{}",1089 +        std::process::id(),1090 +        std::time::SystemTime::now()1091 +            .duration_since(std::time::UNIX_EPOCH)1092 +            .map(|d| d.as_nanos())1093 +            .unwrap_or(0)1094 +    ));1095 +    std::fs::create_dir_all(&dir).unwrap();1096 +    git(&dir, &["init", "-b", "main"]);1097 +    git(&dir, &["config", "user.name", "hu13-test"]);1098 +    git(&dir, &["config", "user.email", "[email protected]"]);1099 +    git(&dir, &["config", "commit.gpgsign", "false"]);1100 +    std::fs::write(dir.join("README.md"), "# cli fixture\n").unwrap();1101 +    std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();1102 +    git(&dir, &["add", "-A"]);1103 +    git(&dir, &["commit", "-m", "init"]);1104 +    assert!(!dir.join(".forge").exists());1105 +1106 +    let out: PathBuf = dir.join("..").join(format!(1107 +        "hu13-cli-out-{}-{}",1108 +        std::process::id(),1109 +        std::time::SystemTime::now()1110 +            .duration_since(std::time::UNIX_EPOCH)1111 +            .map(|d| d.as_nanos())1112 +            .unwrap_or(1)1113 +    ));1114 +    let bin = env!("CARGO_BIN_EXE_hu13");1115 +    let st = Command::new(bin)1116 +        .arg("build")1117 +        .arg(&dir)1118 +        .arg("-o")1119 +        .arg(&out)1120 +        .status()1121 +        .expect("spawn hu13");1122 +    assert!(st.success(), "no-forge build exits 0, got {st}");1123 +    assert!(out.join("index.html").exists());1124 +    assert!(out.join("raw").join("README.md").exists());1125 +}1126 -- 1127 2.55.01128