/summary.md`.
+//!
+//! Rules (spec/schema.md §0, DRAFT): frontmatter is a tiny `---` split +
+//! `yaml_serde`; unknown fields are ignored; a missing required field
+//! skips that file with an `eprintln!` warning naming file + field and the
+//! build still exits 0. Numbers are derived (never stored): 1-based rank
+//! of `(created, id)` at build time. Pages display the number and link by
+//! uuid.
+//!
+//! SECURITY: every byte from `.forge/` is untrusted. Markdown bodies go
+//! through [`crate::build::markdown_to_html`] (raw HTML escaped); titles,
+//! ids, states, diffs, and logs go through [`crate::layout::esc`] (diffs
+//! and logs as escaped ``, never markdown).
+
+use std::path::Path;
+
+use serde::Deserialize;
+
+use crate::build::markdown_to_html;
+use crate::layout::{esc, page};
+
+/// `.forge/` files larger than this are skipped (schema SEC-01: 256 KiB).
+pub const FORGE_MAX_BYTES: u64 = 256 * 1024;
+
+/// Step-log bytes kept per log file (rest truncated with a marker).
+pub const LOG_PREVIEW_MAX: usize = 64 * 1024;
+
+// ---------------------------------------------------------------------------
+// Frontmatter plumbing
+// ---------------------------------------------------------------------------
+
+/// Split `---\nyaml\n---\nbody` into `(yaml, body)`.
+///
+/// The opening fence must be the first line; the closing fence must be a
+/// full line `---`. Returns `None` when there is no frontmatter.
+fn split_frontmatter(text: &str) -> Option<(&str, &str)> {
+ let rest = text.strip_prefix("---")?;
+ let rest = rest
+ .strip_prefix('\n')
+ .or_else(|| rest.strip_prefix("\r\n"))?;
+ let mut offset = 0;
+ for line in rest.split_inclusive('\n') {
+ let trimmed = line.trim_end_matches(['\n', '\r']);
+ if trimmed == "---" {
+ let yaml = &rest[..offset];
+ let body = &rest[offset + line.len()..];
+ return Some((yaml, body));
+ }
+ offset += line.len();
+ }
+ None
+}
+
+/// Read a UTF-8 file, skipping (with a warning) files that are missing,
+/// oversized, or non-UTF-8. `label` names the file for warnings.
+fn read_text_capped(path: &Path, label: &str) -> Option {
+ let len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
+ if len > FORGE_MAX_BYTES {
+ eprintln!("hu13: forge: skipping {label}: over 256 KiB ({len} bytes)");
+ return None;
+ }
+ match std::fs::read(path) {
+ Ok(bytes) => match String::from_utf8(bytes) {
+ Ok(text) => Some(text),
+ Err(_) => {
+ eprintln!("hu13: forge: skipping {label}: not valid UTF-8");
+ None
+ }
+ },
+ Err(e) => {
+ eprintln!("hu13: forge: skipping {label}: cannot read: {e}");
+ None
+ }
+ }
+}
+
+/// Parse YAML frontmatter; on error warn (naming file via `label` plus the
+/// serde error, which names the field) and return `None` (skip the file).
+fn parse_frontmatter(yaml: &str, label: &str) -> Option
+where
+ T: for<'de> Deserialize<'de>,
+{
+ match yaml_serde::from_str(yaml) {
+ Ok(value) => Some(value),
+ Err(e) => {
+ eprintln!("hu13: forge: skipping {label}: {e}");
+ None
+ }
+ }
+}
+
+/// Sorted entry names (files or dirs) directly under `dir`.
+/// Missing dir yields an empty vec (no `.forge/` section yet — not a warning).
+fn sorted_entries(dir: &Path, only_dirs: bool) -> Vec {
+ let rd = match std::fs::read_dir(dir) {
+ Ok(rd) => rd,
+ Err(_) => return Vec::new(),
+ };
+ let mut names: Vec = Vec::new();
+ for ent in rd {
+ let ent = match ent {
+ Ok(ent) => ent,
+ Err(e) => {
+ eprintln!("hu13: forge: bad dir entry in {}: {e}", dir.display());
+ continue;
+ }
+ };
+ let is_dir = ent.file_type().map(|t| t.is_dir()).unwrap_or(false);
+ if is_dir != only_dirs {
+ continue;
+ }
+ names.push(ent.file_name().to_string_lossy().into_owned());
+ }
+ names.sort();
+ names
+}
+
+// ---------------------------------------------------------------------------
+// Typed frontmatter structs (unknown fields ignored by serde default;
+// `#[serde(default)]` marks every optional from schema §2)
+// ---------------------------------------------------------------------------
+
+#[derive(Debug, Clone, Deserialize)]
+struct IssueFm {
+ id: String,
+ title: String,
+ state: String,
+ #[serde(default)]
+ labels: Vec,
+ #[serde(default)]
+ assignees: Vec,
+ #[serde(default)]
+ milestone: Option,
+ created: String,
+ #[serde(default)]
+ updated: Option,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+struct CommentFm {
+ id: String,
+ created: String,
+ #[serde(default)]
+ author: Option,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+struct PrFm {
+ id: String,
+ title: String,
+ state: String,
+ base: String,
+ head: String,
+ created: String,
+ #[serde(default)]
+ draft: bool,
+ #[serde(default)]
+ issues: Vec,
+ #[serde(default)]
+ ci: Option,
+ #[serde(default)]
+ updated: Option,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+struct ReviewFm {
+ id: String,
+ created: String,
+ #[serde(default)]
+ author: Option,
+ #[serde(default)]
+ state: Option,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+struct TaskFm {
+ id: String,
+ title: String,
+ status: String,
+ created: String,
+ #[serde(default)]
+ assignee: Option,
+ #[serde(default)]
+ updated: Option,
+ #[serde(default)]
+ closed: Option,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+struct LabelFm {
+ name: String,
+ #[serde(default)]
+ color: Option,
+ #[serde(default)]
+ description: Option,
+}
+
+fn default_open() -> String {
+ "open".to_string()
+}
+
+#[derive(Debug, Clone, Deserialize)]
+struct MilestoneFm {
+ name: String,
+ #[serde(default = "default_open")]
+ state: String,
+ #[serde(default)]
+ due: Option,
+ #[serde(default)]
+ description: Option,
+}
+
+fn default_runner() -> String {
+ "local".to_string()
+}
+
+#[derive(Debug, Clone, Deserialize)]
+struct CiEnvFm {
+ #[serde(default = "default_runner")]
+ runner: String,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+struct CiRunFm {
+ run: String,
+ verdict: String,
+ created: String,
+ #[serde(default)]
+ steps: Vec,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+struct CiStepFm {
+ #[serde(default)]
+ name: String,
+ #[serde(default)]
+ exit: i32,
+ #[serde(default)]
+ duration_ms: u64,
+}
+
+// ---------------------------------------------------------------------------
+// Loaded entities (frontmatter + body + sidecars, numbers assigned at load)
+// ---------------------------------------------------------------------------
+
+/// One issue/PR comment.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Comment {
+ /// Comment uuid (filename stem).
+ pub id: String,
+ /// Optional author handle.
+ pub author: Option,
+ /// RFC3339 creation timestamp.
+ pub created: String,
+ /// Markdown body after the frontmatter.
+ pub body: String,
+}
+
+/// One issue with derived display number and loaded comments.
+#[derive(Debug, Clone)]
+pub struct Issue {
+ /// 1-based rank of `(created, id)` — display only, never stored.
+ pub number: usize,
+ /// Issue uuid (link key).
+ pub id: String,
+ pub title: String,
+ pub state: String,
+ pub labels: Vec,
+ pub assignees: Vec,
+ pub milestone: Option,
+ pub created: String,
+ pub updated: Option,
+ /// Markdown body after the frontmatter.
+ pub body: String,
+ /// Comments sorted by `(created, id)`.
+ pub comments: Vec,
+}
+
+/// One PR review (state + body).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Review {
+ /// Review uuid.
+ pub id: String,
+ /// Optional author handle.
+ pub author: Option,
+ /// Optional `approved|changes-requested|commented`.
+ pub state: Option,
+ /// RFC3339 creation timestamp.
+ pub created: String,
+ /// Markdown body after the frontmatter.
+ pub body: String,
+}
+
+/// One PR with derived display number, patch text, comments, and reviews.
+#[derive(Debug, Clone)]
+pub struct Pr {
+ /// 1-based rank of `(created, id)` — display only, never stored.
+ pub number: usize,
+ /// PR uuid (link key).
+ pub id: String,
+ pub title: String,
+ pub state: String,
+ pub base: String,
+ pub head: String,
+ pub draft: bool,
+ pub issues: Vec,
+ pub ci: Option,
+ pub created: String,
+ pub updated: Option,
+ /// Markdown body after the frontmatter.
+ pub body: String,
+ /// Raw `patch.diff` text when present.
+ pub patch: Option,
+ /// Comments sorted by `(created, id)`.
+ pub comments: Vec,
+ /// Reviews sorted by `(created, id)`.
+ pub reviews: Vec,
+}
+
+/// One task file (grouped by `status` at render).
+#[derive(Debug, Clone)]
+pub struct TaskItem {
+ /// Task uuid (filename stem).
+ pub id: String,
+ pub title: String,
+ /// `todo|doing|blocked|done`.
+ pub status: String,
+ pub created: String,
+ pub assignee: Option,
+ pub updated: Option,
+ pub closed: Option,
+ /// Markdown body after the frontmatter.
+ pub body: String,
+}
+
+/// One label entry from `labels.md`.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Label {
+ pub name: String,
+ pub color: Option,
+ pub description: Option,
+}
+
+/// One milestone entry from `milestones.md`.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Milestone {
+ pub name: String,
+ pub state: String,
+ pub due: Option,
+ pub description: Option,
+}
+
+/// One step result from a CI run summary.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct CiStepStatus {
+ pub name: String,
+ pub exit: i32,
+ pub duration_ms: u64,
+}
+
+/// One CI run (latest only is rendered).
+#[derive(Debug, Clone)]
+pub struct CiRun {
+ /// Run timestamp id (dir name).
+ pub run: String,
+ /// `pass|fail|running|cancelled`.
+ pub verdict: String,
+ pub created: String,
+ pub steps: Vec,
+ /// Summary body after the frontmatter (the run log).
+ pub body: String,
+ /// `(log filename, content)` for `runs//steps/*.log`, sorted.
+ pub logs: Vec<(String, String)>,
+}
+
+/// CI section: env runner, step script names, latest run.
+#[derive(Debug, Clone)]
+pub struct CiInfo {
+ /// `runner:` from `ci.env` (default `local`).
+ pub runner: String,
+ /// Sorted `steps/*.sh` filenames.
+ pub steps: Vec,
+ /// Latest `runs//summary.md` by lexicographic (== chronological) order.
+ pub latest: Option,
+}
+
+impl CiInfo {
+ /// Latest verdict, or `"none"` when no runs exist at build time.
+ #[must_use]
+ pub fn latest_verdict(&self) -> &str {
+ self.latest
+ .as_ref()
+ .map(|r| r.verdict.as_str())
+ .unwrap_or("none")
+ }
+}
+
+/// Everything the f0rge readers load (numbers already assigned).
+#[derive(Debug, Clone)]
+pub struct ForgeData {
+ /// Issues sorted by `(created, id)` with 1-based numbers.
+ pub issues: Vec,
+ /// PRs sorted by `(created, id)` with 1-based numbers.
+ pub prs: Vec,
+ /// Tasks sorted by `(created, id)`.
+ pub tasks: Vec,
+ /// Labels in file order.
+ pub labels: Vec