src/forge.rs 84.8 KiB 2385 lines Raw
1 //! f0rge readers (m3, f0rge issue #7): issues / pulls / tasks / ci pages.2 //!3 //! Source is the on-disk `<repo>/.forge/` tree (NOT git objects):4 //! `issues/<uuid>/issue.md` (+ `comments/<uuid>.md`),5 //! `prs/<uuid>/pr.md` (+ `patch.diff`, `comments/`, `reviews/`),6 //! `tasks/<uuid>.md`, `labels.md` + `milestones.md` (YAML lists, no fences),7 //! `ci/ci.env` + `steps/*.sh` + `runs/<ts>/summary.md`.8 //!9 //! Rules (spec/schema.md §0, DRAFT): frontmatter is a tiny `---` split +10 //! `yaml_serde`; unknown fields are ignored; a missing required field11 //! skips that file with an `eprintln!` warning naming file + field and the12 //! build still exits 0. Numbers are derived (never stored): 1-based rank13 //! of `(created, id)` at build time. Pages display the number and link by14 //! uuid.15 //!16 //! SECURITY: every byte from `.forge/` is untrusted. Markdown bodies go17 //! through [`crate::build::markdown_to_html`] (raw HTML escaped); titles,18 //! ids, states, diffs, and logs go through [`crate::layout::esc`] (diffs19 //! and logs as escaped `<pre>`, never markdown).20 21 use std::path::Path;22 23 use serde::Deserialize;24 25 use crate::build::markdown_to_html_in;26 use crate::layout::{diff_html, esc, page_site_with_provenance};27 28 /// `.forge/` files larger than this are skipped (schema SEC-01: 256 KiB).29 pub const FORGE_MAX_BYTES: u64 = 256 * 1024;30 31 /// Step-log bytes kept per log file (rest truncated with a marker).32 pub const LOG_PREVIEW_MAX: usize = 64 * 1024;33 34 // ---------------------------------------------------------------------------35 // Frontmatter plumbing36 // ---------------------------------------------------------------------------37 38 /// Split `---\nyaml\n---\nbody` into `(yaml, body)`.39 ///40 /// The opening fence must be the first line; the closing fence must be a41 /// full line `---`. Returns `None` when there is no frontmatter.42 fn split_frontmatter(text: &str) -> Option<(&str, &str)> {43     let rest = text.strip_prefix("---")?;44     let rest = rest45         .strip_prefix('\n')46         .or_else(|| rest.strip_prefix("\r\n"))?;47     let mut offset = 0;48     for line in rest.split_inclusive('\n') {49         let trimmed = line.trim_end_matches(['\n', '\r']);50         if trimmed == "---" {51             let yaml = &rest[..offset];52             let body = &rest[offset + line.len()..];53             return Some((yaml, body));54         }55         offset += line.len();56     }57     None58 }59 60 /// Read a UTF-8 file, skipping (with a warning) files that are missing,61 /// oversized, or non-UTF-8. `label` names the file for warnings.62 fn read_text_capped(path: &Path, label: &str) -> Option<String> {63     // Never follow symlinks: wiki and release artifacts already skip64     // them, and `.forge/` is repo-controlled (f0rge #68).65     match std::fs::symlink_metadata(path) {66         Ok(m) if m.file_type().is_symlink() => {67             eprintln!("hu13: forge: skipping {label}: symlink");68             return None;69         }70         _ => {}71     }72     let len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);73     if len > FORGE_MAX_BYTES {74         eprintln!("hu13: forge: skipping {label}: over 256 KiB ({len} bytes)");75         return None;76     }77     match std::fs::read(path) {78         Ok(bytes) => match String::from_utf8(bytes) {79             Ok(text) => Some(text),80             Err(_) => {81                 eprintln!("hu13: forge: skipping {label}: not valid UTF-8");82                 None83             }84         },85         Err(e) => {86             eprintln!("hu13: forge: skipping {label}: cannot read: {e}");87             None88         }89     }90 }91 92 /// Like [`read_text_capped`], but keeps arbitrary bytes (CI logs may be93 /// lossy UTF-8). Symlinks and oversized files are skipped, so `.forge`94 /// cannot make the build read an unbounded or outside file (f0rge #107).95 fn read_bytes_capped(path: &Path, label: &str) -> Option<Vec<u8>> {96     match std::fs::symlink_metadata(path) {97         Ok(m) if m.file_type().is_symlink() => {98             eprintln!("hu13: forge: skipping {label}: symlink");99             return None;100         }101         _ => {}102     }103     let len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);104     if len > FORGE_MAX_BYTES {105         eprintln!("hu13: forge: skipping {label}: over 256 KiB ({len} bytes)");106         return None;107     }108     match std::fs::read(path) {109         Ok(bytes) if bytes.len() as u64 <= FORGE_MAX_BYTES => Some(bytes),110         Ok(_) => {111             eprintln!("hu13: forge: skipping {label}: over 256 KiB");112             None113         }114         Err(e) => {115             eprintln!("hu13: forge: skipping {label}: cannot read: {e}");116             None117         }118     }119 }120 121 /// Parse YAML frontmatter; on error warn (naming file via `label` plus the122 /// serde error, which names the field) and return `None` (skip the file).123 fn parse_frontmatter<T>(yaml: &str, label: &str) -> Option<T>124 where125     T: for<'de> Deserialize<'de>,126 {127     match yaml_serde::from_str(yaml) {128         Ok(value) => Some(value),129         Err(e) => {130             eprintln!("hu13: forge: skipping {label}: {e}");131             None132         }133     }134 }135 136 /// Sorted entry names (files or dirs) directly under `dir`.137 /// Missing dir yields an empty vec (no `.forge/` section yet — not a warning).138 fn sorted_entries(dir: &Path, only_dirs: bool) -> Vec<String> {139     let rd = match std::fs::read_dir(dir) {140         Ok(rd) => rd,141         Err(_) => return Vec::new(),142     };143     let mut names: Vec<String> = Vec::new();144     for ent in rd {145         let ent = match ent {146             Ok(ent) => ent,147             Err(e) => {148                 eprintln!("hu13: forge: bad dir entry in {}: {e}", dir.display());149                 continue;150             }151         };152         let meta = match std::fs::symlink_metadata(ent.path()) {153             Ok(m) => m,154             Err(_) => continue,155         };156         // Never descend or read through a symlink: `.forge` entries are157         // repo-controlled and must not escape the checkout (f0rge #107).158         if meta.file_type().is_symlink() || meta.is_dir() != only_dirs {159             continue;160         }161         names.push(ent.file_name().to_string_lossy().into_owned());162     }163     names.sort();164     names165 }166 167 // ---------------------------------------------------------------------------168 // Typed frontmatter structs (unknown fields ignored by serde default;169 // `#[serde(default)]` marks every optional from schema §2)170 // ---------------------------------------------------------------------------171 172 #[derive(Debug, Clone, Deserialize)]173 struct IssueFm {174     id: String,175     title: String,176     state: String,177     #[serde(default)]178     labels: Vec<String>,179     #[serde(default)]180     assignees: Vec<String>,181     #[serde(default)]182     milestone: Option<String>,183     created: String,184     #[serde(default)]185     updated: Option<String>,186 }187 188 #[derive(Debug, Clone, Deserialize)]189 struct CommentFm {190     id: String,191     created: String,192     #[serde(default)]193     author: Option<String>,194 }195 196 #[derive(Debug, Clone, Deserialize)]197 struct PrFm {198     id: String,199     title: String,200     state: String,201     base: String,202     head: String,203     created: String,204     #[serde(default)]205     draft: bool,206     #[serde(default)]207     issues: Vec<String>,208     #[serde(default)]209     ci: Option<String>,210     #[serde(default)]211     updated: Option<String>,212 }213 214 #[derive(Debug, Clone, Deserialize)]215 struct ReviewFm {216     id: String,217     created: String,218     #[serde(default)]219     author: Option<String>,220     #[serde(default)]221     state: Option<String>,222 }223 224 #[derive(Debug, Clone, Deserialize)]225 struct TaskFm {226     id: String,227     title: String,228     status: String,229     created: String,230     #[serde(default)]231     assignee: Option<String>,232     #[serde(default)]233     updated: Option<String>,234     #[serde(default)]235     closed: Option<String>,236 }237 238 #[derive(Debug, Clone, Deserialize)]239 struct LabelFm {240     name: String,241     #[serde(default)]242     color: Option<String>,243     #[serde(default)]244     description: Option<String>,245 }246 247 fn default_open() -> String {248     "open".to_string()249 }250 251 #[derive(Debug, Clone, Deserialize)]252 struct MilestoneFm {253     name: String,254     #[serde(default = "default_open")]255     state: String,256     #[serde(default)]257     due: Option<String>,258     #[serde(default)]259     description: Option<String>,260 }261 262 fn default_runner() -> String {263     "local".to_string()264 }265 266 #[derive(Debug, Clone, Deserialize)]267 struct CiEnvFm {268     #[serde(default = "default_runner")]269     runner: String,270 }271 272 #[derive(Debug, Clone, Deserialize)]273 struct CiRunFm {274     run: String,275     verdict: String,276     created: String,277     #[serde(default)]278     steps: Vec<CiStepFm>,279 }280 281 #[derive(Debug, Clone, Deserialize)]282 struct CiStepFm {283     #[serde(default)]284     name: String,285     #[serde(default)]286     exit: i32,287     #[serde(default)]288     duration_ms: u64,289 }290 291 // ---------------------------------------------------------------------------292 // Loaded entities (frontmatter + body + sidecars, numbers assigned at load)293 // ---------------------------------------------------------------------------294 295 /// One issue/PR comment.296 #[derive(Debug, Clone, PartialEq, Eq)]297 pub struct Comment {298     /// Comment uuid (filename stem).299     pub id: String,300     /// Optional author handle.301     pub author: Option<String>,302     /// RFC3339 creation timestamp.303     pub created: String,304     /// Markdown body after the frontmatter.305     pub body: String,306 }307 308 /// One issue with derived display number and loaded comments.309 #[derive(Debug, Clone)]310 pub struct Issue {311     /// 1-based rank of `(created, id)` — display only, never stored.312     pub number: usize,313     /// Issue uuid (link key).314     pub id: String,315     pub title: String,316     pub state: String,317     pub labels: Vec<String>,318     pub assignees: Vec<String>,319     pub milestone: Option<String>,320     pub created: String,321     pub updated: Option<String>,322     /// Markdown body after the frontmatter.323     pub body: String,324     /// Comments sorted by `(created, id)`.325     pub comments: Vec<Comment>,326 }327 328 /// One PR review (state + body).329 #[derive(Debug, Clone, PartialEq, Eq)]330 pub struct Review {331     /// Review uuid.332     pub id: String,333     /// Optional author handle.334     pub author: Option<String>,335     /// Optional `approved|changes-requested|commented`.336     pub state: Option<String>,337     /// RFC3339 creation timestamp.338     pub created: String,339     /// Markdown body after the frontmatter.340     pub body: String,341 }342 343 /// One PR with derived display number, patch text, comments, and reviews.344 #[derive(Debug, Clone)]345 pub struct Pr {346     /// 1-based rank of `(created, id)` — display only, never stored.347     pub number: usize,348     /// PR uuid (link key).349     pub id: String,350     pub title: String,351     pub state: String,352     pub base: String,353     pub head: String,354     pub draft: bool,355     pub issues: Vec<String>,356     pub ci: Option<String>,357     pub created: String,358     pub updated: Option<String>,359     /// Markdown body after the frontmatter.360     pub body: String,361     /// Raw `patch.diff` text when present.362     pub patch: Option<String>,363     /// Comments sorted by `(created, id)`.364     pub comments: Vec<Comment>,365     /// Reviews sorted by `(created, id)`.366     pub reviews: Vec<Review>,367 }368 369 /// One task file (grouped by `status` at render).370 #[derive(Debug, Clone)]371 pub struct TaskItem {372     /// Task uuid (filename stem).373     pub id: String,374     pub title: String,375     /// `todo|doing|blocked|done`.376     pub status: String,377     pub created: String,378     pub assignee: Option<String>,379     pub updated: Option<String>,380     pub closed: Option<String>,381     /// Markdown body after the frontmatter.382     pub body: String,383 }384 385 /// One label entry from `labels.md`.386 #[derive(Debug, Clone, PartialEq, Eq)]387 pub struct Label {388     pub name: String,389     pub color: Option<String>,390     pub description: Option<String>,391 }392 393 /// One milestone entry from `milestones.md`.394 #[derive(Debug, Clone, PartialEq, Eq)]395 pub struct Milestone {396     pub name: String,397     pub state: String,398     pub due: Option<String>,399     pub description: Option<String>,400 }401 402 /// One step result from a CI run summary.403 #[derive(Debug, Clone, PartialEq, Eq)]404 pub struct CiStepStatus {405     pub name: String,406     pub exit: i32,407     pub duration_ms: u64,408 }409 410 /// One CI run (latest only is rendered).411 #[derive(Debug, Clone)]412 pub struct CiRun {413     /// Run timestamp id (dir name).414     pub run: String,415     /// `pass|fail|running|cancelled`.416     pub verdict: String,417     pub created: String,418     pub steps: Vec<CiStepStatus>,419     /// Summary body after the frontmatter (the run log).420     pub body: String,421     /// `(log filename, content)` for `runs/<ts>/steps/*.log`, sorted.422     pub logs: Vec<(String, String)>,423 }424 425 /// CI section: env runner, step script names, latest run.426 #[derive(Debug, Clone)]427 pub struct CiInfo {428     /// `runner:` from `ci.env` (default `local`).429     pub runner: String,430     /// Sorted `steps/*.sh` filenames.431     pub steps: Vec<String>,432     /// Latest `runs/<ts>/summary.md` by lexicographic (== chronological) order.433     pub latest: Option<CiRun>,434 }435 436 impl CiInfo {437     /// Latest verdict, or `"none"` when no runs exist at build time.438     #[must_use]439     pub fn latest_verdict(&self) -> &str {440         self.latest441             .as_ref()442             .map(|r| r.verdict.as_str())443             .unwrap_or("none")444     }445 }446 447 /// Everything the f0rge readers load (numbers already assigned).448 #[derive(Debug, Clone)]449 pub struct ForgeData {450     /// Issues sorted by `(created, id)` with 1-based numbers.451     /// Display via [`issues_display_order`] (open-first, newest-first).452     pub issues: Vec<Issue>,453     /// PRs sorted by `(created, id)` with 1-based numbers.454     /// Display via [`prs_display_order`] (open-first, newest-first).455     pub prs: Vec<Pr>,456     /// Tasks sorted by `(created, id)`.457     pub tasks: Vec<TaskItem>,458     /// Labels in file order.459     pub labels: Vec<Label>,460     /// Milestones in file order.461     pub milestones: Vec<Milestone>,462     /// CI env + steps + latest run.463     pub ci: CiInfo,464 }465 466 impl ForgeData {467     /// Open issues: `state == "open"` exactly (m6 open-counts, f0rge #29).468     /// `in-progress` (and `closed`/`archived`) counts as NOT open.469     #[must_use]470     pub fn open_issue_count(&self) -> usize {471         self.issues.iter().filter(|i| i.state == "open").count()472     }473 474     /// Non-open issues (`total - open`): shown as the `N closed` line on475     /// the issues index so closed totals stay visible (f0rge #29).476     #[must_use]477     pub fn closed_issue_count(&self) -> usize {478         self.issues.len() - self.open_issue_count()479     }480 481     /// Open PRs: `state == "open"` exactly (m6 open-counts, f0rge #29).482     /// Drafts (`draft: true`) are counted as open and stay marked483     /// (` draft` suffix) in the pulls table.484     #[must_use]485     pub fn open_pr_count(&self) -> usize {486         self.prs.iter().filter(|p| p.state == "open").count()487     }488 489     /// Non-open PRs (`closed`/`merged`; `total - open`): shown as the490     /// `N closed` line on the pulls index (f0rge #29).491     #[must_use]492     pub fn closed_pr_count(&self) -> usize {493         self.prs.len() - self.open_pr_count()494     }495 496     /// Issues in index display order (m6/sort, f0rge #48 + #49).497     #[must_use]498     pub fn issues_in_display_order(&self) -> Vec<&Issue> {499         issues_display_order(&self.issues)500     }501 502     /// PRs in index display order (m6/sort, f0rge #48 + #49).503     #[must_use]504     pub fn prs_in_display_order(&self) -> Vec<&Pr> {505         prs_display_order(&self.prs)506     }507 }508 509 /// `true` when an issue/PR counts as open in the index (`state == "open"`510 /// exactly; drafts stay open, `closed`/`merged` and friends stay closed).511 fn is_open_state(state: &str) -> bool {512     state == "open"513 }514 515 /// Issues index display order (m6/sort, f0rge #48 + #49): open first516 /// (reverse-chron within), then non-open (reverse-chron within).517 /// Within each group: `created` desc, `id` desc tiebreak.518 /// Numbers stay creation-rank; input stays `(created, id)` asc.519 #[must_use]520 pub fn issues_display_order(issues: &[Issue]) -> Vec<&Issue> {521     let mut out: Vec<&Issue> = issues.iter().collect();522     out.sort_by(|a, b| {523         let ao = if is_open_state(&a.state) { 0 } else { 1 };524         let bo = if is_open_state(&b.state) { 0 } else { 1 };525         ao.cmp(&bo)526             .then_with(|| b.created.cmp(&a.created))527             .then_with(|| b.id.cmp(&a.id))528     });529     out530 }531 532 /// Pulls index display order (m6/sort, f0rge #48 + #49): open (incl533 /// drafts) first (reverse-chron within), then `closed`/`merged`534 /// (reverse-chron within). Within each group: `created` desc, `id`535 /// desc tiebreak. Numbers stay creation-rank.536 #[must_use]537 pub fn prs_display_order(prs: &[Pr]) -> Vec<&Pr> {538     let mut out: Vec<&Pr> = prs.iter().collect();539     out.sort_by(|a, b| {540         let ao = if is_open_state(&a.state) { 0 } else { 1 };541         let bo = if is_open_state(&b.state) { 0 } else { 1 };542         ao.cmp(&bo)543             .then_with(|| b.created.cmp(&a.created))544             .then_with(|| b.id.cmp(&a.id))545     });546     out547 }548 549 // ---------------------------------------------------------------------------550 // Loading551 // ---------------------------------------------------------------------------552 553 fn load_comments(dir: &Path, scope: &str) -> Vec<Comment> {554     let mut out = Vec::new();555     for name in sorted_entries(dir, false) {556         if !name.ends_with(".md") {557             continue;558         }559         let label = format!("{scope}/{name}");560         let Some(text) = read_text_capped(&dir.join(&name), &label) else {561             continue;562         };563         let Some((yaml, body)) = split_frontmatter(&text) else {564             eprintln!("hu13: forge: skipping {label}: missing frontmatter");565             continue;566         };567         let Some(fm): Option<CommentFm> = parse_frontmatter(yaml, &label) else {568             continue;569         };570         out.push(Comment {571             id: fm.id,572             author: fm.author,573             created: fm.created,574             body: body.to_string(),575         });576     }577     out.sort_by_key(|c| (c.created.clone(), c.id.clone()));578     out579 }580 581 fn load_reviews(dir: &Path, scope: &str) -> Vec<Review> {582     let mut out = Vec::new();583     for name in sorted_entries(dir, false) {584         if !name.ends_with(".md") {585             continue;586         }587         let label = format!("{scope}/{name}");588         let Some(text) = read_text_capped(&dir.join(&name), &label) else {589             continue;590         };591         let Some((yaml, body)) = split_frontmatter(&text) else {592             eprintln!("hu13: forge: skipping {label}: missing frontmatter");593             continue;594         };595         let Some(fm): Option<ReviewFm> = parse_frontmatter(yaml, &label) else {596             continue;597         };598         out.push(Review {599             id: fm.id,600             author: fm.author,601             state: fm.state,602             created: fm.created,603             body: body.to_string(),604         });605     }606     out.sort_by_key(|c| (c.created.clone(), c.id.clone()));607     out608 }609 610 fn load_issues(root: &Path) -> Vec<Issue> {611     let dir = root.join("issues");612     let mut issues = Vec::new();613     for uuid in sorted_entries(&dir, true) {614         let label = format!("issues/{uuid}/issue.md");615         let Some(text) = read_text_capped(&dir.join(&uuid).join("issue.md"), &label) else {616             continue;617         };618         let Some((yaml, body)) = split_frontmatter(&text) else {619             eprintln!("hu13: forge: skipping {label}: missing frontmatter");620             continue;621         };622         let Some(fm): Option<IssueFm> = parse_frontmatter(yaml, &label) else {623             continue;624         };625         if !crate::paths::is_safe_id(&fm.id) {626             eprintln!("hu13: forge: skipping {label}: unsafe id {:?}", fm.id);627             continue;628         }629         if crate::paths::is_reserved_index(&fm.id) {630             eprintln!("hu13: forge: skipping {label}: reserved id index");631             continue;632         }633         let comments = load_comments(634             &dir.join(&uuid).join("comments"),635             &format!("issues/{uuid}/comments"),636         );637         issues.push(Issue {638             number: 0,639             id: fm.id,640             title: fm.title,641             state: fm.state,642             labels: fm.labels,643             assignees: fm.assignees,644             milestone: fm.milestone,645             created: fm.created,646             updated: fm.updated,647             body: body.to_string(),648             comments,649         });650     }651     // Numbers: 1-based rank of (created, id).652     issues.sort_by_key(|i| (i.created.clone(), i.id.clone()));653     for (i, issue) in issues.iter_mut().enumerate() {654         issue.number = i + 1;655     }656     issues657 }658 659 fn load_prs(root: &Path) -> Vec<Pr> {660     let dir = root.join("prs");661     let mut prs = Vec::new();662     for uuid in sorted_entries(&dir, true) {663         let label = format!("prs/{uuid}/pr.md");664         let Some(text) = read_text_capped(&dir.join(&uuid).join("pr.md"), &label) else {665             continue;666         };667         let Some((yaml, body)) = split_frontmatter(&text) else {668             eprintln!("hu13: forge: skipping {label}: missing frontmatter");669             continue;670         };671         let Some(fm): Option<PrFm> = parse_frontmatter(yaml, &label) else {672             continue;673         };674         if !crate::paths::is_safe_id(&fm.id) {675             eprintln!("hu13: forge: skipping {label}: unsafe id {:?}", fm.id);676             continue;677         }678         if crate::paths::is_reserved_index(&fm.id) {679             eprintln!("hu13: forge: skipping {label}: reserved id index");680             continue;681         }682         // Symlink-safe and bounded: a hostile patch.diff used to be read683         // whole, even through a symlink (f0rge #107).684         let patch = read_text_capped(&dir.join(&uuid).join("patch.diff"), "patch.diff");685         let comments = load_comments(686             &dir.join(&uuid).join("comments"),687             &format!("prs/{uuid}/comments"),688         );689         let reviews = load_reviews(690             &dir.join(&uuid).join("reviews"),691             &format!("prs/{uuid}/reviews"),692         );693         prs.push(Pr {694             number: 0,695             id: fm.id,696             title: fm.title,697             state: fm.state,698             base: fm.base,699             head: fm.head,700             draft: fm.draft,701             issues: fm.issues,702             ci: fm.ci,703             created: fm.created,704             updated: fm.updated,705             body: body.to_string(),706             patch,707             comments,708             reviews,709         });710     }711     // Numbers: 1-based rank of (created, id).712     prs.sort_by_key(|p| (p.created.clone(), p.id.clone()));713     for (i, pr) in prs.iter_mut().enumerate() {714         pr.number = i + 1;715     }716     prs717 }718 719 fn load_tasks(root: &Path) -> Vec<TaskItem> {720     let dir = root.join("tasks");721     let mut tasks = Vec::new();722     for name in sorted_entries(&dir, false) {723         if !name.ends_with(".md") {724             continue;725         }726         let label = format!("tasks/{name}");727         let Some(text) = read_text_capped(&dir.join(&name), &label) else {728             continue;729         };730         let Some((yaml, body)) = split_frontmatter(&text) else {731             eprintln!("hu13: forge: skipping {label}: missing frontmatter");732             continue;733         };734         let Some(fm): Option<TaskFm> = parse_frontmatter(yaml, &label) else {735             continue;736         };737         if !matches!(fm.status.as_str(), "todo" | "doing" | "blocked" | "done") {738             eprintln!(739                 "hu13: forge: skipping {label}: unknown status '{}'",740                 fm.status741             );742             continue;743         }744         tasks.push(TaskItem {745             id: fm.id,746             title: fm.title,747             status: fm.status,748             created: fm.created,749             assignee: fm.assignee,750             updated: fm.updated,751             closed: fm.closed,752             body: body.to_string(),753         });754     }755     tasks.sort_by_key(|t| (t.created.clone(), t.id.clone()));756     tasks757 }758 759 fn load_labels(root: &Path) -> Vec<Label> {760     let path = root.join("labels.md");761     if !path.exists() {762         return Vec::new();763     }764     let Some(text) = read_text_capped(&path, "labels.md") else {765         return Vec::new();766     };767     // YAML list, no fences: parse the whole file.768     match yaml_serde::from_str::<Vec<LabelFm>>(&text) {769         Ok(items) => items770             .into_iter()771             .map(|l| Label {772                 name: l.name,773                 color: l.color,774                 description: l.description,775             })776             .collect(),777         Err(e) => {778             eprintln!("hu13: forge: skipping labels.md: {e}");779             Vec::new()780         }781     }782 }783 784 fn load_milestones(root: &Path) -> Vec<Milestone> {785     let path = root.join("milestones.md");786     if !path.exists() {787         return Vec::new();788     }789     let Some(text) = read_text_capped(&path, "milestones.md") else {790         return Vec::new();791     };792     // YAML list, no fences: parse the whole file.793     match yaml_serde::from_str::<Vec<MilestoneFm>>(&text) {794         Ok(items) => items795             .into_iter()796             .map(|m| Milestone {797                 name: m.name,798                 state: m.state,799                 due: m.due,800                 description: m.description,801             })802             .collect(),803         Err(e) => {804             eprintln!("hu13: forge: skipping milestones.md: {e}");805             Vec::new()806         }807     }808 }809 810 /// Remove builder-local absolute paths from published CI logs (f0rge811 /// #94): cargo writes `Checking hu13 (/home/alice/.../hu13)`. Replace812 /// the canonical repo root with `<repo>`, then `$HOME` with `~`.813 fn redact_paths(text: &str, root: &Path) -> String {814     let mut out = text.to_string();815     // Only replace a canonical absolute path. `root` may be `.forge` under a816     // relative repo argument (`.`); using the raw relative form as a needle817     // would replace every dot in `v0.1.0`/`00.log` (f0rge #103).818     if let Ok(root_abs) = root.canonicalize() {819         let needle = root_abs.to_string_lossy();820         if !needle.is_empty() && needle != "/" && needle != "." && needle != ".." {821             out = out.replace(needle.as_ref(), "<repo>");822         }823     }824     if let Ok(home) = std::env::var("HOME") {825         if !home.is_empty() && home != "/" {826             out = out.replace(&home, "~");827         }828     }829     out830 }831 832 fn truncate_log(text: &str) -> String {833     if text.len() <= LOG_PREVIEW_MAX {834         return text.to_string();835     }836     let mut end = LOG_PREVIEW_MAX;837     while !text.is_char_boundary(end) {838         end -= 1;839     }840     format!("{}…[truncated]", &text[..end])841 }842 843 fn load_ci(root: &Path) -> CiInfo {844     let dir = root.join("ci");845     // `root` is `<repo>/.forge`; logs name the repo checkout itself.846     let repo_root = root.parent().unwrap_or(root);847     let runner = if dir.join("ci.env").exists() {848         match read_text_capped(&dir.join("ci.env"), "ci/ci.env") {849             Some(text) => match yaml_serde::from_str::<CiEnvFm>(&text) {850                 Ok(env) => env.runner,851                 Err(e) => {852                     eprintln!("hu13: forge: skipping ci/ci.env: {e}");853                     default_runner()854                 }855             },856             None => default_runner(),857         }858     } else {859         default_runner()860     };861     let steps: Vec<String> = sorted_entries(&dir.join("steps"), false)862         .into_iter()863         .filter(|n| n.ends_with(".sh"))864         .collect();865 866     let mut runs = sorted_entries(&dir.join("runs"), true);867     runs.sort();868     let latest = runs.last().cloned().and_then(|ts| {869         let label = format!("ci/runs/{ts}/summary.md");870         let text = read_text_capped(&dir.join("runs").join(&ts).join("summary.md"), &label)?;871         let (yaml, body) = match split_frontmatter(&text) {872             Some(pair) => pair,873             None => {874                 eprintln!("hu13: forge: skipping {label}: missing frontmatter");875                 return None;876             }877         };878         let fm: CiRunFm = parse_frontmatter(yaml, &label)?;879         let mut logs = Vec::new();880         for name in sorted_entries(&dir.join("runs").join(&ts).join("steps"), false) {881             if !name.ends_with(".log") {882                 continue;883             }884             let label = format!("ci/runs/{ts}/steps/{name}");885             let bytes = read_bytes_capped(886                 &dir.join("runs").join(&ts).join("steps").join(&name),887                 &label,888             )889             .unwrap_or_default();890             let text = String::from_utf8_lossy(&bytes);891             logs.push((name, truncate_log(&redact_paths(&text, repo_root))));892         }893         Some(CiRun {894             run: fm.run,895             verdict: fm.verdict,896             created: fm.created,897             steps: fm898                 .steps899                 .into_iter()900                 .map(|s| CiStepStatus {901                     name: s.name,902                     exit: s.exit,903                     duration_ms: s.duration_ms,904                 })905                 .collect(),906             body: redact_paths(body, repo_root),907             logs,908         })909     });910 911     CiInfo {912         runner,913         steps,914         latest,915     }916 }917 918 /// Load every f0rge section from on-disk `<repo>/.forge/`.919 /// Never fails: bad files are skipped with warnings; a missing920 /// `.forge/` yields empty sections.921 #[must_use]922 pub fn load(repo: &Path) -> ForgeData {923     // Resolve `.forge` and require it to stay inside the repo. A symlinked924     // `.forge` used to publish host issues/wiki/releases (f0rge #107).925     let root = crate::paths::repo_subdir(repo, ".forge")926         .unwrap_or_else(|| repo.join(".hu13-missing-forge"));927     ForgeData {928         issues: load_issues(&root),929         prs: load_prs(&root),930         tasks: load_tasks(&root),931         labels: load_labels(&root),932         milestones: load_milestones(&root),933         ci: load_ci(&root),934     }935 }936 937 // ---------------------------------------------------------------------------938 // Rendering939 // ---------------------------------------------------------------------------940 941 /// Verdict badge: `<span class="badge {verdict}">verdict</span>` for942 /// `pass|fail|running|cancelled`, plain `badge` otherwise (`none`, unknown).943 #[must_use]944 pub fn verdict_badge(verdict: &str) -> String {945     let v = verdict.trim().to_lowercase();946     if matches!(v.as_str(), "pass" | "fail" | "running" | "cancelled") {947         format!("<span class=\"badge {v}\">{}</span>", esc(&v))948     } else {949         format!("<span class=\"badge\">{}</span>", esc(verdict.trim()))950     }951 }952 953 /// CSS class for a state pill (open/closed/merged/other).954 fn state_class(state: &str) -> &'static str {955     match state.trim().to_ascii_lowercase().as_str() {956         "open" => "open",957         "closed" => "closed",958         "merged" => "merged",959         _ => "other",960     }961 }962 963 /// Colored state pill (f0rge #72).964 fn state_pill(state: &str) -> String {965     format!(966         "<span class=\"pill state-{}\">{}</span>",967         state_class(state),968         esc(state.trim())969     )970 }971 972 /// 1-letter author monogram with a deterministic hue class.973 fn monogram(handle: &str) -> String {974     let h = handle.trim();975     let initial: String = h976         .chars()977         .next()978         .map(|c| c.to_uppercase().to_string())979         .unwrap_or_default();980     let mut hash: u32 = 0;981     for b in h.bytes() {982         hash = hash.wrapping_mul(31).wrapping_add(u32::from(b));983     }984     format!(985         "<span class=\"mono m{}\" aria-hidden=\"true\">{}</span>",986         hash % 6,987         esc(&initial)988     )989 }990 991 /// `<time>` element with a relative label and the exact UTC value in992 /// `title`/`datetime` (never a raw nanosecond ISO string in visible text).993 fn time_ago(s: &str, now: i64) -> String {994     match crate::build::parse_ts_secs(s) {995         Some(ts) => format!(996             "<time datetime=\"{}\" title=\"{}\">{}</time>",997             esc(s),998             esc(&crate::build::ts_title(s)),999             esc(&crate::build::relative_age(now, ts))1000         ),1001         None => format!("<time>{}</time>", esc(s)),1002     }1003 }1004 1005 /// Human duration for CI steps: `7 ms` under a second, else `1.2 s`.1006 fn fmt_duration(ms: u64) -> String {1007     if ms < 1000 {1008         format!("{ms} ms")1009     } else {1010         format!("{:.1} s", ms as f64 / 1000.0)1011     }1012 }1013 1014 fn render_comment(c: &Comment, now: i64) -> String {1015     let mut out = String::from("<li class=\"comment\">\n");1016     let author = c.author.as_deref().unwrap_or("unknown");1017     out.push_str(&format!(1018         "<p class=\"comment-head\">{}<span class=\"who\">{}</span> · {}</p>\n",1019         monogram(author),1020         esc(author),1021         time_ago(&c.created, now),1022     ));1023     out.push_str(&markdown_to_html_in(&c.body, "", false));1024     out.push_str("</li>\n");1025     out1026 }1027 1028 fn render_issue_page(1029     issue: &Issue,1030     site_name: &str,1031     nav: &str,1032     branch: &str,1033     short_sha: &str,1034     description: Option<&str>,1035 ) -> String {1036     let mut body = format!(1037         "<h1>#{n} {title}</h1>\n",1038         n = issue.number,1039         title = esc(&issue.title),1040     );1041     let now = crate::build::now_unix();1042     body.push_str("<div class=\"meta\">");1043     body.push_str(&state_pill(&issue.state));1044     for l in &issue.labels {1045         body.push_str(&format!("<span class=\"chip\">{}</span>", esc(l)));1046     }1047     if let Some(m) = &issue.milestone {1048         body.push_str(&format!("<span class=\"chip milestone\">{}</span>", esc(m)));1049     }1050     body.push_str(&format!(1051         "<span class=\"meta-when\">opened {}</span>",1052         time_ago(&issue.created, now)1053     ));1054     body.push_str("</div>\n");1055     let mut details =1056         String::from("<details class=\"meta-more\"><summary>details</summary>\n<dl>\n");1057     if !issue.assignees.is_empty() {1058         let who = issue1059             .assignees1060             .iter()1061             .map(|a| format!("{}{}", monogram(a), esc(a)))1062             .collect::<Vec<_>>()1063             .join(", ");1064         details.push_str(&format!("<dt>assignees</dt><dd>{who}</dd>\n"));1065     }1066     if let Some(u) = &issue.updated {1067         details.push_str(&format!("<dt>updated</dt><dd>{}</dd>\n", time_ago(u, now)));1068     }1069     details.push_str(&format!(1070         "<dt>id</dt><dd><code>{}</code></dd>\n</dl></details>\n",1071         esc(&issue.id)1072     ));1073     body.push_str(&details);1074     body.push_str(&markdown_to_html_in(&issue.body, "", false));1075     body.push_str(&format!("<h2>Comments ({})</h2>\n", issue.comments.len()));1076     if issue.comments.is_empty() {1077         body.push_str("<p class=\"muted\">No comments yet.</p>\n");1078     } else {1079         body.push_str("<ul class=\"comments\">\n");1080         for c in &issue.comments {1081             body.push_str(&render_comment(c, now));1082         }1083         body.push_str("</ul>\n");1084     }1085     body.push_str("<p><a href=\"/issues/index.html\">all issues</a></p>\n");1086     page_site_with_provenance(1087         &format!("#{} {} — {site_name}", issue.number, issue.title),1088         nav,1089         &body,1090         site_name,1091         description,1092         branch,1093         short_sha,1094     )1095 }1096 1097 fn render_pr_page(1098     pr: &Pr,1099     site_name: &str,1100     nav: &str,1101     branch: &str,1102     short_sha: &str,1103     description: Option<&str>,1104 ) -> String {1105     let mut body = format!(1106         "<h1>#{n} {title}</h1>\n",1107         n = pr.number,1108         title = esc(&pr.title),1109     );1110     let now = crate::build::now_unix();1111     body.push_str("<div class=\"meta\">");1112     body.push_str(&state_pill(&pr.state));1113     if pr.draft {1114         body.push_str("<span class=\"chip\">draft</span>");1115     }1116     if let Some(ci) = &pr.ci {1117         body.push_str(&verdict_badge(ci));1118     }1119     body.push_str(&format!(1120         "<span class=\"meta-when\">opened {}</span>",1121         time_ago(&pr.created, now)1122     ));1123     body.push_str("</div>\n");1124     let mut details =1125         String::from("<details class=\"meta-more\"><summary>details</summary>\n<dl>\n");1126     details.push_str(&format!(1127         "<dt>branches</dt><dd><code>{}</code> ← <code>{}</code></dd>\n",1128         esc(&pr.base),1129         esc(&pr.head)1130     ));1131     if !pr.issues.is_empty() {1132         let issues = pr1133             .issues1134             .iter()1135             .map(|i| format!("<code>{}</code>", esc(i)))1136             .collect::<Vec<_>>()1137             .join(", ");1138         details.push_str(&format!("<dt>issues</dt><dd>{issues}</dd>\n"));1139     }1140     if let Some(u) = &pr.updated {1141         details.push_str(&format!("<dt>updated</dt><dd>{}</dd>\n", time_ago(u, now)));1142     }1143     details.push_str(&format!(1144         "<dt>id</dt><dd><code>{}</code></dd>\n</dl></details>\n",1145         esc(&pr.id)1146     ));1147     body.push_str(&details);1148     body.push_str(&markdown_to_html_in(&pr.body, "", false));1149     body.push_str("<h2>Patch</h2>\n");1150     match &pr.patch {1151         Some(patch) if !patch.is_empty() => {1152             body.push_str(&format!(1153                 "<div class=\"pre-scroll\"><pre class=\"diff\">{}</pre></div>\n",1154                 diff_html(patch)1155             ));1156         }1157         _ => body.push_str("<p class=\"muted\">No patch attached.</p>\n"),1158     }1159     body.push_str(&format!("<h2>Comments ({})</h2>\n", pr.comments.len()));1160     if pr.comments.is_empty() {1161         body.push_str("<p class=\"muted\">No comments yet.</p>\n");1162     } else {1163         body.push_str("<ul class=\"comments\">\n");1164         for c in &pr.comments {1165             body.push_str(&render_comment(c, now));1166         }1167         body.push_str("</ul>\n");1168     }1169     body.push_str(&format!("<h2>Reviews ({})</h2>\n", pr.reviews.len()));1170     if pr.reviews.is_empty() {1171         body.push_str("<p class=\"muted\">No reviews yet.</p>\n");1172     } else {1173         body.push_str("<ul class=\"reviews\">\n");1174         for r in &pr.reviews {1175             let state = r.state.as_deref().unwrap_or("commented");1176             let author = r.author.as_deref().unwrap_or("unknown");1177             body.push_str(&format!(1178                 "<li class=\"review\">\n<p class=\"comment-head\">{}<span class=\"who\">{}</span> · {} · {}</p>\n{}</li>\n",1179                 monogram(author),1180                 esc(author),1181                 time_ago(&r.created, now),1182                 esc(state),1183                 markdown_to_html_in(&r.body, "", false),1184             ));1185         }1186         body.push_str("</ul>\n");1187     }1188     body.push_str("<p><a href=\"/pulls/index.html\">all pulls</a></p>\n");1189     page_site_with_provenance(1190         &format!("#{} {} — {site_name}", pr.number, pr.title),1191         nav,1192         &body,1193         site_name,1194         description,1195         branch,1196         short_sha,1197     )1198 }1199 1200 fn render_issues_index(1201     data: &ForgeData,1202     site_name: &str,1203     nav: &str,1204     branch: &str,1205     short_sha: &str,1206     description: Option<&str>,1207 ) -> Result<String, String> {1208     let mut body =1209         String::from("<div class=\"page-head\"><h1>Issues</h1>\n<a class=\"btn-new\" href=\"/new-issue.html\">+ New issue</a></div>\n");1210     if data.issues.is_empty() {1211         body.push_str("<p class=\"muted\">No issues yet. <a href=\"/new-issue.html\">Suggest one</a> — no account needed.</p>\n");1212     } else {1213         let now = crate::build::now_unix();1214         body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>title</th><th>#</th><th>state</th><th>comments</th><th>created</th></tr></thead>\n<tbody>\n");1215         // m6/sort (#48 + #49): open first (newest-first within), then1216         // non-open (newest-first within). Numbers stay creation-rank.1217         // m6/cards2 (#46 + #47): title-first column order.1218         for issue in data.issues_in_display_order() {1219             let state_plain = esc(&issue.state);1220             let state_html = state_pill(&issue.state);1221             let created_plain = time_ago(&issue.created, now);1222             body.push_str(&format!(1223                 "<tr><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{title}\"><a href=\"/issues/{id}.html\">{title}</a></td><td class=\"num trunc\" data-label=\"#\" title=\"{n}\">{n}</td><td class=\"trunc\" data-label=\"state\" title=\"{state_plain}\">{state_html}</td><td class=\"trunc\" data-label=\"comments\" title=\"{comments}\">{comments}</td><td class=\"trunc\" data-label=\"created\" title=\"{created}\">{created_html}</td></tr>\n",1224                 n = issue.number,1225                 id = esc(&issue.id),1226                 title = esc(&issue.title),1227                 state_plain = state_plain,1228                 state_html = state_html,1229                 comments = issue.comments.len(),1230                 created = esc(&created_plain),1231                 created_html = created_plain,1232             ));1233         }1234         body.push_str("</tbody>\n</table></div>\n");1235     }1236     body.push_str(&format!(1237         "<p class=\"muted\">{n} closed</p>\n",1238         n = data.closed_issue_count(),1239     ));1240     if !data.labels.is_empty() {1241         body.push_str("<h2>Labels</h2>\n<ul class=\"labels\">\n");1242         for l in &data.labels {1243             body.push_str(&format!("<li><code>{}</code></li>\n", esc(&l.name)));1244         }1245         body.push_str("</ul>\n");1246     }1247     if !data.milestones.is_empty() {1248         body.push_str("<h2>Milestones</h2>\n<ul class=\"milestones\">\n");1249         for m in &data.milestones {1250             body.push_str(&format!(1251                 "<li><code>{}</code> <span class=\"muted\">{}</span></li>\n",1252                 esc(&m.name),1253                 esc(&m.state),1254             ));1255         }1256         body.push_str("</ul>\n");1257     }1258     Ok(page_site_with_provenance(1259         &format!("issues — {site_name}"),1260         nav,1261         &body,1262         site_name,1263         description,1264         branch,1265         short_sha,1266     ))1267 }1268 1269 fn render_pulls_index(1270     data: &ForgeData,1271     site_name: &str,1272     nav: &str,1273     branch: &str,1274     short_sha: &str,1275     description: Option<&str>,1276 ) -> String {1277     let mut body =1278         String::from("<div class=\"page-head\"><h1>Pulls</h1>\n<a class=\"btn-new\" href=\"/new-pr.html\">+ New PR prompt</a></div>\n");1279     if data.prs.is_empty() {1280         body.push_str("<p class=\"muted\">No pull requests yet. <a href=\"/new-pr.html\">Suggest one</a> — no account needed.</p>\n");1281     } else {1282         let now = crate::build::now_unix();1283         body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>title</th><th>#</th><th>state</th><th>head→base</th><th>created</th></tr></thead>\n<tbody>\n");1284         // m6/sort (#48 + #49): open (incl drafts) first (newest-first1285         // within), then closed/merged (newest-first within).1286         // Numbers stay creation-rank.1287         // m6/cards2 (#46 + #47): title-first column order.1288         for pr in data.prs_in_display_order() {1289             let lanes = format!(1290                 "<span class=\"lane\">{}</span><span class=\"lane-arrow\" aria-hidden=\"true\">→</span><span class=\"lane\">{}</span>",1291                 esc(&pr.head),1292                 esc(&pr.base)1293             );1294             let lanes_plain = format!("{}→{}", pr.head, pr.base);1295             let draft = if pr.draft { " draft" } else { "" };1296             let full_title = format!("{}{}", pr.title, draft);1297             let state_plain = esc(&pr.state);1298             let state_html = state_pill(&pr.state);1299             let created_plain = time_ago(&pr.created, now);1300             body.push_str(&format!(1301                 "<tr><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{full}\"><a href=\"/pulls/{id}.html\">{title}</a>{draft}</td><td class=\"num trunc\" data-label=\"#\" title=\"{n}\">{n}</td><td class=\"trunc\" data-label=\"state\" title=\"{state_plain}\">{state_html}</td><td class=\"trunc\" data-label=\"head→base\" title=\"{lanes_title}\"><code class=\"lanes\">{lanes}</code></td><td class=\"trunc\" data-label=\"created\" title=\"{created}\">{created_html}</td></tr>\n",1302                 n = pr.number,1303                 id = esc(&pr.id),1304                 title = esc(&pr.title),1305                 full = esc(&full_title),1306                 state_plain = state_plain,1307                 state_html = state_html,1308                 lanes_title = esc(&lanes_plain),1309                 created = esc(&created_plain),1310                 created_html = created_plain,1311             ));1312         }1313         body.push_str("</tbody>\n</table></div>\n");1314     }1315     body.push_str(&format!(1316         "<p class=\"muted\">{n} closed</p>\n",1317         n = data.closed_pr_count(),1318     ));1319     page_site_with_provenance(1320         &format!("pulls — {site_name}"),1321         nav,1322         &body,1323         site_name,1324         description,1325         branch,1326         short_sha,1327     )1328 }1329 1330 fn render_tasks_index(1331     tasks: &[TaskItem],1332     site_name: &str,1333     nav: &str,1334     branch: &str,1335     short_sha: &str,1336     description: Option<&str>,1337 ) -> String {1338     let mut body = String::from("<div class=\"page-head\"><h1>Tasks</h1></div>\n");1339     if tasks.is_empty() {1340         body.push_str("<p class=\"muted\">No tasks yet.</p>\n");1341     } else {1342         for status in ["todo", "doing", "blocked", "done"] {1343             let group: Vec<&TaskItem> = tasks.iter().filter(|t| t.status == status).collect();1344             body.push_str(&format!("<h2>{status} ({})</h2>\n", group.len()));1345             if group.is_empty() {1346                 body.push_str("<p class=\"muted\">None.</p>\n");1347             } else {1348                 body.push_str("<ul class=\"tasks\">\n");1349                 for t in group {1350                     let mut meta = format!("<code>{}</code> · {}", esc(&t.id), esc(&t.created));1351                     if let Some(u) = &t.updated {1352                         meta.push_str(&format!(" · updated {}", esc(u)));1353                     }1354                     if let Some(c) = &t.closed {1355                         meta.push_str(&format!(" · closed {}", esc(c)));1356                     }1357                     body.push_str(&format!(1358                         "<li class=\"task\">\n<p><strong class=\"trunc trunc-title\" title=\"{title}\">{title}</strong> <span class=\"muted\">{meta}</span></p>\n{assignee}{body_md}</li>\n",1359                         title = esc(&t.title),1360                         assignee = t.assignee.as_ref().map_or(String::new(), |a| format!(1361                             "<p class=\"muted\">assignee: <code>{}</code></p>\n",1362                             esc(a)1363                         )),1364                         body_md = markdown_to_html_in(&t.body, "", false),1365                     ));1366                 }1367                 body.push_str("</ul>\n");1368             }1369         }1370     }1371     page_site_with_provenance(1372         &format!("tasks — {site_name}"),1373         nav,1374         &body,1375         site_name,1376         description,1377         branch,1378         short_sha,1379     )1380 }1381 1382 fn render_ci_index(1383     ci: &CiInfo,1384     site_name: &str,1385     nav: &str,1386     branch: &str,1387     short_sha: &str,1388     description: Option<&str>,1389 ) -> String {1390     let mut body = String::from("<div class=\"page-head\"><h1>CI</h1></div>\n");1391     let now = crate::build::now_unix();1392     body.push_str(&format!(1393         "<div class=\"meta\"><span class=\"chip\">runner: {}</span></div>\n",1394         esc(&ci.runner)1395     ));1396     if ci.steps.is_empty() {1397         body.push_str("<p class=\"muted\">No CI steps configured.</p>\n");1398     } else {1399         body.push_str("<h2>Steps</h2>\n<ul class=\"ci-steps\">\n");1400         for step in &ci.steps {1401             body.push_str(&format!("<li><code>{}</code></li>\n", esc(step)));1402         }1403         body.push_str("</ul>\n");1404     }1405     body.push_str("<h2>Latest run</h2>\n");1406     match &ci.latest {1407         Some(run) => {1408             let steps = run.steps.len();1409             body.push_str("<div class=\"meta\">");1410             body.push_str(&verdict_badge(&run.verdict));1411             body.push_str(&format!(1412                 "<span class=\"meta-when\">ran {} \u{b7} {steps} step{}</span>",1413                 time_ago(&run.created, now),1414                 if steps == 1 { "" } else { "s" }1415             ));1416             body.push_str("</div>\n");1417             body.push_str(&format!(1418                 "<details class=\"meta-more\"><summary>details</summary>\n<dl><dt>run</dt><dd><code>{}</code></dd>\n<dt>created</dt><dd>{}</dd></dl></details>\n",1419                 esc(&run.run),1420                 time_ago(&run.created, now)1421             ));1422             if run.steps.is_empty() {1423                 body.push_str("<p class=\"muted\">No step results recorded.</p>\n");1424             } else {1425                 body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>step</th><th>exit</th><th>duration</th></tr></thead>\n<tbody>\n");1426                 for s in &run.steps {1427                     body.push_str(&format!(1428                         "<tr><td class=\"trunc\" data-label=\"step\" title=\"{name}\"><code>{name}</code></td><td class=\"trunc\" data-label=\"exit\" title=\"{exit}\">{exit}</td><td class=\"trunc\" data-label=\"duration\" title=\"{dur}\">{dur_h}</td></tr>\n",1429                         name = esc(&s.name),1430                         exit = s.exit,1431                         dur = s.duration_ms,1432                         dur_h = fmt_duration(s.duration_ms),1433                     ));1434                 }1435                 body.push_str("</tbody>\n</table></div>\n");1436             }1437             body.push_str("<h3>Log</h3>\n");1438             if run.body.trim().is_empty() {1439                 body.push_str("<p class=\"muted\">Empty log.</p>\n");1440             } else {1441                 body.push_str(&format!(1442                     "<div class=\"pre-scroll\"><pre class=\"log\">{}</pre></div>\n",1443                     esc(&run.body)1444                 ));1445             }1446             for (name, content) in &run.logs {1447                 body.push_str(&format!("<h3>{}</h3>\n", esc(name)));1448                 body.push_str(&format!(1449                     "<div class=\"pre-scroll\"><pre class=\"log\">{}</pre></div>\n",1450                     esc(content)1451                 ));1452             }1453         }1454         None => body.push_str("<p class=\"muted\">No CI runs yet.</p>\n"),1455     }1456     page_site_with_provenance(1457         &format!("ci — {site_name}"),1458         nav,1459         &body,1460         site_name,1461         description,1462         branch,1463         short_sha,1464     )1465 }1466 1467 fn write_page(out: &Path, rel: &str, html: &str) -> Result<(), String> {1468     if !crate::paths::is_safe_relative(rel) {1469         return Err(format!(1470             "hu13: refusing to write unsafe output path `{rel}`"1471         ));1472     }1473     let dest = out.join(rel);1474     if let Some(parent) = dest.parent() {1475         std::fs::create_dir_all(parent).map_err(|e| format!("hu13: create {rel} parent: {e}"))?;1476     }1477     std::fs::write(&dest, html).map_err(|e| format!("hu13: write {rel}: {e}"))1478 }1479 1480 /// Render `issues/`, `pulls/`, `tasks/`, `ci/` pages from already-loaded data.1481 pub fn render_loaded(1482     data: &ForgeData,1483     out: &Path,1484     site_name: &str,1485     nav: &str,1486     branch: &str,1487     short_sha: &str,1488     description: Option<&str>,1489 ) -> Result<(), String> {1490     write_page(1491         out,1492         "issues/index.html",1493         &render_issues_index(data, site_name, nav, branch, short_sha, description)?,1494     )?;1495     for issue in &data.issues {1496         write_page(1497             out,1498             &format!("issues/{}.html", issue.id),1499             &render_issue_page(issue, site_name, nav, branch, short_sha, description),1500         )?;1501     }1502 1503     write_page(1504         out,1505         "pulls/index.html",1506         &render_pulls_index(data, site_name, nav, branch, short_sha, description),1507     )?;1508     for pr in &data.prs {1509         write_page(1510             out,1511             &format!("pulls/{}.html", pr.id),1512             &render_pr_page(pr, site_name, nav, branch, short_sha, description),1513         )?;1514     }1515 1516     write_page(1517         out,1518         "tasks/index.html",1519         &render_tasks_index(&data.tasks, site_name, nav, branch, short_sha, description),1520     )?;1521     write_page(1522         out,1523         "ci/index.html",1524         &render_ci_index(&data.ci, site_name, nav, branch, short_sha, description),1525     )?;1526 1527     Ok(())1528 }1529 1530 #[cfg(test)]1531 mod tests {1532     use super::*;1533     use std::path::PathBuf;1534     use std::sync::atomic::{AtomicU64, Ordering};1535 1536     static COUNTER: AtomicU64 = AtomicU64::new(0);1537 1538     fn mktemp(prefix: &str) -> PathBuf {1539         let id = COUNTER.fetch_add(1, Ordering::SeqCst);1540         let dir =1541             std::env::temp_dir().join(format!("hu13-forge-{prefix}-{}-{id}", std::process::id()));1542         std::fs::create_dir_all(&dir).expect("mktemp");1543         dir1544     }1545 1546     fn write_forge(repo: &Path, rel: &str, content: &str) {1547         let dest = repo.join(".forge").join(rel);1548         std::fs::create_dir_all(dest.parent().expect("parent")).expect("mkdir");1549         std::fs::write(&dest, content).expect("write forge file");1550     }1551 1552     #[test]1553     fn frontmatter_split_shapes() {1554         let (yaml, body) = split_frontmatter("---\nid: x\n---\nhello\n").expect("split");1555         assert_eq!(yaml, "id: x\n");1556         assert_eq!(body, "hello\n");1557         assert!(split_frontmatter("no fences\n").is_none());1558         assert!(split_frontmatter("---\nunclosed\n").is_none());1559         assert!(split_frontmatter("---\n---\nempty yaml\n")1560             .expect("e")1561             .01562             .is_empty());1563     }1564 1565     #[test]1566     fn unknown_fields_ignored() {1567         let yaml =1568             "id: a\ntitle: T\nstate: open\ncreated: 2026-01-01T00:00:00Z\nsome_future_field: 123\n";1569         let fm: IssueFm = yaml_serde::from_str(yaml).expect("unknown fields ignored");1570         assert_eq!(fm.title, "T");1571         assert!(fm.labels.is_empty());1572         assert!(fm.milestone.is_none());1573     }1574 1575     #[test]1576     fn missing_required_is_error() {1577         let yaml = "id: a\nstate: open\ncreated: 2026-01-01T00:00:00Z\n";1578         let err = yaml_serde::from_str::<IssueFm>(yaml).expect_err("title missing");1579         assert!(1580             err.to_string().contains("title"),1581             "error names field: {err}"1582         );1583     }1584 1585     /// Two issues with out-of-order created dates: the earlier one ranks #1.1586     #[test]1587     fn issue_numbers_rank_by_created_then_id() {1588         let repo = mktemp("numbers");1589         write_forge(1590             &repo,1591             "issues/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/issue.md",1592             "---\nid: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb\ntitle: Newer\nstate: open\ncreated: 2026-09-10T04:00:00Z\n---\nnewer body\n",1593         );1594         write_forge(1595             &repo,1596             "issues/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/issue.md",1597             "---\nid: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa\ntitle: Older\nstate: open\ncreated: 2026-09-10T03:00:00Z\n---\nolder body\n",1598         );1599         let data = load(&repo);1600         assert_eq!(data.issues.len(), 2);1601         assert_eq!(data.issues[0].title, "Older");1602         assert_eq!(data.issues[0].number, 1);1603         assert_eq!(data.issues[1].title, "Newer");1604         assert_eq!(data.issues[1].number, 2);1605     }1606 1607     /// m6/sort (f0rge #48 + #49): issues index shows open first1608     /// (newest-first within), then closed (newest-first within).1609     /// Numbers stay creation-rank so they appear unordered — correct.1610     #[test]1611     fn issues_open_first_then_reverse_chron_numbers_stable() {1612         let repo = mktemp("sort-issues");1613         for (id, title, state, created) in [1614             (1615                 "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",1616                 "Open older",1617                 "open",1618                 "2026-09-10T01:00:00Z",1619             ),1620             (1621                 "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",1622                 "Closed older",1623                 "closed",1624                 "2026-09-10T02:00:00Z",1625             ),1626             (1627                 "cccccccc-cccc-cccc-cccc-cccccccccccc",1628                 "Open newer",1629                 "open",1630                 "2026-09-10T03:00:00Z",1631             ),1632             (1633                 "dddddddd-dddd-dddd-dddd-dddddddddddd",1634                 "Closed newer",1635                 "closed",1636                 "2026-09-10T04:00:00Z",1637             ),1638         ] {1639             write_forge(1640                 &repo,1641                 &format!("issues/{id}/issue.md"),1642                 &format!(1643                     "---\nid: {id}\ntitle: {title}\nstate: {state}\ncreated: {created}\n---\nbody\n"1644                 ),1645             );1646         }1647         let data = load(&repo);1648         // Numbers stay creation-rank (may appear unordered in display).1649         assert_eq!(data.issues[0].number, 1);1650         assert_eq!(data.issues[0].title, "Open older");1651         assert_eq!(data.issues[1].number, 2);1652         assert_eq!(data.issues[2].number, 3);1653         assert_eq!(data.issues[3].number, 4);1654         // Display order: open newest-first, then closed newest-first.1655         let ordered: Vec<&str> = issues_display_order(&data.issues)1656             .iter()1657             .map(|i| i.title.as_str())1658             .collect();1659         assert_eq!(1660             ordered,1661             vec!["Open newer", "Open older", "Closed newer", "Closed older"],1662             "open-first, created desc within"1663         );1664         let numbers: Vec<usize> = issues_display_order(&data.issues)1665             .iter()1666             .map(|i| i.number)1667             .collect();1668         assert_eq!(numbers, vec![3, 1, 4, 2], "numbers stay creation-rank");1669 1670         let out = mktemp("sort-issues-out");1671         render_loaded(&data, &out, "test", "<nav>", "main", "abc1234", None).expect("render");1672         let html = std::fs::read_to_string(out.join("issues/index.html")).expect("issues");1673         let pos = |needle: &str| html.find(needle).expect(needle);1674         assert!(1675             pos("Open newer") < pos("Open older")1676                 && pos("Open older") < pos("Closed newer")1677                 && pos("Closed newer") < pos("Closed older"),1678             "exact row order: open newest-first then closed newest-first"1679         );1680         // Spot-check the stable numbers ride with their rows.1681         assert!(1682             html.contains("<td class=\"num trunc\" data-label=\"#\" title=\"3\">3</td>"),1683             "Open newer keeps #3"1684         );1685         assert!(1686             html.contains("<td class=\"num trunc\" data-label=\"#\" title=\"1\">1</td>"),1687             "Open older keeps #1"1688         );1689     }1690 1691     /// m6/sort (f0rge #48 + #49): pulls index shows open incl drafts1692     /// first (newest-first within), then closed/merged (newest-first).1693     /// Numbers stay creation-rank. Commits stay newest-first (no change).1694     #[test]1695     fn pulls_open_first_then_reverse_chron_numbers_stable() {1696         let repo = mktemp("sort-pulls");1697         for (id, title, state, created, draft) in [1698             (1699                 "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",1700                 "Old open",1701                 "open",1702                 "2026-09-10T01:00:00Z",1703                 false,1704             ),1705             (1706                 "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",1707                 "Old closed",1708                 "closed",1709                 "2026-09-10T02:00:00Z",1710                 false,1711             ),1712             (1713                 "cccccccc-cccc-cccc-cccc-cccccccccccc",1714                 "New draft",1715                 "open",1716                 "2026-09-10T03:00:00Z",1717                 true,1718             ),1719             (1720                 "dddddddd-dddd-dddd-dddd-dddddddddddd",1721                 "New merged",1722                 "merged",1723                 "2026-09-10T04:00:00Z",1724                 false,1725             ),1726         ] {1727             write_forge(1728                 &repo,1729                 &format!("prs/{id}/pr.md"),1730                 &format!(1731                     "---\nid: {id}\ntitle: {title}\nstate: {state}\nbase: main\nhead: feat\ncreated: {created}\ndraft: {draft}\n---\ndesc\n"1732                 ),1733             );1734         }1735         let data = load(&repo);1736         assert_eq!(data.prs[0].number, 1);1737         assert_eq!(data.prs[3].number, 4);1738         let ordered: Vec<&str> = prs_display_order(&data.prs)1739             .iter()1740             .map(|p| p.title.as_str())1741             .collect();1742         assert_eq!(1743             ordered,1744             vec!["New draft", "Old open", "New merged", "Old closed"],1745             "open incl drafts first, created desc within"1746         );1747         let numbers: Vec<usize> = prs_display_order(&data.prs)1748             .iter()1749             .map(|p| p.number)1750             .collect();1751         assert_eq!(numbers, vec![3, 1, 4, 2], "numbers stay creation-rank");1752 1753         let out = mktemp("sort-pulls-out");1754         render_loaded(&data, &out, "test", "<nav>", "main", "abc1234", None).expect("render");1755         let html = std::fs::read_to_string(out.join("pulls/index.html")).expect("pulls");1756         let pos = |needle: &str| html.find(needle).expect(needle);1757         assert!(1758             pos("New draft") < pos("Old open")1759                 && pos("Old open") < pos("New merged")1760                 && pos("New merged") < pos("Old closed"),1761             "exact row order: open newest-first then closed/merged newest-first"1762         );1763         assert!(html.contains(" draft"), "draft suffix kept");1764     }1765 1766     /// m6/sort (f0rge #49): `created` tiebreak is `id` desc in display.1767     #[test]1768     fn display_order_tiebreak_is_id_desc() {1769         let repo = mktemp("sort-tie");1770         for id in [1771             "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",1772             "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",1773         ] {1774             write_forge(1775                 &repo,1776                 &format!("issues/{id}/issue.md"),1777                 &format!(1778                     "---\nid: {id}\ntitle: T {id}\nstate: open\ncreated: 2026-09-10T03:00:00Z\n---\nbody\n"1779                 ),1780             );1781         }1782         let data = load(&repo);1783         // Creation-rank still `id` asc...1784         assert_eq!(data.issues[0].number, 1);1785         // ...but display is `id` desc on ties (newest-first mirror).1786         let ordered: Vec<&str> = issues_display_order(&data.issues)1787             .iter()1788             .map(|i| i.id.as_str())1789             .collect();1790         assert_eq!(1791             ordered,1792             vec![1793                 "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",1794                 "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"1795             ]1796         );1797     }1798 1799     #[test]1800     fn broken_issue_skipped_build_lives() {1801         let repo = mktemp("broken");1802         write_forge(1803             &repo,1804             "issues/good11111-1111-1111-1111-111111111111/issue.md",1805             "---\nid: good11111-1111-1111-1111-111111111111\ntitle: Good\nstate: open\ncreated: 2026-09-10T03:00:00Z\n---\nok\n",1806         );1807         write_forge(1808             &repo,1809             "issues/bad222222-2222-2222-2222-222222222222/issue.md",1810             "---\nid: bad222222-2222-2222-2222-222222222222\nstate: open\ncreated: 2026-09-10T04:00:00Z\n---\nno title\n",1811         );1812         let data = load(&repo);1813         assert_eq!(data.issues.len(), 1, "broken file skipped");1814         assert_eq!(data.issues[0].number, 1);1815     }1816 1817     #[test]1818     fn pr_loads_patch_and_reviews() {1819         let repo = mktemp("pr");1820         write_forge(1821             &repo,1822             "prs/cccccccc-cccc-cccc-cccc-cccccccccccc/pr.md",1823             "---\nid: cccccccc-cccc-cccc-cccc-cccccccccccc\ntitle: Fix it\nstate: open\nbase: main\nhead: fix\ncreated: 2026-09-10T03:00:00Z\ndraft: true\n---\ndesc <b>bold</b>\n",1824         );1825         let dest = repo1826             .join(".forge")1827             .join("prs/cccccccc-cccc-cccc-cccc-cccccccccccc/patch.diff");1828         std::fs::write(&dest, "diff --git a/x b/x\n+evil <script>\n").unwrap();1829         write_forge(1830             &repo,1831             "prs/cccccccc-cccc-cccc-cccc-cccccccccccc/reviews/dddddddd-dddd-dddd-dddd-dddddddddddd.md",1832             "---\nid: dddddddd-dddd-dddd-dddd-dddddddddddd\nauthor: z\nstate: approved\ncreated: 2026-09-10T05:00:00Z\n---\nlgtm\n",1833         );1834         let data = load(&repo);1835         assert_eq!(data.prs.len(), 1);1836         assert_eq!(data.prs[0].number, 1);1837         assert!(data.prs[0].draft);1838         assert!(data.prs[0]1839             .patch1840             .as_ref()1841             .expect("patch")1842             .contains("diff --git"));1843         assert_eq!(data.prs[0].reviews.len(), 1);1844         assert_eq!(data.prs[0].reviews[0].state.as_deref(), Some("approved"));1845 1846         let out = mktemp("pr-out");1847         render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)1848             .expect("render");1849         let html =1850             std::fs::read_to_string(out.join("pulls/cccccccc-cccc-cccc-cccc-cccccccccccc.html"))1851                 .expect("pr page");1852         assert!(html.contains("#1"), "number displayed");1853         assert!(!html.contains("<script>"), "patch escaped");1854         assert!(html.contains("&lt;script&gt;"), "escaped patch present");1855         assert!(html.contains("approved"), "review state shown");1856         assert!(html.contains("lgtm"), "review body shown");1857     }1858 1859     #[test]1860     fn pr_patch_lines_carry_diff_spans() {1861         // m6 diff-colors (f0rge #22): PR patch.diff shares the commit renderer.1862         let repo = mktemp("pr-diff");1863         write_forge(1864             &repo,1865             "prs/cccccccc-cccc-cccc-cccc-cccccccccccc/pr.md",1866             "---\nid: cccccccc-cccc-cccc-cccc-cccccccccccc\ntitle: Fix it\nstate: open\nbase: main\nhead: fix\ncreated: 2026-09-10T03:00:00Z\n---\ndesc\n",1867         );1868         let dest = repo1869             .join(".forge")1870             .join("prs/cccccccc-cccc-cccc-cccc-cccccccccccc/patch.diff");1871         std::fs::write(1872             &dest,1873             "diff --git a/x b/x\n--- a/x\n+++ b/x\n@@ -1 +1 @@\n-old\n+evil <script>\n",1874         )1875         .unwrap();1876         let out = mktemp("pr-diff-out");1877         render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)1878             .expect("render");1879         let html =1880             std::fs::read_to_string(out.join("pulls/cccccccc-cccc-cccc-cccc-cccccccccccc.html"))1881                 .expect("pr page");1882         assert!(1883             html.contains("<span class=\"diff-hunk\">@@ -1 +1 @@</span>"),1884             "hunk cyan: {html}"1885         );1886         assert!(1887             html.contains("<span class=\"diff-file\">--- a/x</span>"),1888             "--- file bold"1889         );1890         assert!(1891             html.contains("<span class=\"diff-file\">+++ b/x</span>"),1892             "+++ file bold"1893         );1894         assert!(1895             html.contains("<span class=\"diff-del\">-old</span>"),1896             "removed red"1897         );1898         assert!(!html.contains("<script>"), "patch escaped");1899         assert!(1900             html.contains("<span class=\"diff-add\">+evil &lt;script&gt;</span>"),1901             "added green, escaped in span"1902         );1903     }1904 1905     #[test]1906     fn tasks_group_and_ci_fail_badge() {1907         let repo = mktemp("taskci");1908         for (id, status) in [1909             ("11111111-1111-1111-1111-111111111111", "todo"),1910             ("22222222-2222-2222-2222-222222222222", "doing"),1911             ("33333333-3333-3333-3333-333333333333", "blocked"),1912             ("44444444-4444-4444-4444-444444444444", "done"),1913         ] {1914             write_forge(1915                 &repo,1916                 &format!("tasks/{id}.md"),1917                 &format!("---\nid: {id}\ntitle: Task {status}\nstatus: {status}\ncreated: 2026-09-10T03:00:00Z\n---\nbody {status}\n"),1918             );1919         }1920         write_forge(&repo, "ci/ci.env", "runner: local\n");1921         std::fs::create_dir_all(repo.join(".forge").join("ci/steps")).unwrap();1922         std::fs::write(1923             repo.join(".forge").join("ci/steps/00_test.sh"),1924             "#!/bin/sh\n",1925         )1926         .unwrap();1927         write_forge(1928             &repo,1929             "ci/runs/2026-09-10T00-00-00Z/summary.md",1930             "---\nrun: 2026-09-10T00-00-00Z\nverdict: fail\ncreated: 2026-09-10T00:00:00Z\nsteps:\n  - name: 00_test.sh\n    exit: 1\n    duration_ms: 42\n---\nboom log\n",1931         );1932         let data = load(&repo);1933         assert_eq!(data.tasks.len(), 4);1934         assert_eq!(data.ci.runner, "local");1935         assert_eq!(data.ci.steps, vec!["00_test.sh".to_string()]);1936         assert_eq!(data.ci.latest_verdict(), "fail");1937 1938         let out = mktemp("taskci-out");1939         render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)1940             .expect("render");1941         let tasks = std::fs::read_to_string(out.join("tasks/index.html")).expect("tasks");1942         for status in ["todo", "doing", "blocked", "done"] {1943             assert!(tasks.contains(status), "group {status}");1944         }1945         let ci = std::fs::read_to_string(out.join("ci/index.html")).expect("ci");1946         assert!(ci.contains("badge fail"), "fail badge");1947         assert!(ci.contains(">fail<"), "verdict text");1948         assert!(ci.contains("00_test.sh"), "step name");1949         assert!(ci.contains("boom log"), "log body");1950     }1951 1952     #[test]1953     fn missing_forge_is_empty_not_error() {1954         let repo = mktemp("empty");1955         let data = load(&repo);1956         assert!(data.issues.is_empty());1957         assert!(data.prs.is_empty());1958         assert!(data.tasks.is_empty());1959         assert_eq!(data.ci.latest_verdict(), "none");1960         let out = mktemp("empty-out");1961         render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)1962             .expect("render");1963         for page in [1964             "issues/index.html",1965             "pulls/index.html",1966             "tasks/index.html",1967             "ci/index.html",1968         ] {1969             let html = std::fs::read_to_string(out.join(page)).expect(page);1970             assert!(html.contains("No "), "{page} empty state");1971         }1972     }1973 1974     /// f0rge #25: index pages carry prominent `+` submit buttons (correct1975     /// hrefs, icon + words) and the empty states link to the forms.1976     #[test]1977     fn submit_buttons_on_issues_pulls_indexes() {1978         // Empty forge: buttons + linked empty states.1979         let repo = mktemp("submit-empty");1980         let out = mktemp("submit-empty-out");1981         render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)1982             .expect("render");1983         let issues = std::fs::read_to_string(out.join("issues/index.html")).expect("issues");1984         let pulls = std::fs::read_to_string(out.join("pulls/index.html")).expect("pulls");1985         assert!(1986             issues.contains("<a class=\"btn-new\" href=\"/new-issue.html\">+ New issue</a>"),1987             "issues + button"1988         );1989         assert!(1990             pulls.contains("<a class=\"btn-new\" href=\"/new-pr.html\">+ New PR prompt</a>"),1991             "pulls + button"1992         );1993         assert!(1994             issues.contains("No issues yet.")1995                 && issues.contains("href=\"/new-issue.html\">Suggest one"),1996             "issues empty state links to form"1997         );1998         assert!(1999             pulls.contains("No pull requests yet.")2000                 && pulls.contains("href=\"/new-pr.html\">Suggest one"),2001             "pulls empty state links to form"2002         );2003         // Non-empty forge: buttons stay above the tables.2004         write_forge(2005             &repo,2006             "issues/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/issue.md",2007             "---\nid: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa\ntitle: T\nstate: open\ncreated: 2026-09-10T03:00:00Z\n---\nbody\n",2008         );2009         write_forge(2010             &repo,2011             "prs/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/pr.md",2012             "---\nid: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb\ntitle: P\nstate: open\nbase: main\nhead: fix\ncreated: 2026-09-10T03:00:00Z\n---\ndesc\n",2013         );2014         let out2 = mktemp("submit-full-out");2015         render_loaded(2016             &load(&repo),2017             &out2,2018             "test",2019             "<nav>",2020             "main",2021             "abc1234",2022             None,2023         )2024         .expect("render");2025         let issues2 = std::fs::read_to_string(out2.join("issues/index.html")).expect("issues");2026         let pulls2 = std::fs::read_to_string(out2.join("pulls/index.html")).expect("pulls");2027         assert!(2028             issues2.contains("href=\"/new-issue.html\">+ New issue"),2029             "issues + button with rows"2030         );2031         assert!(2032             pulls2.contains("href=\"/new-pr.html\">+ New PR prompt"),2033             "pulls + button with rows"2034         );2035         assert!(2036             issues2.contains("table-scroll"),2037             "issues table still renders"2038         );2039         assert!(pulls2.contains("table-scroll"), "pulls table still renders");2040     }2041 2042     #[test]2043     fn verdict_badge_shapes() {2044         assert!(verdict_badge("pass").contains("badge pass"));2045         assert!(verdict_badge("fail").contains("badge fail"));2046         assert!(verdict_badge("running").contains("badge running"));2047         assert!(verdict_badge("cancelled").contains("badge cancelled"));2048         assert!(verdict_badge("none").contains(">none<"));2049         assert!(verdict_badge("<evil>").contains("&lt;evil&gt;"));2050         assert!(!verdict_badge("<evil>").contains("<evil>"));2051     }2052 2053     /// Big-forge: read the real f0rge checkout. Counts must equal a direct2054     /// file-count (and `f0rge issue list` when available); zero panics.2055     #[test]2056     fn big_forge_real_shape_no_panics() {2057         let real = PathBuf::from("~/src/code/gl0w-w0rld/f0rge/.forge");2058         if !real.exists() {2059             return;2060         }2061         let repo = real.parent().expect("parent").to_path_buf();2062         let data = load(&repo);2063         let dir_count = |sub: &str| {2064             std::fs::read_dir(real.join(sub))2065                 .map(|rd| rd.filter_map(Result::ok).count())2066                 .unwrap_or(0)2067         };2068         let file_count = |sub: &str| {2069             std::fs::read_dir(real.join(sub))2070                 .map(|rd| {2071                     rd.filter_map(Result::ok)2072                         .filter(|e| e.path().extension().is_some_and(|x| x == "md"))2073                         .count()2074                 })2075                 .unwrap_or(0)2076         };2077         assert_eq!(2078             data.issues.len(),2079             dir_count("issues"),2080             "issues == dir count"2081         );2082         assert_eq!(data.prs.len(), dir_count("prs"), "prs == dir count");2083         assert_eq!(data.tasks.len(), file_count("tasks"), "tasks == file count");2084         // Cross-check against `f0rge issue list` when the binary is around.2085         let cli_count = std::process::Command::new("f0rge")2086             .arg("issue")2087             .arg("list")2088             .current_dir(&repo)2089             .output()2090             .ok()2091             .filter(|o| o.status.success())2092             .map(|o| {2093                 String::from_utf8_lossy(&o.stdout)2094                     .lines()2095                     .filter(|l| !l.trim().is_empty())2096                     .count()2097             });2098         if let Some(n) = cli_count {2099             assert_eq!(data.issues.len(), n, "issues == `f0rge issue list` count");2100         }2101         // Numbers are a clean 1..=N rank in (created, id) order.2102         let mut prev = (String::new(), String::new());2103         for (i, issue) in data.issues.iter().enumerate() {2104             assert_eq!(issue.number, i + 1);2105             let key = (issue.created.clone(), issue.id.clone());2106             assert!(key >= prev, "sorted order");2107             prev = key;2108         }2109         assert!(!data.ci.steps.is_empty(), "real ci steps read");2110         assert!(data.ci.latest.is_some(), "real ci latest run read");2111     }2112 2113     /// m6/table-scroll (f0rge #30): CSS keeps natural width + nowrap cells2114     /// with a wrapping title floor; hover tint via var.2115     #[test]2116     fn table_scroll_css_rules() {2117         let css = include_str!("../static/style.css");2118         assert!(css.contains("width:max-content"), "natural width");2119         assert!(css.contains("min-width:100%"), "full-bleed floor");2120         assert!(2121             css.contains(".table-scroll th,.table-scroll td") && css.contains("white-space:nowrap"),2122             "cells nowrap"2123         );2124         assert!(2125             css.contains(".table-scroll td.title") && css.contains("min-width:12rem"),2126             "title floor"2127         );2128         assert!(2129             css.contains("tr:hover td") && css.contains("var(--bg-th)"),2130             "hover tint"2131         );2132     }2133 2134     /// m6/table-scroll (f0rge #31): h1 + button share one .page-head row.2135     #[test]2136     fn page_head_css_and_markup() {2137         let css = include_str!("../static/style.css");2138         assert!(css.contains(".page-head"), "page-head rule");2139         assert!(css.contains("align-items:center"), "inline row");2140         let repo = mktemp("page-head");2141         let out = mktemp("page-head-out");2142         render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)2143             .expect("render");2144         for (page, title, btn) in [2145             ("issues/index.html", "<h1>Issues</h1>", "+ New issue"),2146             ("pulls/index.html", "<h1>Pulls</h1>", "+ New PR prompt"),2147         ] {2148             let html = std::fs::read_to_string(out.join(page)).expect(page);2149             let head = html.find("<div class=\"page-head\">").expect("page-head");2150             let h1 = html.find(title).expect("h1");2151             let b = html.find(btn).expect("button");2152             let close = html[head..].find("</div>").expect("close") + head;2153             assert!(2154                 h1 > head && b > head && h1 < close && b < close,2155                 "{page}: h1+btn in page-head"2156             );2157         }2158         // Tasks has no submit button (no page-head needed).2159         let tasks = std::fs::read_to_string(out.join("tasks/index.html")).expect("tasks");2160         assert!(!tasks.contains("btn-new"), "tasks: no submit button");2161     }2162 2163     /// m6/table-scroll (f0rge #30): 200-char title + long sha stay in a2164     /// .table-scroll wrapper with a td.title cell (scroll, never squash).2165     #[test]2166     fn long_title_and_sha_scroll() {2167         let long_title = "t".repeat(200);2168         let long_sha = "a".repeat(64);2169         let repo = mktemp("long-title");2170         write_forge(2171             &repo,2172             "issues/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/issue.md",2173             &format!(2174                 "---\nid: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa\ntitle: {long_title}\nstate: open\ncreated: 2026-09-10T03:00:00Z\n---\nbody\n"2175             ),2176         );2177         write_forge(2178             &repo,2179             "prs/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/pr.md",2180             &format!(2181                 "---\nid: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb\ntitle: {long_title}\nstate: open\nbase: main\nhead: {long_sha}\ncreated: 2026-09-10T03:00:00Z\n---\ndesc\n"2182             ),2183         );2184         let out = mktemp("long-title-out");2185         render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)2186             .expect("render");2187         for page in ["issues/index.html", "pulls/index.html"] {2188             let html = std::fs::read_to_string(out.join(page)).expect(page);2189             assert!(2190                 html.contains("<div class=\"table-scroll\"><table>"),2191                 "{page}: wrapper"2192             );2193             assert!(2194                 html.contains("<td class=\"title trunc trunc-title\""),2195                 "{page}: title cell trunc"2196             );2197             assert!(html.contains("title=\""), "{page}: title attrs");2198             assert!(html.contains(&long_title), "{page}: long title kept");2199         }2200         let pulls = std::fs::read_to_string(out.join("pulls/index.html")).expect("pulls");2201         assert!(pulls.contains(&long_sha), "long head sha kept");2202     }2203 2204     /// m6/open-counts (f0rge #29): nav counts are open-only2205     /// (`state == "open"` exactly; in-progress is NOT open; drafts count2206     /// as open and stay marked) with `N closed` totals on index pages.2207     #[test]2208     fn open_only_counts_with_closed_lines() {2209         let repo = mktemp("open-counts");2210         for (uuid, state, hour) in [2211             ("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "open", "03"),2212             ("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "in-progress", "04"),2213             ("cccccccc-cccc-cccc-cccc-cccccccccccc", "closed", "05"),2214         ] {2215             write_forge(2216                 &repo,2217                 &format!("issues/{uuid}/issue.md"),2218                 &format!(2219                     "---\nid: {uuid}\ntitle: Issue {state}\nstate: {state}\ncreated: 2026-09-10T{hour}:00:00Z\n---\nbody\n"2220                 ),2221             );2222         }2223         for (uuid, state, draft, hour) in [2224             ("dddddddd-dddd-dddd-dddd-dddddddddddd", "open", false, "03"),2225             ("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", "open", true, "04"),2226             (2227                 "ffffffff-ffff-ffff-ffff-ffffffffffff",2228                 "merged",2229                 false,2230                 "05",2231             ),2232         ] {2233             write_forge(2234                 &repo,2235                 &format!("prs/{uuid}/pr.md"),2236                 &format!(2237                     "---\nid: {uuid}\ntitle: PR {uuid}\nstate: {state}\nbase: main\nhead: feat\ncreated: 2026-09-10T{hour}:00:00Z\ndraft: {draft}\n---\ndesc\n"2238                 ),2239             );2240         }2241         let data = load(&repo);2242         assert_eq!(data.open_issue_count(), 1, "only state==open");2243         assert_eq!(data.closed_issue_count(), 2, "in-progress + closed");2244         assert_eq!(data.open_pr_count(), 2, "open + draft-open");2245         assert_eq!(data.closed_pr_count(), 1, "merged");2246 2247         let out = mktemp("open-counts-out");2248         render_loaded(&data, &out, "test", "<nav>", "main", "abc1234", None).expect("render");2249         let issues = std::fs::read_to_string(out.join("issues/index.html")).expect("issues");2250         assert!(issues.contains("2 closed"), "issues closed total");2251         assert!(issues.contains("Issue open"), "open row kept");2252         assert!(issues.contains("Issue closed"), "closed row kept");2253         let pulls = std::fs::read_to_string(out.join("pulls/index.html")).expect("pulls");2254         assert!(pulls.contains("1 closed"), "pulls closed total");2255         assert!(pulls.contains(" draft"), "draft stays marked");2256     }2257 2258     /// m6/no-scroll (f0rge #39): stacked-card reflow on narrow, desktop untouched.2259     /// Research: from knowledge of Gitea mobile (issue/PR lists collapse to2260     /// title-led cards; file table stacks name + meta) — no live fetch here.2261     #[test]2262     fn stacked_cards_no_scroll() {2263         let css = include_str!("../static/style.css");2264         assert!(css.contains("@media (max-width:767px)"), "narrow query");2265         assert!(css.contains("overflow-x:visible"), "no scroll on narrow");2266         assert!(2267             css.contains("content:attr(data-label)"),2268             "label via ::before"2269         );2270         assert!(2271             css.contains(".table-scroll:not(.file-scroll) td::before"),2272             "label rule"2273         );2274         assert!(2275             css.contains(".table-scroll:not(.file-scroll) td.title::before"),2276             "title leads card"2277         );2278         // m6/cards2 (f0rge #46): file table exempt from the card reflow.2279         assert!(2280             css.contains(".file-scroll{overflow-x:auto}"),2281             "file table keeps h-scroll on narrow"2282         );2283         assert!(2284             css.contains("display:block") && css.contains("display:flex"),2285             "card layout"2286         );2287         assert!(css.contains("width:max-content"), "desktop width kept");2288         assert!(2289             css.contains(".table-scroll th,.table-scroll td") && css.contains("white-space:nowrap"),2290             "desktop nowrap kept"2291         );2292         let repo = mktemp("cards");2293         write_forge(2294             &repo,2295             "issues/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/issue.md",2296             "---\nid: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa\ntitle: Card issue\nstate: open\ncreated: 2026-09-10T03:00:00Z\n---\nbody\n",2297         );2298         write_forge(2299             &repo,2300             "prs/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/pr.md",2301             "---\nid: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb\ntitle: Card pr\nstate: open\nbase: main\nhead: feat\ncreated: 2026-09-10T03:00:00Z\n---\ndesc\n",2302         );2303         write_forge(2304             &repo,2305             "tasks/cccccccc-cccc-cccc-cccc-cccccccccccc.md",2306             "---\nid: cccccccc-cccc-cccc-cccc-cccccccccccc\ntitle: Card task\nstatus: todo\ncreated: 2026-09-10T03:00:00Z\n---\nwork\n",2307         );2308         write_forge(&repo, "ci/ci.env", "runner: local\n");2309         std::fs::create_dir_all(repo.join(".forge").join("ci/steps")).unwrap();2310         std::fs::write(2311             repo.join(".forge").join("ci/steps/00_test.sh"),2312             "#!/bin/sh\n",2313         )2314         .unwrap();2315         write_forge(2316             &repo,2317             "ci/runs/2026-09-10T00-00-00Z/summary.md",2318             "---\nrun: 2026-09-10T00-00-00Z\nverdict: pass\ncreated: 2026-09-10T00:00:00Z\nsteps:\n  - name: 00_test.sh\n    exit: 0\n    duration_ms: 7\n---\nok\n",2319         );2320         let out = mktemp("cards-out");2321         render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)2322             .expect("render");2323         for (page, labels) in [2324             (2325                 "issues/index.html",2326                 vec!["#", "title", "state", "comments", "created"],2327             ),2328             (2329                 "pulls/index.html",2330                 vec!["#", "title", "state", "head→base", "created"],2331             ),2332             ("ci/index.html", vec!["step", "exit", "duration"]),2333         ] {2334             let html = std::fs::read_to_string(out.join(page)).expect(page);2335             let tds = html.matches("<td").count();2336             assert!(tds > 0, "{page} has cells");2337             assert_eq!(2338                 html.matches("data-label=").count(),2339                 tds,2340                 "{page}: every td labeled"2341             );2342             for label in labels {2343                 assert!(2344                     html.contains(&format!("data-label=\"{label}\"")),2345                     "{page}: label {label}"2346                 );2347             }2348         }2349         // m6/cards2 (f0rge #47): card DOM order is title-first via template2350         // cell order (no CSS order hacks) — desktop column headers match.2351         for (page, first, second) in [2352             (2353                 "issues/index.html",2354                 "data-label=\"title\"",2355                 "data-label=\"#\"",2356             ),2357             (2358                 "pulls/index.html",2359                 "data-label=\"title\"",2360                 "data-label=\"#\"",2361             ),2362             (2363                 "ci/index.html",2364                 "data-label=\"step\"",2365                 "data-label=\"exit\"",2366             ),2367         ] {2368             let html = std::fs::read_to_string(out.join(page)).expect(page);2369             let a = html.find(first).expect("first cell");2370             let b = html.find(second).expect("second cell");2371             assert!(a < b, "{page}: card DOM title precedes meta");2372         }2373         let tasks = std::fs::read_to_string(out.join("tasks/index.html")).expect("tasks");2374         assert!(!tasks.contains("table-scroll"), "tasks: no scroll wrapper");2375         let title_pos = tasks.find("Card task").expect("task title");2376         let meta_pos = tasks.find("<code>cccccccc").expect("task meta");2377         assert!(title_pos < meta_pos, "tasks: title precedes meta");2378         assert!(tasks.contains("todo (1)"), "tasks group kept");2379         let issues = std::fs::read_to_string(out.join("issues/index.html")).expect("issues");2380         assert!(2381             issues.contains("open") && issues.contains("2026-09-10"),2382             "issues data kept in DOM"2383         );2384     }2385 }