src/build.rs 129.6 KiB 3266 lines Raw
1 //! `hu13 build` — static site (m1 code tab + m2 commits/wiki + m3 f0rge readers + releases/api/agents).2 //!3 //! Reads one branch via [`crate::gitx`], emits `dist/`:4 //! `index.html`, `files/<path>.html`, `raw/<path>` (byte-identical),5 //! `commits.html`, `commit/<sha>.html`, `feed.atom`, `issues/index.html`,6 //! `issues/<uuid>.html`, `pulls/index.html`, `pulls/<uuid>.html`,7 //! `tasks/index.html`, `ci/index.html`, `releases/index.html`,8 //! `releases/<ver>.html`, `releases/<ver>/<file>` artifacts,9 //! `wiki/index.html`, `wiki/<path>.html`, `api/*.json`, `agents.html` +10 //! `agents.md`, `repo.bundle` + `repo.tar.gz` + `repo.git/` (dumb-HTTP,11 //! issue #54: `git clone <origin>/repo.git` works on any static server),12 //! and `style.css`.13 //!14 //! Bodies from the repo are semi-trusted but treated as untrusted:15 //! markdown goes through [`markdown_to_html`], which escapes raw HTML;16 //! commit messages and diffs are escaped `<pre>` (never markdown).17 //!18 //! Machine JSON (`api/*.json`) and HTML are built from one struct pass over19 //! the same loaded readers (no divergent logic): `forge::load`,20 //! `commits::load`, `releases::load`, `wiki::render`.21 22 use std::collections::BTreeMap;23 use std::path::Path;24 25 use crate::layout::esc;26 use crate::{agents, api, commits, feed, forge, gitx, releases, wiki};27 28 /// Files at or above this size get a placeholder page + download link.29 pub const PREVIEW_MAX_BYTES: usize = 256 * 1024;30 31 /// Options for [`run_build`] (flags only, no config file).32 #[derive(Debug, Clone)]33 pub struct BuildOptions {34     /// `--branch` flag (else `$HU13_BRANCH`, else main/master).35     pub branch_flag: Option<String>,36     /// `--clone-url` flag (else git origin remote, else none).37     pub clone_url_flag: Option<String>,38     /// `--public-origin` flag (else `$HU13_PUBLIC_ORIGIN`, else unset).39     /// Base URL of this server, e.g. `http://host:39313`. When set, the40     /// clone block advertises `git clone <origin>/repo.git` (dumb-HTTP).41     /// Empty/whitespace falls through to unset.42     pub public_origin_flag: Option<String>,43     /// `--repo-name` flag (else `$HU13_REPO_NAME`, else repo dir name).44     /// `--title` is accepted as a deprecated alias for the same field.45     /// Empty/whitespace always falls back (the header is never blank).46     pub repo_name_flag: Option<String>,47     /// `--description` flag (else `$HU13_DESCRIPTION`, else none).48     /// Empty/whitespace means absent: the header description line is49     /// hidden entirely (no empty `<p>`).50     pub description_flag: Option<String>,51     /// `--max-log`: max commits listed in `commits.html` (default 100).52     pub max_log: usize,53 }54 55 /// Resolve the repo display name (m6, f0rge issue #24).56 ///57 /// Precedence: `--repo-name` flag > `$HU13_REPO_NAME` > directory name of58 /// `repo` > `"repo"`. Flag and env values are trimmed; empty values fall59 /// through so the header is never blank.60 #[must_use]61 pub fn resolve_repo_name(repo: &Path, flag: Option<&str>) -> String {62     if let Some(f) = flag.map(str::trim).filter(|s| !s.is_empty()) {63         return f.to_string();64     }65     if let Ok(env) = std::env::var("HU13_REPO_NAME") {66         let t = env.trim();67         if !t.is_empty() {68             return t.to_string();69         }70     }71     // `hu13 build .` must name the repo, not the path "." (f0rge #68).72     let abs = std::fs::canonicalize(repo).unwrap_or_else(|_| repo.to_path_buf());73     abs.file_name()74         .map(|n| n.to_string_lossy().into_owned())75         .map(|n| n.trim().to_string())76         .filter(|n| !n.is_empty())77         .unwrap_or_else(|| "repo".to_string())78 }79 80 /// Resolve the site description (m6, f0rge issue #21).81 ///82 /// Precedence: `--description` flag > `$HU13_DESCRIPTION` > absent83 /// (`None`). Flag and env values are trimmed; empty values fall through84 /// to absent, and an absent description hides the header line entirely.85 #[must_use]86 pub fn resolve_description(flag: Option<&str>) -> Option<String> {87     if let Some(f) = flag.map(str::trim).filter(|s| !s.is_empty()) {88         return Some(f.to_string());89     }90     if let Ok(env) = std::env::var("HU13_DESCRIPTION") {91         let t = env.trim();92         if !t.is_empty() {93             return Some(t.to_string());94         }95     }96     None97 }98 99 /// Summary of one successful build (for logs/tests).100 #[derive(Debug, Clone)]101 pub struct BuildSummary {102     /// Branch that was rendered.103     pub branch: String,104     /// Short sha of the branch head.105     pub short_sha: String,106     /// Number of files emitted under `raw/`.107     pub file_count: usize,108 }109 110 /// Decode the HTML entities a browser would decode before treating a111 /// URL as a scheme (`javascript&colon;…`, `&#106;avascript:…`).112 ///113 /// `pulldown-cmark` writes `dest_url` into `href`/`src` and then escapes114 /// it; a destination that still contains `&colon;` will be decoded by the115 /// *browser* into `:` — so the scheme check has to decode it first.116 fn decode_entities(s: &str) -> String {117     let mut out = String::with_capacity(s.len());118     let mut rest = s;119     while let Some(i) = rest.find('&') {120         out.push_str(&rest[..i]);121         let tail = &rest[i + 1..];122         let Some(semi) = tail.find(';') else {123             out.push('&');124             rest = tail;125             continue;126         };127         let name = &tail[..semi];128         let decoded: Option<char> = match name {129             "colon" | "Colon" => Some(':'),130             "sol" | "Tab" | "NewLine" => Some(match name {131                 "Tab" => '\t',132                 "NewLine" => '\n',133                 _ => '/',134             }),135             "amp" => Some('&'),136             "lt" => Some('<'),137             "gt" => Some('>'),138             "quot" => Some('"'),139             "apos" => Some('\''),140             "nbsp" => Some('\u{a0}'),141             _ => name.strip_prefix('#').and_then(|n| {142                 let n = n.strip_prefix(['x', 'X']).map_or_else(143                     || n.parse::<u32>().ok(),144                     |hex| u32::from_str_radix(hex, 16).ok(),145                 );146                 n.and_then(char::from_u32)147             }),148         };149         match decoded {150             Some(ch) => {151                 out.push(ch);152                 rest = &tail[semi + 1..];153             }154             None => {155                 out.push('&');156                 rest = tail;157             }158         }159     }160     out.push_str(rest);161     out162 }163 164 /// `true` when `url` may be emitted into `href`/`src` (f0rge #62).165 ///166 /// Only `http`, `https`, `mailto` and `tel` are allowed as schemes.167 /// Relative URLs (no scheme) are allowed. `javascript:`, `data:`,168 /// `vbscript:`, `file:` etc. are rejected — including when obfuscated169 /// with entities, control characters or mixed case.170 fn safe_url(url: &str) -> bool {171     let decoded = decode_entities(url);172     // Browsers strip tab/newline/CR and leading/trailing C0/space before173     // parsing the scheme.174     let cleaned: String = decoded175         .chars()176         .filter(|c| !c.is_ascii_control() && !c.is_whitespace())177         .collect();178     let lower = cleaned.to_ascii_lowercase();179     let Some(colon) = lower.find(':') else {180         return true; // no scheme: relative path181     };182     let scheme = &lower[..colon];183     if scheme.is_empty()184         || !scheme.chars().enumerate().all(|(i, c)| {185             c.is_ascii_alphabetic()186                 || (i > 0 && (c.is_ascii_digit() || matches!(c, '+' | '-' | '.')))187         })188     {189         return true; // `./a:b`, `#x:y`, `?q=1:2` — not a scheme190     }191     matches!(scheme, "http" | "https" | "mailto" | "tel")192 }193 194 /// Context for relative markdown destinations: a repo file page uses195 /// `/files/...` + `/raw/...` + `/assets/...`; a wiki page uses196 /// `/wiki/...` + raw/assets for non-page files.197 #[derive(Clone, Copy)]198 enum LinkBase<'a> {199     Repo(&'a str),200     Wiki(&'a str),201 }202 203 fn base_path(base: LinkBase<'_>) -> &str {204     match base {205         LinkBase::Repo(p) | LinkBase::Wiki(p) => p,206     }207 }208 209 /// Lexically resolve a relative link against a repo- or wiki-relative210 /// base directory. Returns `None` when it climbs above the root or has211 /// unsafe/absolute components.212 fn rel_join(base: &str, path: &str) -> Option<String> {213     if path.starts_with('/') || path.contains(['\\', '\0']) {214         return None;215     }216     let mut parts: Vec<&str> = base.split('/').filter(|s| !s.is_empty()).collect();217     for seg in path.split('/') {218         match seg {219             "" | "." => {}220             ".." => {221                 parts.pop()?;222             }223             other => parts.push(other),224         }225     }226     if parts.is_empty() {227         return None;228     }229     Some(parts.join("/"))230 }231 232 /// Extensions safe to display inline from `assets/`: raster/static233 /// images whose bytes cannot execute as a document. SVG and HTML stay234 /// downloads under `raw/`.235 fn is_inline_image(path: &str) -> bool {236     let base = path.rsplit('/').next().unwrap_or(path);237     let Some((stem, ext)) = base.rsplit_once('.') else {238         return false;239     };240     !stem.is_empty()241         && matches!(242             ext.to_ascii_lowercase().as_str(),243             "png" | "jpg" | "jpeg" | "gif" | "webp" | "ico" | "avif"244         )245 }246 247 /// Rewrite one relative markdown destination to the generated route.248 /// External URLs, fragments, absolute paths and anything that cannot be249 /// resolved stay as-is. Query/fragment suffixes are preserved.250 fn rewrite_dest(dest: &str, base: LinkBase<'_>, image: bool) -> Option<String> {251     let d = dest.trim();252     if d.is_empty() || d.starts_with(['#', '/']) || d.starts_with("//") {253         return None;254     }255     // A real scheme (`http:`, `mailto:`, ...) is not a relative file.256     if let Some(colon) = d.find(':') {257         let scheme = &d[..colon];258         let mut chars = scheme.chars();259         if chars.next().is_some_and(|c| c.is_ascii_alphabetic())260             && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))261         {262             return None;263         }264     }265     let cut = d.find(['?', '#']).unwrap_or(d.len());266     let (path, suffix) = d.split_at(cut);267     let target = rel_join(base_path(base), path)?;268     let target = target.trim_end_matches('/');269     if target.is_empty() {270         return None;271     }272     let enc = encode_path(target);273     let asset = match base {274         LinkBase::Wiki(_) => format!("/assets/.forge/wiki/{enc}"),275         LinkBase::Repo(_) => format!("/assets/{enc}"),276     };277     let route = if is_inline_image(target) {278         asset279     } else if image {280         format!("/raw/{enc}")281     } else {282         match base {283             LinkBase::Wiki(_) => format!("/wiki/{enc}.html"),284             LinkBase::Repo(_) => {285                 let name = target.rsplit('/').next().unwrap_or(target);286                 if is_markdown(target) || name.eq_ignore_ascii_case("readme") {287                     format!("/files/{enc}.html")288                 } else {289                     format!("/raw/{enc}")290                 }291             }292         }293     };294     Some(format!("{route}{suffix}"))295 }296 297 /// Strip simple inline/block tags for a heading slug. Entity decoding298 /// runs afterwards so `&amp;` becomes a word separator, not `amp`.299 fn heading_text(inner: &str) -> String {300     let mut out = String::with_capacity(inner.len());301     let mut in_tag = false;302     for c in inner.chars() {303         match c {304             '<' => in_tag = true,305             '>' => in_tag = false,306             _ if !in_tag => out.push(c),307             _ => {}308         }309     }310     decode_entities(&out)311 }312 313 fn slugify(text: &str) -> String {314     let mut out = String::new();315     let mut dash = false;316     for c in text.chars().flat_map(char::to_lowercase) {317         if c.is_alphanumeric() {318             out.push(c);319             dash = false;320         } else if !out.is_empty() && !dash {321             out.push('-');322             dash = true;323         }324     }325     while out.ends_with('-') {326         out.pop();327     }328     if out.is_empty() {329         "section".to_string()330     } else {331         out332     }333 }334 335 fn heading_parts(line: &str) -> Option<(usize, &str, &str)> {336     let b = line.as_bytes();337     if b.len() < 8 || b[0] != b'<' || b[1] != b'h' || !(b'1'..=b'6').contains(&b[2]) || b[3] != b'>'338     {339         return None;340     }341     let level = usize::from(b[2] - b'0');342     let close = format!("</h{level}>");343     let end = line.find(&close)?;344     Some((level, &line[4..end], &line[end + close.len()..]))345 }346 347 /// Add deterministic heading ids so `#section` links work. Input is the348 /// renderer's own output, so headings are one per line and contain only349 /// generated tags; code-block lines are entity-escaped and never match.350 fn add_heading_ids(html: &str) -> String {351     let mut out = String::with_capacity(html.len() + 64);352     let mut seen: BTreeMap<String, usize> = BTreeMap::new();353     for line in html.split_inclusive('\n') {354         if let Some((level, inner, tail)) = heading_parts(line) {355             let base = slugify(&heading_text(inner));356             let count = seen.entry(base.clone()).or_insert(0);357             *count += 1;358             let id = if *count == 1 {359                 base360             } else {361                 format!("{base}-{}", *count)362             };363             out.push_str(&format!("<h{level} id=\"{id}\">{inner}</h{level}>{tail}"));364         } else {365             out.push_str(line);366         }367     }368     out369 }370 371 fn render_markdown(md: &str, base: Option<LinkBase<'_>>) -> String {372     use pulldown_cmark::{html, Event, Options, Parser, Tag};373     let options = Options::ENABLE_TABLES374         | Options::ENABLE_TASKLISTS375         | Options::ENABLE_STRIKETHROUGH376         | Options::ENABLE_FOOTNOTES;377     let parser = Parser::new_ext(md, options);378     let mapped = parser.map(|ev| match ev {379         Event::Html(h) => Event::Html(esc(&h).into()),380         Event::InlineHtml(h) => Event::InlineHtml(esc(&h).into()),381         Event::Start(Tag::Link {382             link_type,383             dest_url,384             title,385             id,386         }) => {387             let dest = if safe_url(&dest_url) {388                 let decoded = decode_entities(&dest_url);389                 match base.and_then(|b| rewrite_dest(&decoded, b, false)) {390                     Some(rewritten) => rewritten,391                     None => decoded,392                 }393             } else {394                 "#".to_string()395             };396             Event::Start(Tag::Link {397                 link_type,398                 dest_url: dest.into(),399                 title,400                 id,401             })402         }403         Event::Start(Tag::Image {404             link_type,405             dest_url,406             title,407             id,408         }) => {409             let dest = if safe_url(&dest_url) {410                 let decoded = decode_entities(&dest_url);411                 match base.and_then(|b| rewrite_dest(&decoded, b, true)) {412                     Some(rewritten) => rewritten,413                     None => decoded,414                 }415             } else {416                 "#".to_string()417             };418             Event::Start(Tag::Image {419                 link_type,420                 dest_url: dest.into(),421                 title,422                 id,423             })424         }425         other => other,426     });427     let mut out = String::from("<div class=\"markdown\">\n");428     html::push_html(&mut out, mapped);429     out.push_str("</div>\n");430     add_heading_ids(&out)431 }432 433 /// Render markdown to HTML, escaping raw HTML.434 ///435 /// `pulldown-cmark` passes `Html`/`InlineHtml` events through untouched436 /// (R1 finding), so map them through [`esc`] first. GFM tables, task437 /// lists and strikethrough are enabled (f0rge #71); link/image438 /// destinations are scheme-filtered (f0rge #62). Headings get stable439 /// ids so `#section` links resolve (f0rge #112).440 #[must_use]441 pub fn markdown_to_html(md: &str) -> String {442     render_markdown(md, None)443 }444 445 /// Render markdown from a repo file page or wiki page, rewriting446 /// relative links/images to generated routes (f0rge #112).447 #[must_use]448 pub fn markdown_to_html_in(md: &str, base: &str, wiki: bool) -> String {449     let base = if wiki {450         LinkBase::Wiki(base)451     } else {452         LinkBase::Repo(base)453     };454     render_markdown(md, Some(base))455 }456 457 /// Resolve the clone URL to advertise.458 ///459 /// Precedence: `--clone-url` flag > `git remote get-url origin` at build460 /// time > none (`None`, and the index shows no URL line).461 #[must_use]462 pub fn resolve_clone_url(repo: &Path, flag: Option<&str>) -> Option<String> {463     if let Some(f) = flag.map(str::trim).filter(|s| !s.is_empty()) {464         return Some(f.to_string());465     }466     let out = std::process::Command::new("git")467         .arg("-C")468         .arg(repo)469         .arg("remote")470         .arg("get-url")471         .arg("origin")472         .output();473     match out {474         Ok(o) if o.status.success() => {475             let s = String::from_utf8_lossy(&o.stdout).trim().to_string();476             if s.is_empty() {477                 None478             } else {479                 Some(s)480             }481         }482         _ => None,483     }484 }485 486 /// Resolve the public origin base URL (m6, f0rge issue #32).487 ///488 /// Precedence: `--public-origin` flag > `$HU13_PUBLIC_ORIGIN` > unset489 /// (`None`). Flag and env values are trimmed; empty values fall through490 /// to unset. A single trailing `/` run is stripped so491 /// `<origin>/repo.bundle` never doubles the slash.492 #[must_use]493 pub fn resolve_public_origin(flag: Option<&str>) -> Option<String> {494     if let Some(f) = flag.map(str::trim).filter(|s| !s.is_empty()) {495         let n = f.trim_end_matches('/');496         if !n.is_empty() {497             return Some(n.to_string());498         }499     }500     if let Ok(env) = std::env::var("HU13_PUBLIC_ORIGIN") {501         let t = env.trim();502         if !t.is_empty() {503             let n = t.trim_end_matches('/');504             if !n.is_empty() {505                 return Some(n.to_string());506             }507         }508     }509     None510 }511 512 /// README lookup chain (f0rge convention): repo-root `README.md` >513 /// `readme.md` > `README` > `readme`, else `.forge/wiki/README.md` >514 /// `.forge/wiki/readme.md` > `.forge/wiki/README` > `.forge/wiki/readme`,515 /// else none.516 ///517 /// Returns the winning path plus its blob.518 pub fn find_readme(repo: &Path, branch: &str) -> Option<(String, gitx::Blob)> {519     for candidate in [520         "README.md",521         "readme.md",522         "README",523         "readme",524         ".forge/wiki/README.md",525         ".forge/wiki/readme.md",526         ".forge/wiki/README",527         ".forge/wiki/readme",528     ] {529         let fits = gitx::blob_size(repo, branch, Path::new(candidate))530             .map(|size| size <= README_MAX_BYTES)531             .unwrap_or(false);532         if fits {533             if let Ok(blob) = gitx::show(repo, branch, Path::new(candidate)) {534                 return Some((candidate.to_string(), blob));535             }536         }537     }538     None539 }540 541 /// `true` when a blob must not be inlined (binary or over the cap).542 #[must_use]543 pub fn is_placeholder(bytes: &[u8]) -> bool {544     bytes.len() >= PREVIEW_MAX_BYTES || bytes.contains(&0)545 }546 547 /// Blobs above this size are streamed straight to `raw/` instead of548 /// being held in memory (f0rge #66). Reading every blob fully used to549 /// abort the process in the allocator on a few-hundred-MB file; the550 /// page for such a blob is a placeholder anyway, so only the raw copy551 /// needs the bytes.552 pub const MAX_INLINE_BLOB: u64 = 8 * 1024 * 1024;553 554 /// READMEs above this size are ignored (next candidate in the chain is555 /// tried). Prevents a huge README from bloating every page's preview.556 pub const README_MAX_BYTES: u64 = 1024 * 1024;557 558 /// Cloudflare Pages Free deployment limits (f0rge #81). `raw/` mirrors559 /// every blob, so an ordinary asset-heavy repo can exceed these even560 /// when the build itself succeeds.561 pub const PAGES_MAX_FILE_BYTES: u64 = 25 * 1024 * 1024;562 pub const PAGES_MAX_FILES: usize = 20_000;563 564 /// Bytes sniffed for the language-math binary check (first 8KiB).565 pub const BINARY_SNIFF_BYTES: usize = 8 * 1024;566 567 /// `true` when a blob is binary for language math: the first 8KiB568 /// contain a NUL byte (m6, f0rge issue #37). Binaries stay569 /// downloadable and keep placeholder pages (see [`is_placeholder`]);570 /// they are only excluded from the language mix, never from the571 /// tree, raw output, or snapshots.572 #[must_use]573 pub fn is_binary(bytes: &[u8]) -> bool {574     bytes.iter().take(BINARY_SNIFF_BYTES).any(|b| *b == 0)575 }576 577 /// `(path, size)` rows eligible for language math.578 ///579 /// Skips `.forge/` (forge metadata: issues, PRs, patch diffs — not580 /// project code), gitignored paths (`ignored` is the set from581 /// [`gitx::ignored_paths`]) and binary blobs ([`is_binary`]).582 /// Everything kept is a real project text file; unmapped extensions583 /// still count (as `Other` via [`language_of`]). Percentages from584 /// [`compute_languages`] over this output therefore sum to counted585 /// text bytes only.586 #[must_use]587 pub fn lang_rows(588     blobs: &std::collections::BTreeMap<String, Vec<u8>>,589     ignored: &std::collections::HashSet<String>,590 ) -> Vec<(String, u64)> {591     blobs592         .iter()593         .filter(|(path, _)| !is_forge_meta(path))594         .filter(|(path, _)| !ignored.contains(path.as_str()))595         .filter(|(_, bytes)| !is_binary(bytes))596         .map(|(path, bytes)| (path.clone(), bytes.len() as u64))597         .collect()598 }599 600 /// `true` for `.forge/` metadata paths, which are excluded from601 /// language math: the forge (issues, PRs, diffs, runs) describes the602 /// project but is not its code (m6, f0rge issue #37).603 #[must_use]604 pub fn is_forge_meta(path: &str) -> bool {605     path == ".forge" || path.starts_with(".forge/")606 }607 608 /// `true` for markdown-rendered paths (`.md`/`.markdown`, any case).609 #[must_use]610 pub fn is_markdown(path: &str) -> bool {611     let lower = path.to_lowercase();612     lower.ends_with(".md") || lower.ends_with(".markdown")613 }614 615 /// Percent-encode a repo path for a URL, then HTML-escape it. Git616 /// allows `#`, `?`, `%`, spaces and quotes in names; without this the617 /// browser truncates a link at `#`/`?` (f0rge #89). `/` stays literal618 /// so file paths remain hierarchical; everything outside the unreserved619 /// set becomes `%XX` of its UTF-8 bytes.620 pub(crate) fn encode_path(path: &str) -> String {621     let mut out = String::with_capacity(path.len());622     for &b in path.as_bytes() {623         if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~' | b'/') {624             out.push(char::from(b));625         } else {626             out.push_str(&format!("%{b:02X}"));627         }628     }629     out630 }631 632 pub(crate) fn href_esc(path: &str) -> String {633     esc(&encode_path(path))634 }635 636 /// Humanized byte size (`24 B`, `2.0 KiB`, `3.0 MiB`) for meta bars637 /// and listings (m6, f0rge issue #20).638 #[must_use]639 pub fn human_size(n: u64) -> String {640     if n < 1024 {641         format!("{n} B")642     } else if n < 1024 * 1024 {643         format!("{:.1} KiB", n as f64 / 1024.0)644     } else {645         format!("{:.1} MiB", n as f64 / 1024.0 / 1024.0)646     }647 }648 649 /// Breadcrumb trail for a repo-relative `path`: `root` plus one cumulative650 /// link per segment (`/files/<acc>.html`), so every segment is clickable651 /// (m6, f0rge issue #20). No JS; the nav element labels it for AT.652 #[must_use]653 pub fn breadcrumb(path: &str) -> String {654     let mut out =655         String::from("<nav class=\"crumbs\" aria-label=\"breadcrumb\"><a href=\"/\">root</a>");656     if !path.is_empty() {657         let mut acc = String::new();658         for seg in path.split('/') {659             if !acc.is_empty() {660                 acc.push('/');661             }662             acc.push_str(seg);663             out.push_str(&format!(664                 " / <a href=\"/files/{}.html\">{}</a>",665                 href_esc(&acc),666                 esc(seg)667             ));668         }669     }670     out.push_str("</nav>\n");671     out672 }673 674 /// File meta bar: full path text (selectable, no-JS copy affordance),675 /// humanized size, optional line count, and a Raw download link676 /// (m6, f0rge issue #20).677 #[must_use]678 pub fn file_meta_bar(path: &str, size_bytes: u64, lines: Option<usize>) -> String {679     let mut out = format!(680         "<div class=\"file-meta\"><code class=\"file-path\">{}</code>\n\681          <span class=\"file-size\">{}</span>",682         esc(path),683         human_size(size_bytes)684     );685     if let Some(n) = lines {686         out.push_str(&format!("\n<span class=\"file-lines\">{n} lines</span>"));687     }688     out.push_str(&format!(689         "\n<a class=\"file-raw\" href=\"/raw/{}\">Raw</a></div>\n",690         href_esc(path)691     ));692     out693 }694 695 /// Content sha of a snapshot artifact via `git hash-object` (no new deps;696 /// git is already required to build). `None` when git cannot hash.697 #[must_use]698 pub fn file_hash(path: &Path) -> Option<String> {699     let out = std::process::Command::new("git")700         .arg("hash-object")701         .arg(path)702         .output()703         .ok()?;704     if out.status.success() {705         let s = String::from_utf8_lossy(&out.stdout).trim().to_string();706         if !s.is_empty() {707             return Some(s);708         }709     }710     None711 }712 713 /// Max commit-message chars shown in file tables (m6 code-gitea,714 /// f0rge #33, Gitea-style: short message, truncated). Research note:715 /// live Gitea page (`gitea.com/gitea/tea`, fetched 2026-09-10) renders716 /// file rows as name / commit-summary / age divs with dirs-first and a717 /// `<relative-time>` age; hu13 adapts that to a static TABLE with718 /// build-time relative ages (no JS) per the issue.719 pub const MSG_TRUNC_CHARS: usize = 72;720 721 /// Truncate a commit subject to [`MSG_TRUNC_CHARS`] chars (char-wise,722 /// never splitting a boundary). Short messages pass through; longer723 /// ones are cut to 72 chars plus a trailing `…` so truncation is724 /// visible. Callers escape the result with [`esc`] for HTML.725 #[must_use]726 pub fn truncate_msg(s: &str) -> String {727     let n = s.chars().count();728     if n <= MSG_TRUNC_CHARS {729         return s.to_string();730     }731     let kept: String = s.chars().take(MSG_TRUNC_CHARS).collect();732     format!("{kept}…")733 }734 735 /// Current unix time (seconds) for build-time relative ages.736 #[must_use]737 pub fn now_unix() -> i64 {738     std::time::SystemTime::now()739         .duration_since(std::time::UNIX_EPOCH)740         .map(|d| d.as_secs() as i64)741         .unwrap_or(0)742 }743 744 /// Parse an RFC3339 timestamp to unix seconds (fractional seconds and745 /// numeric offsets accepted; `Z` = UTC). Hand-rolled: no date dependency.746 ///747 /// Returns `None` for anything that is not a real timestamp, so callers748 /// can fall back to showing the raw string.749 #[must_use]750 pub fn parse_ts_secs(s: &str) -> Option<i64> {751     let s = s.trim();752     // Timestamps are ASCII by definition. Reject other text before any753     // byte-index slicing: a 200-char multibyte `created` used to panic754     // on a non-char-boundary (f0rge #108).755     if !s.is_ascii() {756         return None;757     }758     // Accept both `2026-10-27T14:08:48Z` (RFC3339) and git's759     // `2026-10-27 14:08:48 +0000` / `+00:00` forms.760     let (date, rest) = match s.split_once('T') {761         Some(v) => v,762         None => s.split_once(' ')?,763     };764     let mut d = date.split('-');765     let y: i64 = d.next()?.parse().ok()?;766     let mo: i64 = d.next()?.parse().ok()?;767     let da: i64 = d.next()?.parse().ok()?;768     // Keep the civil-date math overflow-free and refuse dates that never769     // existed (Feb 31 used to normalize to Mar 3; huge years panicked).770     if !(1..=9999).contains(&y) || !(1..=12).contains(&mo) {771         return None;772     }773     let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;774     let max_day = match mo {775         1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,776         4 | 6 | 9 | 11 => 30,777         2 if leap => 29,778         2 => 28,779         _ => return None,780     };781     if !(1..=max_day).contains(&da) {782         return None;783     }784     let (hms, offset) = if let Some(t) = rest.strip_suffix('Z') {785         (t, 0i64)786     } else if rest.len() >= 6787         && matches!(rest.as_bytes()[rest.len() - 6], b'+' | b'-')788         && rest.as_bytes()[rest.len() - 3] == b':'789     {790         let sign = if rest.as_bytes()[rest.len() - 6] == b'-' {791             -1792         } else {793             1794         };795         let oh: i64 = rest[rest.len() - 5..rest.len() - 3].parse().ok()?;796         let om: i64 = rest[rest.len() - 2..].parse().ok()?;797         if oh > 23 || om > 59 {798             return None;799         }800         (&rest[..rest.len() - 6], sign * (oh * 3600 + om * 60))801     } else if rest.len() >= 5 && matches!(rest.as_bytes()[rest.len() - 5], b'+' | b'-') {802         let sign = if rest.as_bytes()[rest.len() - 5] == b'-' {803             -1804         } else {805             1806         };807         let oh: i64 = rest[rest.len() - 4..rest.len() - 2].parse().ok()?;808         let om: i64 = rest[rest.len() - 2..].parse().ok()?;809         if oh > 23 || om > 59 {810             return None;811         }812         (&rest[..rest.len() - 5], sign * (oh * 3600 + om * 60))813     } else {814         (rest, 0)815     };816     let mut t = hms.split(':');817     let h: i64 = t.next()?.parse().ok()?;818     let mi: i64 = t.next()?.parse().ok()?;819     let sec: i64 = t820         .next()821         .unwrap_or("0")822         .split('.')823         .next()824         .unwrap_or("0")825         .trim()826         .parse()827         .ok()?;828     if !(0..=23).contains(&h) || !(0..=59).contains(&mi) || !(0..=60).contains(&sec) {829         return None;830     }831     // Howard Hinnant's days_from_civil.832     let y2 = if mo <= 2 { y - 1 } else { y };833     let era = if y2 >= 0 { y2 } else { y2 - 399 } / 400;834     let yoe = y2 - era * 400;835     let mp = (mo + 9) % 12;836     let doy = (153 * mp + 2) / 5 + da - 1;837     let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;838     let days = era * 146_097 + doe - 719_468;839     Some(days * 86_400 + h * 3600 + mi * 60 + sec - offset)840 }841 842 /// Short human title for a timestamp: `2026-09-10 03:52 UTC`843 /// (falls back to the input when it does not parse).844 #[must_use]845 pub fn ts_title(s: &str) -> String {846     let Some(secs) = parse_ts_secs(s) else {847         return s.to_string();848     };849     let days = secs.div_euclid(86_400);850     let rem = secs.rem_euclid(86_400);851     // Civil-from-days (inverse of days_from_civil).852     let z = days + 719_468;853     let era = if z >= 0 { z } else { z - 146_096 } / 146_097;854     let doe = z - era * 146_097;855     let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;856     let y = yoe + era * 400;857     let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);858     let mp = (5 * doy + 2) / 153;859     let d = doy - (153 * mp + 2) / 5 + 1;860     let m = if mp < 10 { mp + 3 } else { mp - 9 };861     let y = if m <= 2 { y + 1 } else { y };862     format!(863         "{y:04}-{m:02}-{d:02} {:02}:{:02} UTC",864         rem / 3600,865         (rem % 3600) / 60866     )867 }868 869 /// Build-time relative age (`3d ago` style, Gitea `relative-time`870 /// adapted to static text, no JS). `now_ts`/`then_ts` are unix seconds;871 /// future timestamps clamp to `just now`. Thresholds: ≤10s just now,872 /// <60s `Ns ago`, <1h `Nm ago`, <1d `Nh ago`, <30d `Nd ago`,873 /// <365d `Nmo ago`, else `Ny ago`.874 #[must_use]875 pub fn relative_age(now_ts: i64, then_ts: i64) -> String {876     let mut diff = now_ts.saturating_sub(then_ts);877     if diff < 0 {878         diff = 0;879     }880     if diff <= 10 {881         return "just now".to_string();882     }883     if diff < 60 {884         return format!("{diff}s ago");885     }886     if diff < 3600 {887         return format!("{}m ago", diff / 60);888     }889     if diff < 86_400 {890         return format!("{}h ago", diff / 3600);891     }892     if diff < 86_400 * 30 {893         return format!("{}d ago", diff / 86_400);894     }895     if diff < 86_400 * 365 {896         return format!("{}mo ago", diff / (86_400 * 30));897     }898     format!("{}y ago", diff / (86_400 * 365))899 }900 901 /// Language of a repo-relative path by extension (lowercased).902 /// Map (m6 code-gitea, f0rge #33): rs/py/js/ts/go/sh/css/html/md903 /// (+markdown)/toml/yaml (+yml)/json/c/h; everything else is `Other`.904 /// `Dockerfile`/`Makefile`/extensionless names are `Other` by design.905 #[must_use]906 pub fn language_of(path: &str) -> &'static str {907     let ext = match path.rsplit('.').next() {908         Some(e) if path.contains('.') => e.to_lowercase(),909         _ => String::new(),910     };911     match ext.as_str() {912         "rs" => "Rust",913         "py" => "Python",914         "js" => "JavaScript",915         "ts" => "TypeScript",916         "go" => "Go",917         "sh" => "Shell",918         "css" => "CSS",919         "html" => "HTML",920         "md" | "markdown" => "Markdown",921         "toml" => "TOML",922         "yaml" | "yml" => "YAML",923         "json" => "JSON",924         "c" | "h" => "C",925         _ => "Other",926     }927 }928 929 /// Tokyo Night hue per language for the stacked bar + legend.930 #[must_use]931 pub fn language_color(lang: &str) -> &'static str {932     match lang {933         "Rust" => "#f7768e",934         "Python" => "#7aa2f7",935         "JavaScript" => "#e0af68",936         "TypeScript" => "#7dcfff",937         "Go" => "#73daca",938         "Shell" => "#9ece6a",939         "CSS" => "#bb9af7",940         "HTML" => "#ff9e64",941         "Markdown" => "#9aa5ce",942         "TOML" => "#c0caf5",943         "YAML" => "#ff007c",944         "JSON" => "#89ddff",945         "C" => "#1abc9c",946         _ => "#565f89",947     }948 }949 950 /// One language share for the mix bar.951 #[derive(Debug, Clone, PartialEq)]952 pub struct LangStat {953     /// Display name (`Rust`, …, `Other`).954     pub lang: String,955     /// Total bytes in this language.956     pub bytes: u64,957     /// Share of all counted bytes (0–100).958     pub pct: f64,959 }960 961 /// Group `(path, bytes)` rows into language shares (bytes share).962 ///963 /// Callers pass pre-filtered rows (see [`lang_rows`]): gitignored964 /// paths and binary blobs are already excluded, so the total here is965 /// counted text bytes only and `Other` holds just real text files966 /// with unmapped extensions. Languages under 1% merge into `Other`;967 /// output sorts bytes-desc. Empty input (or zero total) yields an968 /// empty vec (caller hides the bar and shows the no-languages note969 /// instead).970 #[must_use]971 pub fn compute_languages(files: &[(String, u64)]) -> Vec<LangStat> {972     use std::collections::BTreeMap;973     let mut by_lang: BTreeMap<String, u64> = BTreeMap::new();974     for (path, size) in files {975         *by_lang.entry(language_of(path).to_string()).or_default() += *size;976     }977     let total: u64 = by_lang.values().sum();978     if total == 0 {979         return Vec::new();980     }981     let mut main: Vec<(String, u64)> = Vec::new();982     let mut other_bytes: u64 = by_lang.get("Other").copied().unwrap_or(0);983     for (lang, bytes) in &by_lang {984         if lang == "Other" {985             continue;986         }987         let pct = *bytes as f64 * 100.0 / total as f64;988         if pct < 1.0 {989             other_bytes += *bytes;990         } else {991             main.push((lang.clone(), *bytes));992         }993     }994     if other_bytes > 0 {995         main.push(("Other".to_string(), other_bytes));996     }997     let mut out: Vec<LangStat> = main998         .into_iter()999         .map(|(lang, bytes)| LangStat {1000             pct: bytes as f64 * 100.0 / total as f64,1001             lang,1002             bytes,1003         })1004         .collect();1005     out.sort_by_key(|a| std::cmp::Reverse(a.bytes));1006     out1007 }1008 1009 /// Render the stacked language bar + legend (pure build-time, no JS).1010 ///1011 /// `files` is pre-filtered `(path, bytes)` text rows (see [`lang_rows`]:1012 /// gitignored and binary bytes never reach here, so `Other` is real1013 /// text with unmapped extensions only). Returns a1014 /// `<section class="langs">` with a Tokyo Night stacked bar and a1015 /// `%` legend when at least one code file exists (a file whose1016 /// extension maps to a known language); otherwise a muted1017 /// `No languages yet.` note and no bar (hidden when no code).1018 /// Everything is escaped; widths are `pct` with one decimal.1019 #[must_use]1020 pub fn language_bar(files: &[(String, u64)]) -> String {1021     let has_code = files.iter().any(|(p, _)| language_of(p) != "Other");1022     if !has_code {1023         return "<p class=\"muted lang-none\">No languages yet.</p>\n".to_string();1024     }1025     let stats = compute_languages(files);1026     if stats.is_empty() {1027         return "<p class=\"muted lang-none\">No languages yet.</p>\n".to_string();1028     }1029     let aria = stats1030         .iter()1031         .map(|s| format!("{} {:.1}%", s.lang, s.pct))1032         .collect::<Vec<_>>()1033         .join(", ");1034     let mut out = format!(1035         "<section class=\"langs\" aria-label=\"Languages\">\n<h2>Languages</h2>\n\1036          <div class=\"lang-bar\" role=\"img\" aria-label=\"{}\">\n",1037         esc(&aria)1038     );1039     for s in &stats {1040         out.push_str(&format!(1041             "<span style=\"width:{:.1}%;background:{}\"></span>",1042             s.pct,1043             language_color(&s.lang)1044         ));1045     }1046     out.push_str("\n</div>\n<ul class=\"lang-legend\">\n");1047     for s in &stats {1048         out.push_str(&format!(1049             "<li><span class=\"lang-dot\" style=\"background:{}\"></span>{} {:.1}%</li>\n",1050             language_color(&s.lang),1051             esc(&s.lang),1052             s.pct1053         ));1054     }1055     out.push_str("</ul>\n</section>\n");1056     out1057 }1058 1059 /// Gitea-style file table (m6 code-gitea, f0rge #33).1060 ///1061 /// `entries` stay in `gitx::ls_tree` order (dirs-first, each alpha).1062 /// `parent` is `Some((href, label))` for subdirs (`..` row) or `None`1063 /// at the root. `history` maps full repo-relative path →1064 /// `(short_message, age)` already truncated + aged at build; missing1065 /// entries render empty cells. Names link to `/files/<full>.html`1066 /// (dirs with a `/` suffix); messages are escaped + truncated to1067 /// [`MSG_TRUNC_CHARS`]; ages are precomputed `relative_age` strings.1068 /// Output is a scroll-wrapped TABLE (file-table class) with1069 /// Name / Last commit / Age headers.1070 #[must_use]1071 pub fn file_table_html(1072     dir: &str,1073     entries: &[gitx::TreeEntry],1074     parent: Option<(&str, &str)>,1075     history: &std::collections::BTreeMap<String, (String, String)>,1076 ) -> String {1077     let mut out = String::from(1078         "<div class=\"table-scroll file-scroll\"><table class=\"file-table\">\n\1079          <thead><tr><th>Name</th><th>Last commit</th><th>Age</th></tr></thead>\n<tbody>\n",1080     );1081     if let Some((href, label)) = parent {1082         out.push_str(&format!(1083             "<tr class=\"parent\"><td class=\"trunc\" title=\"{}\"><a href=\"{}\">{}</a></td>\1084              <td class=\"trunc trunc-message\" title=\"\"></td><td class=\"trunc\" title=\"\"></td></tr>\n",1085             esc(label),1086             esc(href),1087             esc(label)1088         ));1089     }1090     for e in entries {1091         let full = if dir.is_empty() {1092             e.name.clone()1093         } else {1094             format!("{dir}/{}", e.name)1095         };1096         let (msg, age) = history.get(&full).cloned().unwrap_or_default();1097         let msg_short = truncate_msg(&msg);1098         if e.is_dir {1099             out.push_str(&format!(1100                 "<tr class=\"dir\"><td class=\"trunc\" title=\"{}\"><a href=\"/files/{}.html\">{}/</a></td>\1101                  <td class=\"title trunc trunc-message\" title=\"{}\">{}</td><td class=\"trunc\" title=\"{}\">{}</td></tr>\n",1102                 esc(&e.name),1103                 href_esc(&full),1104                 esc(&e.name),1105                 esc(&msg),1106                 esc(&msg_short),1107                 esc(&age),1108                 esc(&age)1109             ));1110         } else if e.is_gitlink {1111             // Submodule: no blob in this repo, so no file page/raw copy.1112             out.push_str(&format!(1113                 "<tr class=\"file gitlink\"><td class=\"trunc\" title=\"submodule\">{} <span class=\"muted\">@ submodule</span></td>\1114                  <td class=\"title trunc trunc-message\" title=\"\"></td><td class=\"trunc\" title=\"\"></td></tr>\n",1115                 esc(&e.name),1116             ));1117         } else {1118             out.push_str(&format!(1119                 "<tr class=\"file\"><td class=\"trunc\" title=\"{}\"><a href=\"/files/{}.html\">{}</a></td>\1120                  <td class=\"title trunc trunc-message\" title=\"{}\">{}</td><td class=\"trunc\" title=\"{}\">{}</td></tr>\n",1121                 esc(&e.name),1122                 href_esc(&full),1123                 esc(&e.name),1124                 esc(&msg),1125                 esc(&msg_short),1126                 esc(&age),1127                 esc(&age)1128             ));1129         }1130     }1131     out.push_str("</tbody>\n</table></div>\n");1132     out1133 }1134 1135 /// Prominent Clone & download section for the code tab (m6, f0rge1136 /// issues #20 + #32 + #34; dumb-clone #54): collapsed native `<details>`1137 /// (closed by default, zero JS, keyboard accessible, works file://) with1138 /// stacked no-JS sections and selectable `<pre>` commands inside. When a1139 /// public origin is configured the primary command is1140 /// `git clone <origin>/repo.git` (dumb-HTTP, works on Pages/nginx/serve1141 /// with zero dynamic; exact snapshot note + bundle/tarball secondary,1142 /// origin remote demoted to "mirror source"); otherwise dumb-local first1143 /// (`git clone <url>` first when a clone URL is configured, else local1144 /// `git clone repo.git`) plus the bundle/tarball links with sizes and1145 /// shas. The `<summary>` always shows `Clone & download`1146 /// plus a branch@sha hint. The summary also carries the CI verdict badge1147 /// (m6 finish, f0rge #53): `Clone & download · branch @ sha · CI badge`1148 /// so the standalone repo-meta branch/CI line is gone (no duplication).1149 #[must_use]1150 #[allow(clippy::too_many_arguments)]1151 pub fn clone_block(1152     clone_url: Option<&str>,1153     public_origin: Option<&str>,1154     branch: &str,1155     short_sha: &str,1156     ci_verdict: &str,1157     bundle_size: u64,1158     bundle_sha: Option<&str>,1159     tarball_size: u64,1160     tarball_sha: Option<&str>,1161 ) -> String {1162     let bundle_link = match bundle_sha {1163         Some(s) => format!(1164             "<a href=\"/repo.bundle\">repo.bundle</a> ({}; sha <code>{}</code>)",1165             human_size(bundle_size),1166             esc(s)1167         ),1168         None => format!(1169             "<a href=\"/repo.bundle\">repo.bundle</a> ({})",1170             human_size(bundle_size)1171         ),1172     };1173     let tarball_link = match tarball_sha {1174         Some(s) => format!(1175             "<a href=\"/repo.tar.gz\">repo.tar.gz</a> ({}; sha <code>{}</code>)",1176             human_size(tarball_size),1177             esc(s)1178         ),1179         None => format!(1180             "<a href=\"/repo.tar.gz\">repo.tar.gz</a> ({})",1181             human_size(tarball_size)1182         ),1183     };1184     let badge = crate::forge::verdict_badge(ci_verdict);1185     let mut out = format!(1186         "<details class=\"clonebox clone-block\" aria-label=\"Clone and download\">\n\1187          <summary><span class=\"cb-seg\">Clone &amp; download</span><span class=\"cb-seg\">{} @ {}</span><span class=\"cb-seg\">CI {}</span></summary>\n<div class=\"clone-body\">\n",1188         esc(branch),1189         esc(short_sha),1190         badge1191     );1192     if let Some(origin) = public_origin.map(str::trim).filter(|s| !s.is_empty()) {1193         let base = origin.trim_end_matches('/');1194         let dumb_url = format!("{base}/repo.git");1195         let bundle_url = format!("{base}/repo.bundle");1196         let tarball_url = format!("{base}/repo.tar.gz");1197         out.push_str("<h3>Clone with git</h3>\n");1198         out.push_str(&format!(1199             "<pre class=\"clone-cmd\"><code>git clone {}</code></pre>\n\1200              <p>exact snapshot of <code>{}</code> @ <code>{}</code>.</p>\n",1201             esc(&dumb_url),1202             esc(branch),1203             esc(short_sha)1204         ));1205         out.push_str("<h3>Download the snapshot</h3>\n");1206         out.push_str(&format!(1207             "<pre class=\"clone-cmd\"><code>curl -O {}</code></pre>\n\1208              <pre class=\"clone-cmd\"><code>git clone repo.bundle -b {}</code></pre>\n<p>{bundle_link}</p>\n",1209             esc(&bundle_url),1210             esc(branch)1211         ));1212         out.push_str("<h3>Download the tarball</h3>\n");1213         out.push_str(&format!(1214             "<pre class=\"clone-cmd\"><code>curl -O {}</code></pre>\n<p>{tarball_link}</p>\n",1215             esc(&tarball_url)1216         ));1217         if let Some(url) = clone_url {1218             out.push_str(&format!(1219                 "<p class=\"muted\">mirror source: <code>{}</code></p>\n",1220                 esc(url)1221             ));1222         }1223         out.push_str("</div>\n</details>\n");1224         return out;1225     }1226     if let Some(url) = clone_url {1227         out.push_str("<h3>Clone with git</h3>\n");1228         out.push_str(&format!(1229             "<pre class=\"clone-cmd\"><code>git clone {}</code></pre>\n",1230             esc(url)1231         ));1232     }1233     // Unset origin: the bundle is the authoritative, self-contained1234     // clone method (no host address needed). `repo.git` is the same1235     // snapshot as a served directory, mentioned second (f0rge #59).1236     out.push_str("<h3>Clone the bundle</h3>\n");1237     out.push_str(&format!(1238         "<pre class=\"clone-cmd\"><code>git clone repo.bundle -b {}</code></pre>\n<p>{bundle_link}</p>\n",1239         esc(branch)1240     ));1241     out.push_str("<h3>Served as a directory</h3>\n");1242     out.push_str(&format!(1243         "<pre class=\"clone-cmd\"><code>git clone repo.git -b {}</code></pre>\n\1244          <p>the same snapshot as a dumb-HTTP checkout under this site's \1245          web root (<code>/repo.git</code>), no smart server needed.</p>\n",1246         esc(branch)1247     ));1248     out.push_str("<h3>Download the tarball</h3>\n");1249     out.push_str(&format!(1250         "<pre class=\"clone-cmd\"><code>tar -xzf repo.tar.gz</code></pre>\n<p>{tarball_link}</p>\n"1251     ));1252     if clone_url.is_none() {1253         out.push_str(1254             "<p class=\"muted\">No clone URL is configured for this mirror — \1255              the bundle and tarball above carry the full snapshot. \1256              They are served from this server: ask the host for its address.</p>\n",1257         );1258     } else {1259         out.push_str(1260             "<p class=\"muted\">The bundle and tarball above are \1261              served from this server: ask the host for its address.</p>\n",1262         );1263     }1264     out.push_str("</div>\n</details>\n");1265     out1266 }1267 1268 /// Render one text file body: escaped `<pre>` with clickable per-line1269 /// anchors (`#L12`, shareable), wrapped in a horizontal-scroll container1270 /// so long lines never break the 360px layout.1271 fn render_text_pre(display: &str, path: &str) -> String {1272     // Normalize line endings and drop exactly one trailing terminator:1273     // `split('\n')` used to turn a final newline into an empty L404 and1274     // kept `\r` in CRLF files (f0rge #88).1275     let normalized = display.replace("\r\n", "\n").replace('\r', "\n");1276     let text = normalized.strip_suffix('\n').unwrap_or(&normalized);1277     let mut out = String::from("<div class=\"pre-scroll\"><pre class=\"code\">\n");1278     if text.is_empty() {1279         out.push_str("<span class=\"line\" id=\"L1\"><a class=\"ln\" href=\"#L1\">1</a> </span>");1280     } else {1281         // Build-time tokens (f0rge #73): unknown/huge files stay plain.1282         let highlighted = crate::highlight::highlight_lines(path, text);1283         for (i, line) in text.split('\n').enumerate() {1284             let n = i + 1;1285             out.push_str("<span class=\"line\" id=\"L");1286             out.push_str(&n.to_string());1287             out.push_str("\"><a class=\"ln\" href=\"#L");1288             out.push_str(&n.to_string());1289             out.push_str("\">");1290             out.push_str(&n.to_string());1291             out.push_str("</a> ");1292             match &highlighted {1293                 Some(lines) => out.push_str(&lines[i]),1294                 None => out.push_str(&esc(line)),1295             }1296             out.push_str("</span>");1297         }1298     }1299     out.push_str("</pre></div>\n");1300     out1301 }1302 1303 /// Top nav with live open f0rge counts (m6 open-counts, f0rge #29:1304 /// open means `state == "open"` exactly; drafts count as open) and1305 /// conditional section tabs (m6 hide-empty, f0rge #38): `tasks` /1306 /// `releases` / `wiki` render ONLY when non-empty; `issues` / `pulls`1307 /// always stay (they carry the submit path), as do `code` / `commits` /1308 /// `ci` / `agents`. Hidden sections still build (deep links keep1309 /// working); only the nav omits the tab.1310 fn nav_bar(1311     issue_count: usize,1312     pr_count: usize,1313     has_tasks: bool,1314     has_releases: bool,1315     has_wiki: bool,1316 ) -> String {1317     let mut tabs = vec![1318         "<a href=\"/\">code</a>".to_string(),1319         "<a href=\"/commits.html\">commits</a>".to_string(),1320         format!("<a href=\"/issues/index.html\">issues ({issue_count})</a>"),1321         format!("<a href=\"/pulls/index.html\">pulls ({pr_count})</a>"),1322     ];1323     if has_tasks {1324         tabs.push("<a href=\"/tasks/index.html\">tasks</a>".to_string());1325     }1326     tabs.push("<a href=\"/ci/index.html\">ci</a>".to_string());1327     if has_releases {1328         tabs.push("<a href=\"/releases/index.html\">releases</a>".to_string());1329     }1330     if has_wiki {1331         tabs.push("<a href=\"/wiki/index.html\">wiki</a>".to_string());1332     }1333     tabs.push("<a href=\"/agents.html\">agents</a>".to_string());1334     tabs.join("<span class=\"nav-sep\" aria-hidden=\"true\">·</span>")1335 }1336 1337 /// Write `out/style.css` from the embedded static file.1338 fn write_style(out: &Path) -> Result<(), String> {1339     let css = include_str!("../static/style.css");1340     std::fs::write(out.join("style.css"), css).map_err(|e| format!("hu13: write style.css: {e}"))1341 }1342 1343 /// Write `out/logo.svg` + `out/favicon.svg` + `out/manifest.webmanifest`1344 /// from the embedded static files (m6 favicon #16; logo-text #27 + #28).1345 /// SVG-first, zero binary blobs: the header lockup is `logo.svg`1346 /// Pages Functions routing: only `/inbox/*` runs a Function; every1347 /// other path stays a static asset. Must live in the build output, not1348 /// the repo root (f0rge #79).1349 pub const ROUTES_FILE: &str =1350     "{\n  \"version\": 1,\n  \"include\": [\"/inbox/*\"],\n  \"exclude\": []\n}\n";1351 1352 /// Header rules for Cloudflare Pages (and any host that reads `_headers`).1353 ///1354 /// `/raw/**` holds committed, untrusted bytes: a repo can contain1355 /// `raw/evil.html` signing script in the site's own origin, where the1356 /// inbox endpoints live. `serve` sets the same policy directly1357 /// (f0rge #61); this file gives Pages the equivalent.1358 pub const HEADERS_FILE: &str = "\1359 # generated by hu13: committed files and release artifacts are untrusted1360 /raw/*1361   Content-Disposition: attachment1362   X-Content-Type-Options: nosniff1363 # Artifacts are build outputs copied from .forge/releases/out/<ver>/;1364 # never let an artifact .html/.svg execute in the site origin (f0rge #97).1365 # Cloudflare Pages allows one splat per rule. A named `:version`1366 # placeholder plus one splat covers every artifact depth (f0rge #106).1367 # A two-splat rule is silently skipped by Pages and leaves artifacts active.1368 /releases/:version/*1369   Content-Disposition: attachment1370   X-Content-Type-Options: nosniff1371 /*1372   X-Content-Type-Options: nosniff1373   Referrer-Policy: strict-origin-when-cross-origin1374 ";1375 1376 /// (diamond-hub + hu13 as <path>, no <text>/fonts), the favicon is the1377 /// simplified mark-only variant for 16px, and the manifest points at the1378 /// favicon (`icons` svg, `short_name` hu13, `display` minimal-ui).1379 fn write_favicon_and_manifest(out: &Path) -> Result<(), String> {1380     let logo = include_str!("../static/logo.svg");1381     std::fs::write(out.join("logo.svg"), logo).map_err(|e| format!("hu13: write logo.svg: {e}"))?;1382     let gl0w = include_str!("../static/gl0w.svg");1383     std::fs::write(out.join("gl0w.svg"), gl0w).map_err(|e| format!("hu13: write gl0w.svg: {e}"))?;1384     let icon = include_str!("../static/favicon.svg");1385     std::fs::write(out.join("favicon.svg"), icon)1386         .map_err(|e| format!("hu13: write favicon.svg: {e}"))?;1387     let social = include_bytes!("../static/og.png");1388     std::fs::write(out.join("og.png"), social).map_err(|e| format!("hu13: write og.png: {e}"))?;1389     let manifest = include_str!("../static/manifest.webmanifest");1390     std::fs::write(out.join("manifest.webmanifest"), manifest)1391         .map_err(|e| format!("hu13: write manifest.webmanifest: {e}"))?;1392     std::fs::write(out.join("_headers"), HEADERS_FILE)1393         .map_err(|e| format!("hu13: write _headers: {e}"))?;1394     std::fs::write(out.join("_routes.json"), ROUTES_FILE)1395         .map_err(|e| format!("hu13: write _routes.json: {e}"))?;1396     Ok(())1397 }1398 1399 /// Absolute form of `p` (join cwd for relative paths, no symlink work).1400 fn absolute(p: &Path) -> std::path::PathBuf {1401     if p.is_absolute() {1402         p.to_path_buf()1403     } else {1404         std::env::current_dir()1405             .map(|cwd| cwd.join(p))1406             .unwrap_or_else(|_| p.to_path_buf())1407     }1408 }1409 1410 /// Absolute lexical form of `p` with `.`/`..` collapsed. Does not1411 /// touch the filesystem (unlike canonicalize), so it also works for an1412 /// output path that does not exist yet.1413 fn normalize_lexical(p: &Path) -> std::path::PathBuf {1414     use std::path::Component;1415     let mut out = std::path::PathBuf::new();1416     for c in p.components() {1417         match c {1418             Component::CurDir => {}1419             Component::ParentDir => {1420                 out.pop();1421             }1422             Component::RootDir | Component::Prefix(_) | Component::Normal(_) => {1423                 out.push(c.as_os_str());1424             }1425         }1426     }1427     out1428 }1429 1430 /// Resolve symlinks in the deepest existing ancestor, then append the1431 /// not-yet-created suffix. This catches `-o alias/..` where `alias` is a1432 /// symlink into the repo (f0rge #77).1433 fn resolve_existing(p: &Path) -> std::path::PathBuf {1434     let abs = normalize_lexical(&absolute(p));1435     let mut suffix: Vec<std::ffi::OsString> = Vec::new();1436     let mut cur = abs.as_path();1437     loop {1438         if let Ok(mut resolved) = cur.canonicalize() {1439             for part in suffix.iter().rev() {1440                 resolved.push(part);1441             }1442             return normalize_lexical(&resolved);1443         }1444         match cur.file_name() {1445             Some(name) => {1446                 suffix.push(name.to_os_string());1447                 match cur.parent() {1448                     Some(parent) => cur = parent,1449                     None => return abs,1450                 }1451             }1452             None => return abs,1453         }1454     }1455 }1456 1457 /// `true` when git tracks at least one path under repo-relative `rel`.1458 fn has_tracked_under(repo: &Path, rel: &Path) -> bool {1459     if rel.as_os_str().is_empty() {1460         return true;1461     }1462     std::process::Command::new("git")1463         .arg("-C")1464         .arg(repo)1465         .arg("ls-files")1466         .arg("-z")1467         .arg("--")1468         .arg(rel)1469         .output()1470         .map(|o| o.status.success() && !o.stdout.is_empty())1471         .unwrap_or(true)1472 }1473 1474 /// Remove everything already in `out/` before a build (f0rge #67).1475 ///1476 /// Without this, pages for deleted issues/files keep being served from a1477 /// reused output dir. Refuses to delete the repo itself or anything that1478 /// contains it (`-o ..`, `-o .`), resolves `.`/`..` and symlinked1479 /// ancestors before comparing, and refuses an in-repo output that holds1480 /// tracked files (f0rge #77).1481 fn prepare_out(repo: &Path, out: &Path) -> Result<(), String> {1482     let out_path = normalize_lexical(&absolute(out));1483     let out_abs = resolve_existing(&out_path);1484     let repo_abs = resolve_existing(repo);1485     if out_abs == repo_abs || repo_abs.starts_with(&out_abs) {1486         return Err(format!(1487             "hu13: refusing to clean output dir {}: it contains the repo",1488             out.display()1489         ));1490     }1491     if out_abs.parent().is_none() {1492         return Err("hu13: refusing to use the filesystem root as output dir".to_string());1493     }1494     if let Ok(rel) = out_abs.strip_prefix(&repo_abs) {1495         if rel1496             .components()1497             .next()1498             .is_some_and(|c| c.as_os_str() == ".git")1499             || has_tracked_under(&repo_abs, rel)1500         {1501             return Err(format!(1502                 "hu13: refusing to clean in-repo output dir {}: it holds tracked files",1503                 out.display()1504             ));1505         }1506     }1507     match std::fs::symlink_metadata(&out_path) {1508         Ok(meta) if meta.file_type().is_symlink() => {1509             std::fs::remove_file(&out_path)1510                 .map_err(|e| format!("hu13: remove symlinked out dir {}: {e}", out.display()))?;1511         }1512         Ok(meta) if meta.is_dir() => {1513             std::fs::remove_dir_all(&out_path)1514                 .map_err(|e| format!("hu13: clean output dir {}: {e}", out.display()))?;1515         }1516         Ok(_) => {1517             std::fs::remove_file(&out_path)1518                 .map_err(|e| format!("hu13: remove output file {}: {e}", out.display()))?;1519         }1520         Err(_) => {}1521     }1522     std::fs::create_dir_all(&out_path)1523         .map_err(|e| format!("hu13: create output dir {}: {e}", out.display()))1524 }1525 1526 /// Make `og:image` absolute on every built page when a public origin is1527 /// configured (f0rge #95). Done once over the output tree because the1528 /// origin is build config, not page content: all pages share the same1529 /// `content="/og.png"` marker.1530 fn absolutize_social_meta(out: &Path, origin: &str) -> Result<(), String> {1531     let base = origin.trim_end_matches('/');1532     if base.is_empty() {1533         return Ok(());1534     }1535     let absolute = format!("content=\"{}/og.png\"", esc(base));1536     let mut stack = vec![out.to_path_buf()];1537     while let Some(dir) = stack.pop() {1538         let rd = std::fs::read_dir(&dir)1539             .map_err(|e| format!("hu13: social meta read {}: {e}", dir.display()))?;1540         for ent in rd {1541             let ent = ent.map_err(|e| format!("hu13: social meta entry: {e}"))?;1542             let path = ent.path();1543             if path.is_dir() {1544                 stack.push(path);1545                 continue;1546             }1547             if !path.extension().is_some_and(|x| x == "html") {1548                 continue;1549             }1550             let rel = path.strip_prefix(out).unwrap_or(&path);1551             let rel = rel.to_string_lossy().replace('\\', "/");1552             // Only generated pages carry this marker. `/raw/` and release1553             // artifacts are byte-for-byte copies of untrusted inputs: reading1554             // them as UTF-8 can fail the build and rewriting them corrupts the1555             // download (f0rge #100). `repo.git` is git internals, never HTML.1556             if rel.starts_with("raw/")1557                 || rel.starts_with("repo.git/")1558                 || crate::releases::is_artifact_path(&rel)1559             {1560                 continue;1561             }1562             let html = std::fs::read_to_string(&path)1563                 .map_err(|e| format!("hu13: social meta read {}: {e}", path.display()))?;1564             let replaced = html.replace("content=\"/og.png\"", &absolute);1565             if replaced != html {1566                 std::fs::write(&path, replaced)1567                     .map_err(|e| format!("hu13: social meta write {}: {e}", path.display()))?;1568             }1569         }1570     }1571     Ok(())1572 }1573 1574 /// Check an output tree against deployment limits. Returns the first1575 /// offending file (or the file-count overflow) with enough context to1576 /// fix the repo. Kept separate from the walk so tests can use tiny caps.1577 pub fn check_output_budget(1578     out: &Path,1579     max_file_bytes: u64,1580     max_files: usize,1581 ) -> Result<(), String> {1582     let mut stack = vec![out.to_path_buf()];1583     let mut files = 0usize;1584     while let Some(dir) = stack.pop() {1585         let rd = std::fs::read_dir(&dir)1586             .map_err(|e| format!("hu13: output budget read {}: {e}", dir.display()))?;1587         for ent in rd {1588             let ent = ent.map_err(|e| format!("hu13: output budget entry: {e}"))?;1589             let path = ent.path();1590             let meta = ent1591                 .metadata()1592                 .map_err(|e| format!("hu13: output budget stat {}: {e}", path.display()))?;1593             if meta.is_dir() {1594                 stack.push(path);1595             } else if meta.is_file() {1596                 files += 1;1597                 if files > max_files {1598                     return Err(format!(1599                         "hu13: output has more than {max_files} files (Cloudflare Pages limit);                          last: {}",1600                         path.display()1601                     ));1602                 }1603                 if meta.len() > max_file_bytes {1604                     return Err(format!(1605                         "hu13: output file {} is {} bytes, over the Cloudflare Pages limit of {} bytes;                          keep large assets out of git or use Git LFS",1606                         path.display(),1607                         meta.len(),1608                         max_file_bytes1609                     ));1610                 }1611             }1612         }1613     }1614     Ok(())1615 }1616 1617 /// Build the full static site. Returns a summary on success.1618 pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSummary, String> {1619     let branch = gitx::resolve_branch(repo, opts.branch_flag.as_deref())1620         .map_err(|e| format!("hu13: {e}"))?;1621     let short_sha = gitx::rev_parse_short(repo, &branch).map_err(|e| format!("hu13: {e}"))?;1622     prepare_out(repo, out)?;1623     let clone_url = resolve_clone_url(repo, opts.clone_url_flag.as_deref());1624     let public_origin = resolve_public_origin(opts.public_origin_flag.as_deref());1625 1626     let name = resolve_repo_name(repo, opts.repo_name_flag.as_deref());1627     let description = resolve_description(opts.description_flag.as_deref());1628     let desc_opt = description.as_deref();1629 1630     // Walk the whole tree (dirs-first order from gitx), remembering each1631     // dir's entries so dir pages need no second pass.1632     let mut dir_entries: BTreeMap<String, Vec<gitx::TreeEntry>> = BTreeMap::new();1633     let mut dirs: Vec<String> = vec![String::new()];1634     let mut files: Vec<String> = Vec::new();1635     let mut queue: Vec<String> = vec![String::new()];1636     while let Some(dir) = queue.pop() {1637         let entries = gitx::ls_tree(repo, &branch, &dir).map_err(|e| format!("hu13: {e}"))?;1638         for e in &entries {1639             let full = if dir.is_empty() {1640                 e.name.clone()1641             } else {1642                 format!("{dir}/{}", e.name)1643             };1644             if e.is_dir {1645                 queue.push(full);1646             } else if !e.is_gitlink {1647                 // Gitlinks have no blob in this repo (f0rge #68).1648                 files.push(full);1649             }1650         }1651         dir_entries.insert(dir.clone(), entries);1652         let _ = dirs.len();1653     }1654     dirs = dir_entries.keys().cloned().collect();1655 1656     let readme = find_readme(repo, &branch);1657 1658     std::fs::create_dir_all(out.join("files"))1659         .map_err(|e| format!("hu13: create files dir: {e}"))?;1660     std::fs::create_dir_all(out.join("raw")).map_err(|e| format!("hu13: create raw dir: {e}"))?;1661     write_style(out)?;1662     write_favicon_and_manifest(out)?;1663 1664     // f0rge readers (m3, issue #7): on-disk `.forge/`. Load once up front1665     // so every page (including file pages below) gets a nav with live1666     // open counts (m6 open-counts, f0rge #29). Loading never fails:1667     // bad files are skipped + warned. Releases load here too (m61668     // hide-empty, f0rge #38) so the nav knows whether the releases tab1669     // has content; wiki presence comes from `wiki::page_count` (same1670     // filters as `wiki::render`, read-only, no files emitted).1671     let forge_data = forge::load(repo);1672     let release_data = releases::load(repo);1673     let nav = nav_bar(1674         forge_data.open_issue_count(),1675         forge_data.open_pr_count(),1676         !forge_data.tasks.is_empty(),1677         !release_data.is_empty(),1678         wiki::page_count(repo) > 0,1679     );1680 1681     // Raw files first: byte-identical copies. Blobs over MAX_INLINE_BLOB1682     // are streamed to disk and never enter the in-memory map; their file1683     // pages become placeholders (f0rge #66).1684     let mut blobs: BTreeMap<String, Vec<u8>> = BTreeMap::new();1685     let mut oversize: BTreeMap<String, u64> = BTreeMap::new();1686     for f in &files {1687         let dest = out.join("raw").join(f);1688         if let Some(parent) = dest.parent() {1689             std::fs::create_dir_all(parent).map_err(|e| format!("hu13: create raw parent: {e}"))?;1690         }1691         let size =1692             gitx::blob_size(repo, &branch, Path::new(f)).map_err(|e| format!("hu13: {e}"))?;1693         if size > MAX_INLINE_BLOB {1694             gitx::show_to_file(repo, &branch, Path::new(f), &dest)1695                 .map_err(|e| format!("hu13: {e}"))?;1696             oversize.insert(f.clone(), size);1697         } else {1698             let blob = gitx::show(repo, &branch, Path::new(f)).map_err(|e| format!("hu13: {e}"))?;1699             std::fs::write(&dest, &blob.bytes).map_err(|e| format!("hu13: write raw {f}: {e}"))?;1700             blobs.insert(f.clone(), blob.bytes);1701         }1702         // Raster images mentioned by markdown need an inline route; `/raw`1703         // intentionally downloads everything. Copy only non-executable1704         // image formats under `/assets` (f0rge #112). SVG/HTML stay raw.1705         if is_inline_image(f) {1706             let adest = out.join("assets").join(f);1707             if let Some(parent) = adest.parent() {1708                 std::fs::create_dir_all(parent)1709                     .map_err(|e| format!("hu13: create assets parent: {e}"))?;1710             }1711             std::fs::copy(&dest, &adest).map_err(|e| format!("hu13: write asset {f}: {e}"))?;1712         }1713     }1714 1715     // Per-path pages (m6 code-gitea, f0rge #33): Gitea-style file1716     // TABLEs with last-commit message + build-time relative age.1717     // History is one `git log -1` per tree path (tiny repos only);1718     // failures degrade to empty cells, never a failed build.1719     let now_ts = now_unix();1720     let mut history: BTreeMap<String, (String, String)> = BTreeMap::new();1721     {1722         let mut all_paths: Vec<String> = Vec::new();1723         for (d, ents) in &dir_entries {1724             for e in ents {1725                 if e.is_gitlink {1726                     continue;1727                 }1728                 let full = if d.is_empty() {1729                     e.name.clone()1730                 } else {1731                     format!("{d}/{}", e.name)1732                 };1733                 all_paths.push(full);1734             }1735         }1736         for fp in all_paths {1737             let cell = match gitx::last_commit_for_path(repo, &branch, &fp) {1738                 Ok(Some(pc)) => (pc.subject, relative_age(now_ts, pc.committer_ts)),1739                 _ => (String::new(), String::new()),1740             };1741             history.insert(fp, cell);1742         }1743     }1744     for dir in &dirs {1745         if dir.is_empty() {1746             continue; // root is index.html1747         }1748         let entries = dir_entries.get(dir).cloned().unwrap_or_default();1749         let parent = match dir.rfind('/') {1750             Some(i) => dir[..i].to_string(),1751             None => String::new(),1752         };1753         let parent_href = if parent.is_empty() {1754             "/".to_string()1755         } else {1756             format!("/files/{}.html", href_esc(&parent))1757         };1758         let parent_label = if parent.is_empty() {1759             ".. (root)".to_string()1760         } else {1761             format!(".. ({parent})")1762         };1763         let mut body = breadcrumb(dir);1764         body.push_str(&format!("<h1>{}/</h1>\n", esc(dir)));1765         body.push_str(&file_table_html(1766             dir,1767             &entries,1768             Some((&parent_href, &parent_label)),1769             &history,1770         ));1771         let html = crate::layout::page_site_with_provenance(1772             &format!("{dir} — {name}"),1773             &nav,1774             &body,1775             &name,1776             desc_opt,1777             &branch,1778             &short_sha,1779         );1780         let dest = out.join("files").join(format!("{dir}.html"));1781         if let Some(parent) = dest.parent() {1782             std::fs::create_dir_all(parent)1783                 .map_err(|e| format!("hu13: create files parent: {e}"))?;1784         }1785         std::fs::write(&dest, html).map_err(|e| format!("hu13: write files page: {e}"))?;1786     }1787 1788     for f in &files {1789         let mut body = breadcrumb(f);1790         if let Some(&size) = oversize.get(f) {1791             body.push_str(&file_meta_bar(f, size, None));1792             body.push_str(&format!(1793                 "<p class=\"notice\">placeholder: too large to preview ({size} bytes) — \1794                  <a href=\"/raw/{}\">download the raw file</a> instead.</p>\n",1795                 href_esc(f)1796             ));1797         } else {1798             let bytes = blobs.get(f).cloned().unwrap_or_default();1799             if is_placeholder(&bytes) {1800                 body.push_str(&file_meta_bar(f, bytes.len() as u64, None));1801                 let kind = if bytes.len() >= PREVIEW_MAX_BYTES {1802                     format!("too large to preview ({} bytes)", bytes.len())1803                 } else {1804                     "binary file".to_string()1805                 };1806                 body.push_str(&format!(1807                     "<p class=\"notice\">placeholder: {kind} — preview omitted. \1808                  <a href=\"/raw/{}\">download the raw file</a> instead.</p>\n",1809                     href_esc(f)1810                 ));1811             } else if is_markdown(f) {1812                 let display = String::from_utf8_lossy(&bytes).into_owned();1813                 body.push_str(&file_meta_bar(1814                     f,1815                     bytes.len() as u64,1816                     Some(display.lines().count()),1817                 ));1818                 let base = f.rsplit_once('/').map(|(d, _)| d).unwrap_or("");1819                 body.push_str(&markdown_to_html_in(&display, base, false));1820                 body.push_str(&format!(1821                     "<p class=\"muted\"><a href=\"/raw/{}\">view raw</a></p>\n",1822                     href_esc(f)1823                 ));1824             } else {1825                 let display = String::from_utf8_lossy(&bytes).into_owned();1826                 body.push_str(&file_meta_bar(1827                     f,1828                     bytes.len() as u64,1829                     Some(display.lines().count()),1830                 ));1831                 body.push_str(&render_text_pre(&display, f));1832             }1833         }1834         let html = crate::layout::page_site_with_provenance(1835             &format!("{f} — {name}"),1836             &nav,1837             &body,1838             &name,1839             desc_opt,1840             &branch,1841             &short_sha,1842         );1843         let dest = out.join("files").join(format!("{f}.html"));1844         if let Some(parent) = dest.parent() {1845             std::fs::create_dir_all(parent)1846                 .map_err(|e| format!("hu13: create files parent: {e}"))?;1847         }1848         std::fs::write(&dest, html).map_err(|e| format!("hu13: write files page: {e}"))?;1849     }1850 1851     // Commits + feed + wiki + f0rge + releases + api + agents (m2/m3):1852     // one struct pass — the same loaded vecs feed both HTML and JSON.1853     let commit_log = commits::load(repo, &branch, opts.max_log)?;1854     commits::render_loaded(1855         repo,1856         out,1857         &commit_log,1858         &name,1859         &nav,1860         &branch,1861         &short_sha,1862         desc_opt,1863     )?;1864     feed::render_loaded_with_origin(1865         repo,1866         out,1867         &branch,1868         &name,1869         &commit_log,1870         desc_opt,1871         public_origin.as_deref(),1872     )?;1873     let wiki_pages = wiki::render(repo, out, &name, &nav, &branch, &short_sha, desc_opt)?;1874     // f0rge pages (m3, issue #7): issues/pulls/tasks/ci from loaded data.1875     // Tasks/releases/wiki indexes always render even when empty (m61876     // hide-empty, f0rge #38): only the nav hides the tab, deep links1877     // keep working with their "No X yet" empty states.1878     forge::render_loaded(&forge_data, out, &name, &nav, &branch, &short_sha, desc_opt)?;1879     releases::render_loaded(1880         &release_data,1881         repo,1882         out,1883         &name,1884         &nav,1885         &branch,1886         &short_sha,1887         desc_opt,1888     )?;1889     api::render(out, &forge_data, &commit_log, &release_data, &wiki_pages)?;1890     agents::render(1891         out,1892         &name,1893         &nav,1894         clone_url.as_deref(),1895         &branch,1896         &short_sha,1897         desc_opt,1898     )?;1899     // Mailbox forms (m4, issue #9): free textboxes, honeypot, license box.1900     crate::inbox::render_forms(out, &name, &nav, &branch, &short_sha, desc_opt)?;1901 1902     // Snapshot last so the index can link sizes. Dumb-HTTP repo.git (#54)1903     // rides alongside the bundle/tarball so `git clone <origin>/repo.git`1904     // works on any static server (Pages/nginx/serve), no smart HTTP.1905     let snap = gitx::snapshot(repo, &branch, out).map_err(|e| format!("hu13: {e}"))?;1906     gitx::export_dumb_repo(&snap.bundle, &branch, out).map_err(|e| format!("hu13: {e}"))?;1907     let bundle_size = std::fs::metadata(&snap.bundle)1908         .map(|m| m.len())1909         .unwrap_or(0);1910     let tarball_size = std::fs::metadata(&snap.tarball)1911         .map(|m| m.len())1912         .unwrap_or(0);1913 1914     // index.html header: stacked repo-head (name + desc + clonebox) so narrow1915     // screens wrap safely. Branch/sha/CI live ONLY in the clone summary1916     // (m6 finish, f0rge #53): `Clone & download · branch @ sha · CI badge`1917     // — the old standalone repo-meta branch/CI line is deleted (no dupe).1918     // No counts line here (m6 open-counts, f0rge #36): nav carries counts.1919     let mut body = format!("<div class=\"repo-head\"><h1>{}</h1>\n", esc(&name));1920     if let Some(d) = &description {1921         body.push_str(&format!("<p class=\"repo-desc\">{}</p>\n", esc(d)));1922     }1923     body.push_str(&clone_block(1924         clone_url.as_deref(),1925         public_origin.as_deref(),1926         &branch,1927         &short_sha,1928         forge_data.ci.latest_verdict(),1929         bundle_size,1930         file_hash(&snap.bundle).as_deref(),1931         tarball_size,1932         file_hash(&snap.tarball).as_deref(),1933     ));1934 1935     body.push_str("</div>\n");1936     // Files then README in one stacked column at every width (f0rge #120);1937     // no desktop sidebar split.1938     // Code tab file TABLE (m6 code-gitea, f0rge #33, Gitea-style) plus the1939     // language mix bar (bytes share, Tokyo Night hues, no JS).1940     // Language math (m6, f0rge #37): tracked files only (via gitx, so1941     // untracked/ignored junk never counts), minus force-added paths1942     // that still match gitignore, minus binary blobs (NUL in first1943     // 8KiB). `Other` is real text with unmapped extensions only.1944     // Ignore lookup never fails the build (degrades to no ignores).1945     let root_entries = dir_entries.get("").cloned().unwrap_or_default();1946     let ignored: std::collections::HashSet<String> =1947         gitx::ignored_paths(repo, &files).unwrap_or_default();1948     let lang_files: Vec<(String, u64)> = lang_rows(&blobs, &ignored);1949     let lang_section = language_bar(&lang_files);1950     body.push_str("<div class=\"cols\"><section class=\"code-files\"><h2>Files</h2>\n");1951     body.push_str(&file_table_html("", &root_entries, None, &history));1952     body.push_str(&lang_section);1953     body.push_str("</section>\n<section class=\"code-readme\"><h2>README</h2>\n");1954     match &readme {1955         Some((path, blob)) => {1956             if is_markdown(path) || path.to_lowercase().contains("readme") {1957                 let (base, wiki) = if let Some(rest) = path.strip_prefix(".forge/wiki/") {1958                     (rest.rsplit_once('/').map(|(d, _)| d).unwrap_or(""), true)1959                 } else {1960                     (path.rsplit_once('/').map(|(d, _)| d).unwrap_or(""), false)1961                 };1962                 body.push_str(&markdown_to_html_in(&blob.display, base, wiki));1963             } else {1964                 body.push_str(&render_text_pre(&blob.display, path));1965             }1966         }1967         None => body.push_str("<p class=\"muted\">No README found.</p>\n"),1968     }1969     body.push_str("</section></div>\n");1970 1971     let index = crate::layout::page_site_with_provenance(1972         &name, &nav, &body, &name, desc_opt, &branch, &short_sha,1973     );1974     std::fs::write(out.join("index.html"), index)1975         .map_err(|e| format!("hu13: write index.html: {e}"))?;1976 1977     // Branded 404 for Pages/nginx; `serve` streams this file with status1978     // 404 on unknown paths (f0rge #85). Same nav/footer as every page.1979     let not_found = crate::layout::page_site_with_provenance(1980         &format!("not found — {name}"),1981         &nav,1982         "<h1>404</h1>\n<p>Nothing here.</p>\n<p><a href=\"/\">Back to code</a></p>\n",1983         &name,1984         desc_opt,1985         &branch,1986         &short_sha,1987     );1988     std::fs::write(out.join("404.html"), not_found)1989         .map_err(|e| format!("hu13: write 404.html: {e}"))?;1990 1991     if let Some(origin) = public_origin.as_deref() {1992         absolutize_social_meta(out, origin)?;1993     }1994 1995     // Fail loudly here instead of letting Pages reject an opaque deploy1996     // (f0rge #81). Counts the whole emitted tree, including repo.git.1997     check_output_budget(out, PAGES_MAX_FILE_BYTES, PAGES_MAX_FILES)?;1998 1999     Ok(BuildSummary {2000         branch,2001         short_sha,2002         file_count: files.len(),2003     })2004 }2005 2006 /// Collect every file path under `dir` (relative, `/`-separated).2007 #[cfg(test)]2008 pub fn test_all_files(dir: &Path) -> Vec<std::path::PathBuf> {2009     let mut out = Vec::new();2010     let mut stack = vec![dir.to_path_buf()];2011     while let Some(d) = stack.pop() {2012         let rd = std::fs::read_dir(&d).expect("read_dir");2013         for ent in rd {2014             let ent = ent.expect("dir entry");2015             let p = ent.path();2016             if p.is_dir() {2017                 stack.push(p);2018             } else {2019                 out.push(p.strip_prefix(dir).expect("prefix").to_path_buf());2020             }2021         }2022     }2023     out.sort();2024     out2025 }2026 2027 #[cfg(test)]2028 mod tests {2029     use super::*;2030     use std::path::PathBuf;2031     use std::process::Command;2032     use std::sync::atomic::{AtomicU64, Ordering};2033 2034     static COUNTER: AtomicU64 = AtomicU64::new(0);2035 2036     fn mktemp(prefix: &str) -> PathBuf {2037         let id = COUNTER.fetch_add(1, Ordering::SeqCst);2038         let dir =2039             std::env::temp_dir().join(format!("hu13-build-{prefix}-{}-{id}", std::process::id()));2040         std::fs::create_dir_all(&dir).expect("mktemp");2041         dir2042     }2043 2044     fn git(repo: &Path, args: &[&str]) {2045         let st = Command::new("git")2046             .arg("-C")2047             .arg(repo)2048             .args(args)2049             .status()2050             .expect("spawn git");2051         assert!(st.success(), "git {args:?} failed in {}", repo.display());2052     }2053 2054     fn opts() -> BuildOptions {2055         BuildOptions {2056             branch_flag: None,2057             clone_url_flag: None,2058             public_origin_flag: None,2059             repo_name_flag: None,2060             description_flag: None,2061             max_log: 100,2062         }2063     }2064 2065     /// Fixture: 10 files, no `.forge/` (the no-forge case) —2066     /// README + subdir + binary + >256KB file.2067     fn fixture_repo() -> PathBuf {2068         let dir = mktemp("fixture");2069         git(&dir, &["init", "-b", "main"]);2070         git(&dir, &["config", "user.name", "hu13-test"]);2071         git(&dir, &["config", "user.email", "[email protected]"]);2072         git(&dir, &["config", "commit.gpgsign", "false"]);2073         std::fs::write(2074             dir.join("README.md"),2075             "# Hello fixture\n\nWelcome to the **fixture** repo.\n\n<script>alert(1)</script>\n",2076         )2077         .unwrap();2078         std::fs::create_dir_all(dir.join("src")).unwrap();2079         std::fs::write(2080             dir.join("src").join("main.rs"),2081             "fn main() {\n    println!(\"hi\");\n}\n",2082         )2083         .unwrap();2084         std::fs::write(2085             dir.join("src").join("lib.rs"),2086             "pub fn add(a: i32, b: i32) -> i32 {\n    a + b\n}\n",2087         )2088         .unwrap();2089         std::fs::create_dir_all(dir.join("docs")).unwrap();2090         std::fs::write(2091             dir.join("docs").join("guide.md"),2092             "# Guide\n\nSome *docs* here.\n",2093         )2094         .unwrap();2095         std::fs::write(dir.join("notes.txt"), "plain notes\nsecond line\n").unwrap();2096         std::fs::create_dir_all(dir.join("assets")).unwrap();2097         let mut bin: Vec<u8> = (0u8..=255).collect();2098         bin.extend_from_slice(b"\x00\x01\x02binary tail");2099         std::fs::write(dir.join("assets").join("logo.bin"), &bin).unwrap();2100         std::fs::create_dir_all(dir.join("data")).unwrap();2101         std::fs::write(dir.join("data").join("big.txt"), vec![b'a'; 300_000]).unwrap();2102         std::fs::create_dir_all(dir.join("sub")).unwrap();2103         std::fs::write(dir.join("sub").join("nested.txt"), "nested\n").unwrap();2104         std::fs::write(dir.join("sub").join("deep.md"), "# Deep\n\nnested docs\n").unwrap();2105         std::fs::write(2106             dir.join("LICENSE"),2107             "MIT License\n\nPermission is hereby granted.\n",2108         )2109         .unwrap();2110         git(&dir, &["add", "-A"]);2111         git(&dir, &["commit", "-m", "fixture commit"]);2112         dir2113     }2114 2115     fn build_fixture() -> (PathBuf, PathBuf) {2116         let repo = fixture_repo();2117         let out = mktemp("out");2118         let summary = run_build(&repo, &out, &opts()).expect("build fixture");2119         assert_eq!(summary.branch, "main");2120         assert_eq!(summary.file_count, 10);2121         assert!(!summary.short_sha.is_empty());2122         (repo, out)2123     }2124 2125     #[test]2126     fn index_has_tree_and_readme_html() {2127         let (_repo, out) = build_fixture();2128         let index = std::fs::read_to_string(out.join("index.html")).expect("index");2129         // File tree (dirs-first flat root listing).2130         for name in ["src", "docs", "assets", "data", "sub"] {2131             assert!(index.contains(name), "tree should list {name}");2132         }2133         for name in ["README.md", "notes.txt", "LICENSE"] {2134             assert!(index.contains(name), "tree should list {name}");2135         }2136         // README rendered to HTML (markdown heading), raw HTML escaped.2137         assert!(index.contains("Hello fixture"), "README body rendered");2138         assert!(2139             !index.contains("<script>alert(1)</script>"),2140             "raw HTML escaped"2141         );2142         assert!(index.contains("&lt;script&gt;"), "escaped HTML present");2143         // Header (m6 finish, f0rge #53): branch/sha/CI live ONLY in the2144         // clone summary — no standalone repo-meta / CI dupe line.2145         assert!(index.contains("main"), "branch shown");2146         assert!(!index.contains("TODO"), "no TODO markers left");2147         assert!(index.contains("issues (0)"), "nav open issues 0");2148         assert!(index.contains("pulls (0)"), "nav open pulls 0");2149         assert!(!index.contains("repo-meta"), "no dupe repo-meta line");2150         assert!(!index.contains("<p>CI:"), "no dupe CI line");2151         assert!(index.contains("badge"), "CI verdict badge in summary");2152         let summ = index2153             .find("<summary><span class=\"cb-seg\">Clone")2154             .expect("summary");2155         let send = index[summ..].find("</summary>").expect("summary end") + summ;2156         let summary = &index[summ..send];2157         assert!(summary.contains("main"), "branch in summary");2158         assert!(summary.contains("CI"), "CI in summary");2159         assert!(summary.contains("badge"), "badge in summary");2160         // f0rge #36: code tab carries no redundant counts line.2161         let head_start = index.find("<div class=\"repo-head\">").expect("repo-head");2162         let details_end = index[head_start..].find("</details>").expect("details end") + head_start;2163         let head = &index[head_start..details_end];2164         for needle in ["issues (", "pulls (", "tasks (", "releases ("] {2165             assert!(!head.contains(needle), "code tab drops {needle}");2166         }2167         // Clone block (bundle-first: no origin remote is configured).2168         assert!(index.contains("clone-block"), "clone block present");2169         assert!(index.contains("repo.bundle"), "bundle linked");2170         assert!(index.contains("repo.tar.gz"), "tarball linked");2171         assert!(index.contains("git clone repo.bundle"), "bundle clone docs");2172         assert!(2173             index.contains("tar -xzf repo.tar.gz"),2174             "tarball extract docs"2175         );2176         // No clone URL line when no origin remote is configured.2177         assert!(2178             !index.contains("git clone http"),2179             "no URL line without remote"2180         );2181     }2182 2183     #[test]2184     fn open_counts_nav_and_no_code_tab_counts() {2185         // m6/open-counts (f0rge #29 + #36): header nav is open-only,2186         // code tab keeps branch/sha/CI but drops the counts line,2187         // index pages carry the `N closed` totals.2188         let repo = fixture_repo();2189         let forge_write = |rel: &str, content: &str| {2190             let dest = repo.join(".forge").join(rel);2191             std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();2192             std::fs::write(&dest, content).unwrap();2193         };2194         for (uuid, state, hour) in [2195             ("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "open", "03"),2196             ("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "in-progress", "04"),2197             ("cccccccc-cccc-cccc-cccc-cccccccccccc", "closed", "05"),2198         ] {2199             forge_write(2200                 &format!("issues/{uuid}/issue.md"),2201                 &format!(2202                     "---\nid: {uuid}\ntitle: Issue {state}\nstate: {state}\ncreated: 2026-09-10T{hour}:00:00Z\n---\nbody\n"2203                 ),2204             );2205         }2206         for (uuid, state, draft, hour) in [2207             ("dddddddd-dddd-dddd-dddd-dddddddddddd", "open", false, "03"),2208             ("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", "open", true, "04"),2209             (2210                 "ffffffff-ffff-ffff-ffff-ffffffffffff",2211                 "merged",2212                 false,2213                 "05",2214             ),2215         ] {2216             forge_write(2217                 &format!("prs/{uuid}/pr.md"),2218                 &format!(2219                     "---\nid: {uuid}\ntitle: PR {uuid}\nstate: {state}\nbase: main\nhead: feat\ncreated: 2026-09-10T{hour}:00:00Z\ndraft: {draft}\n---\ndesc\n"2220                 ),2221             );2222         }2223         git(&repo, &["add", "-A"]);2224         git(&repo, &["commit", "-m", "forge counts"]);2225         let out = mktemp("out-open-counts");2226         run_build(&repo, &out, &opts()).expect("build");2227         let index = std::fs::read_to_string(out.join("index.html")).expect("index");2228         assert!(index.contains("issues (1)"), "nav open issues");2229         assert!(index.contains("pulls (2)"), "nav open pulls (draft incl)");2230         assert!(2231             !index.contains("repo-meta"),2232             "no dupe repo-meta (f0rge #53)"2233         );2234         assert!(!index.contains("<p>CI:"), "no dupe CI line (f0rge #53)");2235         assert!(index.contains("badge"), "CI badge in summary");2236         let head_start = index.find("<div class=\"repo-head\">").expect("repo-head");2237         let details_end = index[head_start..].find("</details>").expect("details end") + head_start;2238         let head = &index[head_start..details_end];2239         for needle in ["issues (", "pulls (", "tasks (", "releases ("] {2240             assert!(!head.contains(needle), "code tab drops {needle}");2241         }2242         let issues = std::fs::read_to_string(out.join("issues/index.html")).expect("issues");2243         assert!(issues.contains("2 closed"), "issues closed total");2244         let pulls = std::fs::read_to_string(out.join("pulls/index.html")).expect("pulls");2245         assert!(pulls.contains("1 closed"), "pulls closed total");2246         assert!(pulls.contains(" draft"), "draft stays marked");2247     }2248 2249     #[test]2250     fn clone_url_flag_wins() {2251         let repo = fixture_repo();2252         let out = mktemp("out-flag");2253         let mut o = opts();2254         o.clone_url_flag = Some("https://example.com/x.git".to_string());2255         run_build(&repo, &out, &o).expect("build");2256         let index = std::fs::read_to_string(out.join("index.html")).expect("index");2257         assert!(index.contains("git clone https://example.com/x.git"));2258     }2259 2260     #[test]2261     fn raw_byte_identical_for_all_files() {2262         let (repo, out) = build_fixture();2263         let raws = test_all_files(&out.join("raw"));2264         assert_eq!(raws.len(), 10, "10 raw files, got {raws:?}");2265         for rel in &raws {2266             let want = std::fs::read(repo.join(rel)).expect("repo file");2267             let got = std::fs::read(out.join("raw").join(rel)).expect("raw file");2268             assert_eq!(got, want, "byte-identical: {}", rel.display());2269         }2270     }2271 2272     #[test]2273     fn placeholder_for_binary_and_big_file() {2274         let (_repo, out) = build_fixture();2275         for page in ["files/assets/logo.bin.html", "files/data/big.txt.html"] {2276             let html = std::fs::read_to_string(out.join(page)).expect(page);2277             assert!(html.contains("placeholder"), "{page} has placeholder");2278             assert!(html.contains("/raw/"), "{page} links a download");2279         }2280         // Binary bytes must never be inlined into the page.2281         let bin_page =2282             std::fs::read_to_string(out.join("files/assets/logo.bin.html")).expect("bin page");2283         assert!(!bin_page.contains("binary tail"), "binary not inlined");2284         // Small text file gets line numbers + raw link, no placeholder.2285         let txt = std::fs::read_to_string(out.join("files/notes.txt.html")).expect("txt page");2286         assert!(txt.contains("/raw/notes.txt"), "raw link");2287         assert!(!txt.contains("placeholder"), "text file inlined");2288         assert!(2289             txt.contains("&lt;") || txt.contains("plain notes"),2290             "escaped pre body"2291         );2292     }2293 2294     #[test]2295     fn md_files_are_rendered() {2296         let (_repo, out) = build_fixture();2297         let html = std::fs::read_to_string(out.join("files/docs/guide.md.html")).expect("md page");2298         assert!(2299             html.contains("<h1 id=\"guide\">Guide</h1>"),2300             "markdown rendered, got: {html}"2301         );2302         assert!(html.contains("/raw/docs/guide.md"), "raw link kept");2303     }2304 2305     #[test]2306     fn bundle_clones() {2307         let (_repo, out) = build_fixture();2308         assert!(out.join("repo.bundle").exists());2309         assert!(out.join("repo.tar.gz").exists());2310         let dest_parent = mktemp("clone");2311         let dest = dest_parent.join("cloned");2312         let st = Command::new("git")2313             .arg("clone")2314             .arg("-b")2315             .arg("main")2316             .arg(out.join("repo.bundle"))2317             .arg(&dest)2318             .status()2319             .expect("git clone");2320         assert!(st.success(), "bundle clones");2321         assert!(dest.join("README.md").exists());2322         assert!(dest.join("sub").join("nested.txt").exists());2323     }2324 2325     #[test]2326     fn no_forge_build_succeeds() {2327         // Fixture has no `.forge/` at all; build must still exit Ok (0).2328         let repo = fixture_repo();2329         assert!(!repo.join(".forge").exists());2330         let out = mktemp("out-noforge");2331         let res = run_build(&repo, &out, &opts());2332         assert!(res.is_ok(), "no-forge build exits 0, got {res:?}");2333         assert!(out.join("index.html").exists());2334     }2335 2336     #[test]2337     fn readme_chain_prefers_root_then_wiki() {2338         let dir = mktemp("readme-chain");2339         git(&dir, &["init", "-b", "main"]);2340         git(&dir, &["config", "user.name", "hu13-test"]);2341         git(&dir, &["config", "user.email", "[email protected]"]);2342         git(&dir, &["config", "commit.gpgsign", "false"]);2343         // Wiki-only README first.2344         std::fs::create_dir_all(dir.join(".forge").join("wiki")).unwrap();2345         std::fs::write(2346             dir.join(".forge").join("wiki").join("README.md"),2347             "# Wiki readme\n",2348         )2349         .unwrap();2350         std::fs::write(dir.join("notes.txt"), "x\n").unwrap();2351         git(&dir, &["add", "-A"]);2352         git(&dir, &["commit", "-m", "wiki readme only"]);2353         let (path, _) = find_readme(&dir, "main").expect("wiki fallback");2354         assert_eq!(path, ".forge/wiki/README.md");2355         // Root README.md wins once added.2356         std::fs::write(dir.join("README.md"), "# Root readme\n").unwrap();2357         git(&dir, &["add", "-A"]);2358         git(&dir, &["commit", "-m", "root readme"]);2359         let (path, _) = find_readme(&dir, "main").expect("root");2360         assert_eq!(path, "README.md");2361     }2362 2363     #[test]2364     fn readme_chain_shows_extensionless_wiki_readme() {2365         let dir = mktemp("readme-extless");2366         git(&dir, &["init", "-b", "main"]);2367         git(&dir, &["config", "user.name", "hu13-test"]);2368         git(&dir, &["config", "user.email", "[email protected]"]);2369         git(&dir, &["config", "commit.gpgsign", "false"]);2370         // Extensionless wiki README only (f0rge issue #35).2371         std::fs::create_dir_all(dir.join(".forge").join("wiki")).unwrap();2372         std::fs::write(2373             dir.join(".forge").join("wiki").join("README"),2374             "# Wiki extless\n",2375         )2376         .unwrap();2377         std::fs::write(dir.join("notes.txt"), "x\n").unwrap();2378         git(&dir, &["add", "-A"]);2379         git(&dir, &["commit", "-m", "wiki extless only"]);2380         let (path, _) = find_readme(&dir, "main").expect("wiki extless fallback");2381         assert_eq!(path, ".forge/wiki/README");2382         // Code tab renders it.2383         let out = mktemp("out-extless");2384         run_build(&dir, &out, &opts()).expect("build");2385         let index = std::fs::read_to_string(out.join("index.html")).expect("index");2386         assert!(index.contains("Wiki extless"), "code tab shows wiki README");2387         assert!(2388             !index.contains("from <code>") && !index.contains(".forge/wiki/README</code>"),2389             "code tab does not name the README path"2390         );2391     }2392 2393     #[test]2394     fn readme_chain_full_priority_order() {2395         let dir = mktemp("readme-priority");2396         git(&dir, &["init", "-b", "main"]);2397         git(&dir, &["config", "user.name", "hu13-test"]);2398         git(&dir, &["config", "user.email", "[email protected]"]);2399         git(&dir, &["config", "commit.gpgsign", "false"]);2400         let chain = [2401             "README.md",2402             "readme.md",2403             "README",2404             "readme",2405             ".forge/wiki/README.md",2406             ".forge/wiki/readme.md",2407             ".forge/wiki/README",2408             ".forge/wiki/readme",2409         ];2410         std::fs::create_dir_all(dir.join(".forge").join("wiki")).unwrap();2411         for (i, cand) in chain.iter().enumerate() {2412             std::fs::write(dir.join(cand), format!("# marker {i}\n")).unwrap();2413         }2414         std::fs::write(dir.join("notes.txt"), "x\n").unwrap();2415         git(&dir, &["add", "-A"]);2416         git(&dir, &["commit", "-m", "all readmes"]);2417         for (i, want) in chain.iter().enumerate() {2418             let (path, blob) = find_readme(&dir, "main").expect("readme present");2419             assert_eq!(&path, want, "priority position {i}");2420             assert!(2421                 blob.display.contains(&format!("marker {i}")),2422                 "winning body at {want}"2423             );2424             // Root extensionless beats wiki once wiki-only remains.2425             if *want == "readme" {2426                 let (p, _) = find_readme(&dir, "main").expect("root extless");2427                 assert_eq!(p, "readme");2428             }2429             git(&dir, &["rm", "-q", want]);2430             git(&dir, &["commit", "-m", &format!("drop {want}")]);2431         }2432         assert!(find_readme(&dir, "main").is_none(), "none when chain empty");2433     }2434 2435     #[test]2436     fn build_emits_commits_and_wiki() {2437         let repo = fixture_repo();2438         // On-disk wiki with one page (proves the .forge — not git — read path).2439         std::fs::create_dir_all(repo.join(".forge").join("wiki")).unwrap();2440         std::fs::write(2441             repo.join(".forge").join("wiki").join("notes.md"),2442             "# Build notes\n\nwiki body\n",2443         )2444         .unwrap();2445         git(&repo, &["add", "-A"]);2446         git(&repo, &["commit", "-m", "add wiki"]);2447         let out = mktemp("out-commits-wiki");2448         run_build(&repo, &out, &opts()).expect("build");2449         // Commits wired in.2450         let list = std::fs::read_to_string(out.join("commits.html")).expect("list");2451         assert!(list.contains("fixture commit"));2452         assert!(list.contains("add wiki"));2453         assert!(list.contains("/commit/"), "detail links");2454         let log = gitx::log_n(&repo, "main", 100).expect("log");2455         assert!(out2456             .join("commit")2457             .join(format!("{}.html", log[0].sha))2458             .exists());2459         // Wiki wired in (index + per-page), nav links present.2460         let index = std::fs::read_to_string(out.join("index.html")).expect("index");2461         assert!(index.contains("commits.html"), "nav links commits");2462         assert!(index.contains("wiki/index.html"), "nav links wiki");2463         let windex =2464             std::fs::read_to_string(out.join("wiki").join("index.html")).expect("wiki index");2465         assert!(windex.contains("Build notes"), "H1 title");2466         assert!(out.join("wiki").join("notes.md.html").exists());2467     }2468 2469     #[test]2470     fn markdown_escapes_raw_html() {2471         let html = markdown_to_html("# hi\n\n<script>alert(1)</script>\n\n**bold**\n");2472         assert!(html.contains("<h1 id=\"hi\">hi</h1>"), "heading renders");2473         assert!(!html.contains("<script>"), "no raw passthrough");2474         assert!(html.contains("&lt;script&gt;"), "escaped instead");2475         assert!(2476             html.contains("<strong>bold</strong>"),2477             "legit markdown intact"2478         );2479     }2480 2481     #[test]2482     fn markdown_enables_gfm_tables_tasks_strikethrough() {2483         let md = "| a | b |\n|---|---|\n| 1 | 2 |\n\n- [x] done\n- [ ] todo\n\n~~gone~~\n";2484         let html = markdown_to_html(md);2485         let table = format!("<{}>", "table");2486         assert!(html.contains(&table), "table parsed: {html}");2487         assert!(html.contains("<th>a</th>"), "header cell");2488         assert!(html.contains("<td>1</td>"), "body cell");2489         assert!(html.contains("type=\"checkbox\""), "task checkbox");2490         assert!(html.contains("checked"), "checked task preserved");2491         assert!(html.contains("<li>"), "task list item");2492         assert!(html.contains("<del>gone</del>"), "strikethrough");2493     }2494 2495     #[test]2496     fn markdown_blocks_dangerous_link_schemes() {2497         for evil in [2498             "[x](javascript:alert(1))",2499             "[x](JaVaScRiPt:alert(1))",2500             "[x](java\tscript:alert(1))",2501             "[x](javascript&colon;alert(1))",2502             "[x](&#106;avascript:alert(1))",2503             "[x](data:text/html;base64,PHNjcmlwdD4=)",2504             "[x](vbscript:msgbox(1))",2505             "[x](file:///etc/passwd)",2506             "![i](javascript:alert(1))",2507             "![i](data:image/svg+xml,<svg onload=alert(1)>)",2508         ] {2509             let html = markdown_to_html(evil);2510             let lower = html.to_ascii_lowercase();2511             for attr in ["href=", "src="] {2512                 for bad in ["javascript:", "vbscript:", "data:", "file:"] {2513                     assert!(2514                         !lower.contains(&format!("{attr}\"{bad}")),2515                         "{evil:?} must not emit {attr}{bad}: {html}"2516                     );2517                 }2518             }2519         }2520     }2521 2522     #[test]2523     fn markdown_keeps_safe_links_and_relative_urls() {2524         let md = "[a](https://example.com/x?y=1&z=2) [b](/files/x.html) [c](mailto:a@b) [d](tel:+1) [e](#L3)";2525         let html = markdown_to_html(md);2526         assert!(html.contains("href=\"https://example.com/x?y=1&amp;z=2\""));2527         assert!(html.contains("href=\"/files/x.html\""));2528         assert!(html.contains("href=\"mailto:a@b\""));2529         assert!(html.contains("href=\"tel:+1\""));2530         assert!(html.contains("href=\"#L3\""));2531     }2532 2533     #[test]2534     fn human_size_formats() {2535         assert_eq!(human_size(0), "0 B");2536         assert_eq!(human_size(24), "24 B");2537         assert_eq!(human_size(1023), "1023 B");2538         assert_eq!(human_size(2048), "2.0 KiB");2539         assert_eq!(human_size(3 * 1024 * 1024), "3.0 MiB");2540     }2541 2542     #[test]2543     fn breadcrumb_links_each_segment() {2544         let (_repo, out) = build_fixture();2545         // File page: root plus every segment linked.2546         let html =2547             std::fs::read_to_string(out.join("files/sub/nested.txt.html")).expect("file page");2548         assert!(html.contains("<a href=\"/\">root</a>"), "root crumb");2549         assert!(2550             html.contains("<a href=\"/files/sub.html\">sub</a>"),2551             "dir segment linked"2552         );2553         assert!(2554             html.contains("<a href=\"/files/sub/nested.txt.html\">nested.txt</a>"),2555             "file segment linked"2556         );2557         // Dir page carries a breadcrumb too.2558         let dir = std::fs::read_to_string(out.join("files/sub.html")).expect("dir page");2559         assert!(dir.contains("<a href=\"/\">root</a>"), "dir root crumb");2560         assert!(2561             dir.contains("<a href=\"/files/sub.html\">sub</a>"),2562             "dir self crumb"2563         );2564     }2565 2566     #[test]2567     fn line_numbers_are_shareable_anchors() {2568         let (_repo, out) = build_fixture();2569         let html = std::fs::read_to_string(out.join("files/notes.txt.html")).expect("txt page");2570         assert!(html.contains("id=\"L1\""), "line 1 anchor");2571         assert!(html.contains("href=\"#L1\""), "line 1 link");2572         assert!(html.contains("id=\"L2\""), "line 2 anchor");2573         assert!(html.contains("href=\"#L2\""), "line 2 link");2574     }2575 2576     #[test]2577     fn file_meta_bar_has_size_lines_raw_and_path() {2578         let (_repo, out) = build_fixture();2579         let html = std::fs::read_to_string(out.join("files/notes.txt.html")).expect("txt page");2580         assert!(html.contains("file-meta"), "meta bar");2581         assert!(html.contains("notes.txt"), "path text");2582         // "plain notes\nsecond line\n" is 24 bytes in 2 lines.2583         assert!(html.contains("24 B"), "humanized size");2584         assert!(html.contains("2 lines"), "line count");2585         assert!(html.contains("/raw/notes.txt"), "raw button");2586     }2587 2588     #[test]2589     fn dir_page_has_parent_row_and_sizes() {2590         // m6/code-gitea (f0rge #33): dir pages are a Gitea-style TABLE2591         // (Name / Last commit / Age); sizes + raw live on file pages2592         // (file_meta_bar), not in the table. Breadcrumb stays.2593         let (_repo, out) = build_fixture();2594         let html = std::fs::read_to_string(out.join("files/sub.html")).expect("dir page");2595         assert!(2596             html.contains("file-table\">"),2597             "file TABLE, got: {}",2598             &html[..2000.min(html.len())]2599         );2600         assert!(2601             html.contains("<a href=\"/\">.. (root)</a>"),2602             "parent .. row to root"2603         );2604         assert!(html.contains("nested.txt"), "lists file");2605         assert!(html.contains("class=\"crumbs\""), "breadcrumb kept");2606         assert!(html.contains("<th>Name</th>"), "Name header");2607         assert!(html.contains("<th>Last commit</th>"), "message header");2608         assert!(html.contains("<th>Age</th>"), "age header");2609         // Sizes + raw live on the file page itself (meta bar), not the table.2610         let txt = std::fs::read_to_string(out.join("files/notes.txt.html")).expect("txt page");2611         assert!(txt.contains("24 B"), "file size on file page");2612         assert!(txt.contains("/raw/notes.txt"), "raw on file page");2613     }2614 2615     #[test]2616     fn file_table_is_gitea_style_dirs_first() {2617         // f0rge #33 rubric: TABLE (not bullets), dirs-first then files2618         // each alpha, dirs with `/` suffix, parent `..` row, breadcrumb.2619         let (_repo, out) = build_fixture();2620         let index = std::fs::read_to_string(out.join("index.html")).expect("index");2621         assert!(index.contains("file-table\">"), "root TABLE");2622         assert!(2623             !index.contains("<ul class=\"tree\">"),2624             "no bullets on code tab"2625         );2626         assert!(2627             index.contains("class=\"crumbs\"") || index.contains("repo-head"),2628             "page kept"2629         );2630         // Dirs carry a trailing slash link; files do not gain one.2631         assert!(index.contains(">src/</a>"), "dir with / suffix");2632         // Dirs-first: every root dir sorts before every root file.2633         let dir_pos = index.find(">src/</a>").expect("src pos");2634         let file_pos = index.find(">README.md</a>").expect("readme pos");2635         assert!(dir_pos < file_pos, "dirs-first in root TABLE");2636         // Subdir page: parent row + breadcrumb + own TABLE.2637         let sub = std::fs::read_to_string(out.join("files/sub.html")).expect("sub");2638         assert!(sub.contains("file-table\">"), "subdir TABLE");2639         assert!(sub.contains("<tr class=\"parent\">"), "parent row element");2640         assert!(2641             sub.contains("<a href=\"/\">.. (root)</a>"),2642             "parent .. link"2643         );2644         assert!(2645             sub.contains("<nav class=\"crumbs\""),2646             "breadcrumb kept on subdir"2647         );2648         // Message + age columns exist even when history is empty-ish.2649         assert!(2650             sub.contains("<td class=\"title trunc trunc-message\""),2651             "message cell trunc"2652         );2653         assert!(sub.contains("title=\""), "title attrs kept");2654     }2655 2656     /// m6/cards2 (f0rge #46): file table stays a real table on all widths —2657     /// no data-label card treatment, scroll wrapper kept, trunc + title kept.2658     #[test]2659     fn file_table_stays_table_no_card_labels() {2660         let css = include_str!("../static/style.css");2661         assert!(css.contains("@media (max-width:767px)"), "narrow query");2662         assert!(2663             css.contains(".table-scroll:not(.file-scroll)"),2664             "cards scoped off file-scroll"2665         );2666         assert!(2667             css.contains(".file-scroll{overflow-x:auto}"),2668             "file-scroll keeps h-scroll on narrow"2669         );2670         let (_repo, out) = build_fixture();2671         for page in ["index.html", "files/sub.html"] {2672             let html = std::fs::read_to_string(out.join(page)).expect(page);2673             assert!(2674                 html.contains(2675                     "<div class=\"table-scroll file-scroll\"><table class=\"file-table\">"2676                 ),2677                 "{page}: scroll wrapper kept"2678             );2679             assert!(!html.contains("data-label="), "{page}: no card labels");2680             assert!(2681                 html.contains("<td class=\"title trunc trunc-message\""),2682                 "{page}: message cell trunc kept"2683             );2684             assert!(html.contains("title=\""), "{page}: title attrs kept");2685             assert!(2686                 html.contains("<th>Name</th>")2687                     && html.contains("<th>Last commit</th>")2688                     && html.contains("<th>Age</th>"),2689                 "{page}: headers kept"2690             );2691         }2692     }2693 2694     #[test]2695     fn truncate_msg_cuts_at_72_with_ellipsis() {2696         assert_eq!(truncate_msg("short"), "short");2697         let exactly = "x".repeat(72);2698         assert_eq!(truncate_msg(&exactly), exactly, "72 chars untouched");2699         let long = "y".repeat(100);2700         let got = truncate_msg(&long);2701         assert_eq!(got.chars().count(), 73, "72 + ellipsis, got {got:?}");2702         assert!(got.ends_with("…"), "visible truncation");2703         // Multi-byte chars count as chars, never split a boundary.2704         let uni = "é".repeat(100);2705         let got = truncate_msg(&uni);2706         assert_eq!(got.chars().count(), 73);2707         assert!(got.ends_with("…"));2708     }2709 2710     #[test]2711     fn parse_git_and_rfc3339_dates() {2712         assert_eq!(2713             parse_ts_secs("2026-09-10T07:43:55Z"),2714             parse_ts_secs("2026-09-10 07:43:55 +0000")2715         );2716         let utc = parse_ts_secs("2026-09-10T07:43:55Z").unwrap();2717         let plus1 = parse_ts_secs("2026-09-10 08:43:55 +0100").unwrap();2718         assert_eq!(utc, plus1, "offset is applied");2719         assert!(parse_ts_secs("not a date").is_none());2720         assert_eq!(ts_title("2026-09-10T07:43:55Z"), "2026-09-10 07:43 UTC");2721     }2722 2723     #[test]2724     fn output_budget_reports_file_size_and_count() {2725         let dir = mktemp("budget");2726         std::fs::write(dir.join("small.txt"), "ok").unwrap();2727         std::fs::create_dir_all(dir.join("nested")).unwrap();2728         std::fs::write(dir.join("nested/big.bin"), vec![0u8; 64]).unwrap();2729         assert!(check_output_budget(&dir, 32, 10).is_err(), "size cap");2730         assert!(check_output_budget(&dir, 1024, 1).is_err(), "count cap");2731         assert!(check_output_budget(&dir, 1024, 2).is_ok(), "within caps");2732         std::fs::remove_dir_all(&dir).unwrap();2733     }2734 2735     #[test]2736     fn hostile_dates_are_rejected() {2737         // Hostile `.forge` timestamps must not panic (f0rge #82).2738         assert!(parse_ts_secs("100000000000000000-01-01T00:00:00Z").is_none());2739         assert!(parse_ts_secs("-9223372036854775808-01-01T00:00:00Z").is_none());2740         assert!(parse_ts_secs("0000-01-01T00:00:00Z").is_none());2741         assert!(parse_ts_secs("10000-01-01T00:00:00Z").is_none());2742         assert!(parse_ts_secs("2026-02-31T00:00:00Z").is_none());2743         assert!(parse_ts_secs("2026-04-31T00:00:00Z").is_none());2744         assert!(parse_ts_secs("2026-02-29T00:00:00Z").is_none());2745         assert!(parse_ts_secs("2024-02-29T00:00:00Z").is_some());2746         assert!(parse_ts_secs("2026-01-01T00:00:00+2500").is_none());2747         assert!(parse_ts_secs("2026-01-01T00:00:00+0060").is_none());2748     }2749 2750     #[test]2751     fn relative_age_fixed_clock() {2752         // f0rge #33 rubric: fixed-clock fixture, build-time relative ages.2753         let now = 1_786_000_000_i64;2754         assert_eq!(relative_age(now, now), "just now");2755         assert_eq!(relative_age(now, now - 5), "just now");2756         assert_eq!(relative_age(now, now - 30), "30s ago");2757         assert_eq!(relative_age(now, now - 5 * 60), "5m ago");2758         assert_eq!(relative_age(now, now - 3 * 3600), "3h ago");2759         assert_eq!(relative_age(now, now - 3 * 86_400), "3d ago");2760         assert_eq!(relative_age(now, now - 10 * 86_400), "10d ago");2761         assert_eq!(relative_age(now, now - 60 * 86_400), "2mo ago");2762         assert_eq!(relative_age(now, now - 400 * 86_400), "1y ago");2763         assert_eq!(relative_age(now, now + 9999), "just now", "future clamps");2764     }2765 2766     #[test]2767     fn language_math_exact_shares_and_tiny_grouping() {2768         // f0rge #33 rubric: known bytes -> exact %.2769         let files = vec![("a.rs".to_string(), 100_u64), ("b.py".to_string(), 300_u64)];2770         let stats = compute_languages(&files);2771         assert_eq!(stats.len(), 2);2772         assert_eq!(stats[0].lang, "Python");2773         assert_eq!(stats[0].bytes, 300);2774         assert!(2775             (stats[0].pct - 75.0).abs() < 1e-9,2776             "py 75%, got {}",2777             stats[0].pct2778         );2779         assert_eq!(stats[1].lang, "Rust");2780         assert!(2781             (stats[1].pct - 25.0).abs() < 1e-9,2782             "rs 25%, got {}",2783             stats[1].pct2784         );2785         // <1% merges into Other.2786         let mut files = vec![("big.rs".to_string(), 10_000_u64)];2787         files.push(("tiny.py".to_string(), 50_u64));2788         let stats = compute_languages(&files);2789         let langs: Vec<&str> = stats.iter().map(|s| s.lang.as_str()).collect();2790         assert!(2791             !langs.contains(&"Python"),2792             "tiny <1% grouped, got {langs:?}"2793         );2794         assert!(langs.contains(&"Other"), "other carries tiny share");2795         // Extension map covers the issue list; unknown -> Other.2796         for (path, want) in [2797             ("x.rs", "Rust"),2798             ("x.py", "Python"),2799             ("x.js", "JavaScript"),2800             ("x.ts", "TypeScript"),2801             ("x.go", "Go"),2802             ("x.sh", "Shell"),2803             ("x.css", "CSS"),2804             ("x.html", "HTML"),2805             ("x.md", "Markdown"),2806             ("x.toml", "TOML"),2807             ("x.yaml", "YAML"),2808             ("x.yml", "YAML"),2809             ("x.json", "JSON"),2810             ("x.c", "C"),2811             ("x.h", "C"),2812             ("x.txt", "Other"),2813             ("Dockerfile", "Other"),2814         ] {2815             assert_eq!(language_of(path), want, "map for {path}");2816         }2817     }2818 2819     #[test]2820     fn language_bar_hidden_without_code() {2821         // f0rge #33: bar hidden when no code; note shown instead. No JS.2822         let txt_only = vec![2823             ("notes.txt".to_string(), 24_u64),2824             ("data.bin".to_string(), 10_u64),2825         ];2826         let html = language_bar(&txt_only);2827         assert!(html.contains("No languages yet."), "note when no code");2828         assert!(!html.contains("lang-bar"), "bar hidden when no code");2829         assert!(!html.contains("<script"), "no JS in lang block");2830         // With code: stacked bar + legend with %.2831         let code = vec![("a.rs".to_string(), 100_u64), ("b.py".to_string(), 100_u64)];2832         let html = language_bar(&code);2833         assert!(html.contains("lang-bar"), "bar shown with code");2834         assert!(html.contains("lang-legend"), "legend shown");2835         assert!(html.contains("Rust"), "rust legend");2836         assert!(html.contains("50.0%"), "exact share in legend");2837         assert!(html.contains("background:#"), "Tokyo Night hues inline");2838     }2839 2840     #[test]2841     fn language_bar_shows_on_index_with_legend() {2842         // Integration: balanced code repo -> stacked bar + legend.2843         // (The 10-file fixture is 300KB of .txt, so its tiny .rs/.md2844         // shares correctly collapse into Other per the <1% rule.)2845         let dir = mktemp("lang-index");2846         git(&dir, &["init", "-b", "main"]);2847         git(&dir, &["config", "user.name", "hu13-test"]);2848         git(&dir, &["config", "user.email", "[email protected]"]);2849         git(&dir, &["config", "commit.gpgsign", "false"]);2850         std::fs::write(dir.join("a.rs"), "fn a() {}\n".repeat(20)).unwrap();2851         std::fs::write(dir.join("b.py"), "print(1)\n".repeat(20)).unwrap();2852         std::fs::write(dir.join("README.md"), "# hi\n").unwrap();2853         git(&dir, &["add", "-A"]);2854         git(&dir, &["commit", "-m", "lang mix"]);2855         let out = mktemp("out-lang-index");2856         run_build(&dir, &out, &opts()).expect("build");2857         let index = std::fs::read_to_string(out.join("index.html")).expect("index");2858         assert!(index.contains("lang-bar"), "stacked bar on code tab");2859         assert!(index.contains("lang-legend"), "legend on code tab");2860         assert!(index.contains("Rust"), "rust in legend");2861         assert!(index.contains("Python"), "python in legend");2862         assert!(index.contains('%'), "legend carries %");2863         // The big-file fixture still shows a bar (100% Other), never empty.2864         let (_repo2, out2) = build_fixture();2865         let index2 = std::fs::read_to_string(out2.join("index.html")).expect("index2");2866         assert!(index2.contains("lang-bar"), "fixture bar present (Other)");2867         assert!(index2.contains("Other"), "tiny shares group into Other");2868     }2869 2870     #[test]2871     fn escaping_evil_filename_and_message() {2872         // f0rge #33 rubric: escaping everywhere (filename + message).2873         let dir = mktemp("evil-names");2874         git(&dir, &["init", "-b", "main"]);2875         git(&dir, &["config", "user.name", "hu13-test"]);2876         git(&dir, &["config", "user.email", "[email protected]"]);2877         git(&dir, &["config", "commit.gpgsign", "false"]);2878         std::fs::write(dir.join("README.md"), "# hi\n").unwrap();2879         std::fs::write(dir.join("evil<script>.txt"), "evil file\n").unwrap();2880         std::fs::write(dir.join("ok.rs"), "fn main() {}\n").unwrap();2881         git(&dir, &["add", "-A"]);2882         // Evil + over-long message (also exercises 72-char truncation).2883         let evil_msg = "<script>alert(1)</script> ".to_string() + &"A".repeat(100);2884         git(&dir, &["commit", "-m", &evil_msg]);2885         let out = mktemp("out-evil");2886         run_build(&dir, &out, &opts()).expect("build");2887         let index = std::fs::read_to_string(out.join("index.html")).expect("index");2888         assert!(2889             !index.contains("<script>alert(1)</script>"),2890             "no raw script in TABLE"2891         );2892         assert!(index.contains("&lt;script&gt;"), "escaped message in TABLE");2893         assert!(!index.contains("evil<script>.txt"), "no raw filename");2894         assert!(index.contains("evil&lt;script&gt;.txt"), "escaped filename");2895         assert!(index.contains("…"), "long message truncated in TABLE");2896         // File page for the evil name is escaped too.2897         let evil_page = std::fs::read_to_string(out.join("files/evil<script>.txt.html"));2898         assert!(2899             evil_page.is_ok() || out.join("files").exists(),2900             "evil file page emitted"2901         );2902         if let Ok(html) = evil_page {2903             assert!(2904                 !html.contains("<script>alert(1)</script>"),2905                 "no raw on file page"2906             );2907         }2908     }2909 2910     #[test]2911     fn clone_details_collapsed_with_hint_and_contents() {2912         // f0rge #34 rubric: native <details>, closed by default, all2913         // three methods + sizes inside, branch/sha hint in summary.2914         let repo = fixture_repo();2915         let out = mktemp("out-details");2916         let mut o = opts();2917         o.clone_url_flag = Some("https://example.com/x.git".to_string());2918         run_build(&repo, &out, &o).expect("build");2919         let index = std::fs::read_to_string(out.join("index.html")).expect("index");2920         assert!(index.contains("<details"), "details present");2921         assert!(index.contains("class=\"clonebox"), "clonebox class");2922         assert!(index.contains("clone-block"), "clone-block class kept");2923         assert!(index.contains("Clone &amp; download"), "summary label");2924         assert!(index.contains("main"), "branch hint in summary");2925         // Closed by default: no `open` attr on the clone details.2926         let d0 = index2927             .find("<details class=\"clonebox")2928             .expect("details pos");2929         let d1 = index[d0..].find('>').expect("details end") + d0;2930         let tag = &index[d0..d1];2931         assert!(!tag.contains("open"), "closed by default, got {tag:?}");2932         // Contents intact: all three methods + sizes.2933         assert!(2934             index.contains("git clone https://example.com/x.git"),2935             "url method"2936         );2937         assert!(index.contains("git clone repo.bundle"), "bundle method");2938         assert!(index.contains("tar -xzf repo.tar.gz"), "tarball method");2939         assert!(index.contains("repo.bundle"), "bundle size/link");2940         assert!(index.contains("repo.tar.gz"), "tarball size/link");2941         // Keyboard accessible native element, zero JS.2942         assert!(!index.contains("<script"), "zero JS for clone block");2943     }2944 2945     #[test]2946     fn clone_block_with_url_leads_with_git_clone() {2947         let repo = fixture_repo();2948         let out = mktemp("out-clone-url");2949         let mut o = opts();2950         o.clone_url_flag = Some("https://example.com/x.git".to_string());2951         run_build(&repo, &out, &o).expect("build");2952         let index = std::fs::read_to_string(out.join("index.html")).expect("index");2953         assert!(index.contains("clone-block"), "clone block");2954         assert!(2955             index.contains("git clone https://example.com/x.git"),2956             "url clone cmd"2957         );2958         assert!(index.contains("git clone repo.bundle"), "bundle cmd");2959         assert!(index.contains("tar -xzf repo.tar.gz"), "tarball cmd");2960         assert!(index.contains("repo.bundle"), "bundle link");2961         assert!(index.contains("repo.tar.gz"), "tarball link");2962         let url_pos = index2963             .find("git clone https://example.com/x.git")2964             .expect("url pos");2965         let bundle_pos = index.find("git clone repo.bundle").expect("bundle pos");2966         assert!(url_pos < bundle_pos, "url-first with clone url");2967     }2968 2969     #[test]2970     fn clone_block_bundle_first_without_url() {2971         // Fixture has no origin remote: bundle-first presentation.2972         let (_repo, out) = build_fixture();2973         let index = std::fs::read_to_string(out.join("index.html")).expect("index");2974         assert!(index.contains("clone-block"), "clone block");2975         assert!(index.contains("git clone repo.bundle"), "bundle-first cmd");2976         assert!(!index.contains("git clone http"), "no url cmd");2977         let bundle_pos = index.find("repo.bundle").expect("bundle pos");2978         let tar_pos = index.find("repo.tar.gz").expect("tar pos");2979         assert!(bundle_pos < tar_pos, "bundle before tarball");2980     }2981 2982     #[test]2983     fn code_head_ci_badge_green_red() {2984         // f0rge #43: code repo-head CI badge is green/red via2985         // `.badge.pass` / `.badge.fail` (running/cancelled keep tones).2986         for verdict in ["pass", "fail"] {2987             let repo = fixture_repo();2988             let run_dir = repo.join(".forge").join("ci/runs/2026-09-10T00-00-00Z");2989             std::fs::create_dir_all(&run_dir).unwrap();2990             std::fs::write(2991                 run_dir.join("summary.md"),2992                 format!(2993                     "---\nrun: 2026-09-10T00-00-00Z\nverdict: {verdict}\ncreated: 2026-09-10T00:00:00Z\n---\nlog\n"2994                 ),2995             )2996             .unwrap();2997             git(&repo, &["add", "-A"]);2998             git(&repo, &["commit", "-m", &format!("ci {verdict}")]);2999             let out = mktemp(&format!("out-ci-{verdict}"));3000             run_build(&repo, &out, &opts()).expect("build");3001             let index = std::fs::read_to_string(out.join("index.html")).expect("index");3002             let summ = index3003                 .find("<summary><span class=\"cb-seg\">Clone")3004                 .expect("summary");3005             let send = index[summ..].find("</summary>").expect("summary end") + summ;3006             let summary = &index[summ..send];3007             assert!(3008                 summary.contains(&format!("badge {verdict}")),3009                 "clone summary carries badge {verdict}"3010             );3011             assert!(!index.contains("repo-meta"), "no dupe meta");3012             assert!(!index.contains("<p>CI:"), "no dupe CI line");3013         }3014     }3015 3016     #[test]3017     fn clone_summary_followed_by_body_no_gap() {3018         // f0rge #44: summary immediately followed by content wrapper —3019         // no empty <p>, no margin-collapse gap when collapsed.3020         let (_repo, out) = build_fixture();3021         let index = std::fs::read_to_string(out.join("index.html")).expect("index");3022         assert!(3023             index.contains("</summary>\n<div class=\"clone-body\">"),3024             "summary immediately followed by clone-body wrapper"3025         );3026         let s = index.find("</summary>").expect("summary end");3027         let after = &index[s..s + 200];3028         assert!(3029             !after.contains("<p></p>"),3030             "no empty paragraph after summary"3031         );3032         assert!(3033             !after.contains("<p> </p>"),3034             "no blank paragraph after summary"3035         );3036         // Wrapper closes before the details closes (structure intact).3037         assert!(3038             index.contains("</div>\n</details>"),3039             "clone-body closes inside details"3040         );3041     }3042 3043     #[test]3044     fn is_binary_sniffs_first_8kib() {3045         // f0rge #37: NUL in the first 8KiB means binary for math.3046         assert!(!is_binary(&[]), "empty is text");3047         assert!(!is_binary(b"fn main() {}\n"), "plain text");3048         assert!(is_binary(b"ab\x00cd"), "early NUL");3049         assert!(!is_binary(&[b'a'; 100]), "no NUL anywhere");3050         // NUL at the last sniffed byte still counts; just past it does not.3051         let mut edge_in = vec![b'a'; BINARY_SNIFF_BYTES];3052         edge_in[BINARY_SNIFF_BYTES - 1] = 0;3053         assert!(is_binary(&edge_in), "NUL at byte 8191");3054         let mut edge_out = vec![b'a'; BINARY_SNIFF_BYTES + 1];3055         edge_out[BINARY_SNIFF_BYTES] = 0;3056         assert!(!is_binary(&edge_out), "NUL past 8KiB ignored");3057         assert_eq!(BINARY_SNIFF_BYTES, 8 * 1024, "8KiB sniff window");3058     }3059 3060     #[test]3061     fn lang_rows_skips_ignored_and_binaries() {3062         // Pure unit: ignored paths + NUL blobs drop out, real text3063         // (even unmapped extensions) stays for `Other`.3064         use std::collections::{BTreeMap, HashSet};3065         let mut blobs: BTreeMap<String, Vec<u8>> = BTreeMap::new();3066         blobs.insert("a.rs".to_string(), b"fn a() {}\n".to_vec());3067         blobs.insert("notes.txt".to_string(), b"hello\n".to_vec());3068         blobs.insert("blob.bin".to_string(), b"ab\x00cd".to_vec());3069         blobs.insert("ignored/s.rs".to_string(), b"fn s() {}\n".to_vec());3070         let ignored: HashSet<String> = ["ignored/s.rs".to_string()].into_iter().collect();3071         let mut rows = lang_rows(&blobs, &ignored);3072         rows.sort();3073         let paths: Vec<&str> = rows.iter().map(|(p, _)| p.as_str()).collect();3074         assert_eq!(paths, vec!["a.rs", "notes.txt"], "only text kept");3075         // Sizes are exact byte lengths.3076         let size = |name: &str| rows.iter().find(|(p, _)| p == name).unwrap().1;3077         assert_eq!(size("a.rs"), b"fn a() {}\n".len() as u64);3078         assert_eq!(size("notes.txt"), b"hello\n".len() as u64);3079     }3080 3081     #[test]3082     fn lang_rows_skips_forge_metadata() {3083         // f0rge #37 follow-up: .forge/ (issues, PR diffs, runs) is not3084         // project code and stays out of the mix entirely.3085         use std::collections::{BTreeMap, HashSet};3086         let mut blobs: BTreeMap<String, Vec<u8>> = BTreeMap::new();3087         blobs.insert("src/main.rs".to_string(), b"fn main() {}\n".to_vec());3088         blobs.insert(3089             ".forge/prs/uuid/pr.md".to_string(),3090             b"---\nid: x\n---\nbody\n".to_vec(),3091         );3092         blobs.insert(3093             ".forge/prs/uuid/patch.diff".to_string(),3094             b"diff --git a\n+line\n".to_vec(),3095         );3096         let rows = lang_rows(&blobs, &HashSet::new());3097         let paths: Vec<&str> = rows.iter().map(|(p, _)| p.as_str()).collect();3098         assert_eq!(paths, vec!["src/main.rs"], "forge metadata excluded");3099     }3100 3101     #[test]3102     fn language_math_excludes_gitignored_and_binaries() {3103         // f0rge #37 rubric: fixture with a gitignored dir + a binary +3104         // code files; ignored/binary bytes are excluded and the3105         // legend percentages sum to counted text only.3106         let dir = mktemp("lang-ignore");3107         git(&dir, &["init", "-b", "main"]);3108         git(&dir, &["config", "user.name", "hu13-test"]);3109         git(&dir, &["config", "user.email", "[email protected]"]);3110         git(&dir, &["config", "commit.gpgsign", "false"]);3111         std::fs::write(dir.join(".gitignore"), "ignored/\n*.log\n").unwrap();3112         std::fs::create_dir_all(dir.join("src")).unwrap();3113         let rs = "fn a() {}\n".repeat(20);3114         let py = "print(1)\n".repeat(20);3115         std::fs::write(dir.join("src").join("a.rs"), &rs).unwrap();3116         std::fs::write(dir.join("src").join("b.py"), &py).unwrap();3117         std::fs::write(dir.join("notes.txt"), "plain notes\n").unwrap();3118         std::fs::create_dir_all(dir.join("assets")).unwrap();3119         let mut bin: Vec<u8> = (0u8..=255).collect();3120         bin.extend_from_slice(b"\x00\x01binary tail");3121         std::fs::write(dir.join("assets").join("blob.bin"), &bin).unwrap();3122         std::fs::create_dir_all(dir.join("ignored")).unwrap();3123         // Large ignored code file: would dominate the bar if counted.3124         std::fs::write(3125             dir.join("ignored").join("secret.rs"),3126             "fn s() {}\n".repeat(500),3127         )3128         .unwrap();3129         std::fs::write(dir.join("debug.log"), "log line\n".repeat(500)).unwrap();3130         git(&dir, &["add", "-A"]);3131         git(&dir, &["commit", "-m", "base"]);3132         // Force-add so the ignored paths are tracked (hu13 lists tracked3133         // files); the language filter must still drop them via --no-index.3134         git(&dir, &["add", "-f", "ignored/secret.rs", "debug.log"]);3135         git(&dir, &["commit", "-m", "forced ignored"]);3136 3137         // gitx layer: ignored subset is exactly the forced paths.3138         let tracked = vec![3139             "src/a.rs".to_string(),3140             "src/b.py".to_string(),3141             "notes.txt".to_string(),3142             "assets/blob.bin".to_string(),3143             "ignored/secret.rs".to_string(),3144             "debug.log".to_string(),3145         ];3146         let ignored = gitx::ignored_paths(&dir, &tracked).expect("ignored_paths");3147         assert!(ignored.contains("ignored/secret.rs"), "dir rule");3148         assert!(ignored.contains("debug.log"), "glob rule");3149         assert!(!ignored.contains("src/a.rs"), "code kept");3150         assert!(3151             !ignored.contains("assets/blob.bin"),3152             "binary kept here (binary rule drops it)"3153         );3154 3155         // Build layer: filtered rows are text only.3156         let out = mktemp("out-lang-ignore");3157         run_build(&dir, &out, &opts()).expect("build");3158         let index = std::fs::read_to_string(out.join("index.html")).expect("index");3159         let sec_start = index3160             .find("<section class=\"langs\"")3161             .expect("langs section");3162         let sec_end = index[sec_start..].find("</section>").expect("langs end") + sec_start;3163         let langs = &index[sec_start..sec_end];3164         // Ignored + binary paths never appear in the math section3165         // (they still have tree rows + raw downloads elsewhere).3166         for needle in ["secret.rs", "debug.log", "blob.bin"] {3167             assert!(3168                 !langs.contains(needle),3169                 "{needle} excluded from language math"3170             );3171         }3172         assert!(langs.contains("Rust"), "rust counted");3173         assert!(langs.contains("Python"), "python counted");3174         // Percentages in the legend sum to ~100 (text-only total).3175         // Parse only the legend list (the bar spans carry duplicate3176         // `width:x%` values that must not be double-counted).3177         let ul_start = langs3178             .find("<ul class=\"lang-legend\">")3179             .expect("legend list");3180         let legend = &langs[ul_start..];3181         let mut pct_sum = 0.0_f64;3182         let mut count = 0_usize;3183         let mut rest = legend;3184         while let Some(i) = rest.find('%') {3185             let start = rest[..i].rfind(['>', ' ']).map(|p| p + 1).unwrap_or(0);3186             let num: f64 = rest[start..i].trim().parse().unwrap_or(f64::NAN);3187             assert!(3188                 num.is_finite(),3189                 "legend % parses, got {:?}",3190                 &rest[start..i]3191             );3192             pct_sum += num;3193             count += 1;3194             rest = &rest[i + 1..];3195         }3196         assert!(count >= 2, "legend has shares, got {langs:?}");3197         assert!(3198             (pct_sum - 100.0).abs() < 0.2,3199             "shares sum to 100, got {pct_sum} in {langs:?}"3200         );3201         // Binary is still downloadable with a placeholder page.3202         let bin_page =3203             std::fs::read_to_string(out.join("files/assets/blob.bin.html")).expect("bin page");3204         assert!(bin_page.contains("placeholder"), "binary placeholder kept");3205         assert!(3206             out.join("raw/assets/blob.bin").exists(),3207             "binary downloadable"3208         );3209         assert!(3210             out.join("raw/ignored/secret.rs").exists(),3211             "ignored still in raw"3212         );3213     }3214 3215     #[test]3216     fn finish_page_heads_and_clone_summary() {3217         // m6 finish (f0rge #50 + #53): one head pattern everywhere, branch+CI3218         // only in the clone summary, no dupe line.3219         let (_repo, out) = build_fixture();3220         let read = |rel: &str| std::fs::read_to_string(out.join(rel)).expect(rel);3221         let index = read("index.html");3222         // Code tab: repo-head with h1, summary carries branch @ sha + CI.3223         let head_pos = index.find("<div class=\"repo-head\">").expect("repo-head");3224         assert!(index[head_pos..].contains("<h1>"), "code h1 in repo-head");3225         let s0 = index3226             .find("<summary><span class=\"cb-seg\">Clone")3227             .expect("summary");3228         let s1 = index[s0..].find("</summary>").expect("summary end") + s0;3229         let summary = &index[s0..s1];3230         assert!(summary.contains("Clone &amp; download"), "summary label");3231         assert!(summary.contains("main"), "branch in summary");3232         assert!(summary.contains("CI"), "CI in summary");3233         assert!(summary.contains("badge"), "CI badge in summary");3234         assert!(!index.contains("repo-meta"), "no dupe repo-meta");3235         assert!(!index.contains("<p>CI:"), "no dupe CI line");3236         // Branch appears in summary; outside details it is not repeated as3237         // a meta line (description/counts carry no branch text here).3238         let details_pos = index.find("<details class=\"clonebox").expect("details");3239         assert!(3240             s0 > head_pos && s0 >= details_pos,3241             "summary inside code head"3242         );3243         // Every index/Form page opens with the same page-head h1 row.3244         for (rel, h1) in [3245             ("issues/index.html", "<h1>Issues</h1>"),3246             ("pulls/index.html", "<h1>Pulls</h1>"),3247             ("commits.html", "<h1>Commits</h1>"),3248             ("tasks/index.html", "<h1>Tasks</h1>"),3249             ("ci/index.html", "<h1>CI</h1>"),3250             ("releases/index.html", "<h1>Releases</h1>"),3251             ("wiki/index.html", "<h1>Wiki</h1>"),3252             ("new-issue.html", "<h1>Suggest an issue</h1>"),3253             ("new-pr.html", "<h1>Suggest a change</h1>"),3254         ] {3255             let html = read(rel);3256             let hpos = html.find("<div class=\"page-head\">").expect(rel);3257             let h1pos = html.find(h1).unwrap_or_else(|| panic!("{rel} missing h1"));3258             let close = html[hpos..].find("</div>").expect("close") + hpos;3259             assert!(h1pos > hpos && h1pos < close, "{rel}: h1 inside page-head");3260         }3261         // Agents guide renders its markdown h1; spacing comes from the3262         // shared `main>h1:first-child` gap-var rule (CSS assert in layout).3263         let agents = read("agents.html");3264         assert!(agents.contains("<h1"), "agents h1");3265     }3266 }