#8 m3/releases-api
Patch
src/agents.rs+143 −0--- /dev/null+++ b/src/agents.rs@@ -0,0 +1,143 @@1+//! Agent surface (m3, f0rge issue #8): `agents.html` + `agents.md`.2+//!3+//! Static instructions for agents: read via raw/api/tarball/bundle, submit4+//! via the two inbox POSTs. The markdown source is written verbatim to5+//! `dist/agents.md`; `dist/agents.html` is the same text rendered through6+//! [`crate::build::markdown_to_html`].7+//!8+//! The curl examples POST to the exact m4 route paths `/inbox/issue` and9+//! `/inbox/pr-prompt` (the m4 server implements those paths; keep the10+//! examples in sync). License text is quoted verbatim from `DRAFT.md`.11+//!12+//! SECURITY: all untrusted values (site name, clone URL, branch, sha) go13+//! through [`crate::layout::esc`]; the guide body is static text.14+15+use std::path::Path;16+17+use crate::build::markdown_to_html;18+use crate::layout::{esc, page};19+20+/// License grant quoted verbatim from `DRAFT.md` (receiver-friendly v1).21+pub const LICENSE_TEXT: &str = "By submitting this text you irrevocably give it to this project.\nYou keep no rights in it, you are owed nothing for it, and you will\nmake no claim over it — ownership, credit, payment, infringement,\nor otherwise — against the maintainer or anyone who uses it. The\nmaintainer may keep, share, change, or use it for any purpose, in\nany code or license, without notice or payment. If you cannot agree\nto that, do not submit.";22+23+fn agents_markdown(24+ site_name: &str,25+ clone_url: Option<&str>,26+ branch: &str,27+ short_sha: &str,28+) -> String {29+ let clone_line = match clone_url {30+ Some(u) => format!("Clone: `git clone {u}` (branch `{branch}` @ `{short_sha}`)."),31+ None => format!("No clone URL configured; branch `{branch}` @ `{short_sha}`."),32+ };33+ format!(34+ "# Agents guide — {site_name}\n\n\35+ You are an agent visiting a hu13 mirror of one repo. No accounts, no JS, no auth.\n\36+ Read with curl; suggest with two dumb text POSTs. A human (or their agent) triages later.\n\n\37+ ## Read\n\n\38+ - Site: `{site_name}`, {clone_line}\n\39+ - Raw file bytes: `/raw/<path>` (byte-identical to the repo).\n\40+ - JSON API (compact fixed-order, same data as HTML): `/api/issues.json`, `/api/pulls.json`, `/api/commits.json`, `/api/releases.json`, `/api/wiki.json`.\n\41+ - Snapshots: `/repo.tar.gz` (extract) + `/repo.bundle` (`git clone -b {branch} repo.bundle`). The bundle is authoritative when no clone URL is configured.\n\42+ - Releases + artifacts: `/releases/index.html`, `/releases/<ver>.html`, downloads under `/releases/<ver>/<file>`.\n\43+ - Feed: `/feed.atom` (up to 50 entries, commits + releases newest-first — poll this, not the HTML).\n\44+ - Human pages mirror the same data: `/commits.html`, `/commit/<sha>.html`, `/issues/index.html`, `/pulls/index.html`, `/tasks/index.html`, `/ci/index.html`, `/wiki/index.html`.\n\n\45+ ## Submit (the only write path)\n\n\46+ Exactly two text-only POSTs on the built-in server (static site has forms at `/new-issue.html` and `/new-pr.html`; both are free textboxes):\n\n\47+ - `POST /inbox/issue` — suggest an issue. Fields: `title` + `body`.\n\48+ - `POST /inbox/pr-prompt` — suggest a change. Fields: `title` + `prompt`.\n\n\49+ Both POSTs are `Content-Type: application/x-www-form-urlencoded` (urlencoded-only; `multipart/*` is rejected), UTF-8 only.\n\n\50+ | field | rule |\n\51+ | ----- | ---- |\n\52+ | `title` | required, plain text, <=300 chars |\n\53+ | `body` / `prompt` | required, plain text, <=20000 chars |\n\54+ | `handle` | optional, plain text, <=40 chars |\n\55+ | `contact` | optional, plain text, <=100 chars |\n\56+ | `license` | required checkbox — must be checked, else `400` |\n\57+ | honeypot | `honeypot` field must be left empty (filled = silently dropped, still says received) |\n\58+ | whole request | <=32KB else `413` |\n\n\59+ On success the server shows a `received` page with a one-time ack uuid (shown once, not in a URL, no lookup endpoint) and reflects nothing back (kills reflected XSS). No GET, no listing, no read-back. Inbox never appears in the site; accepted text shows up later as a real issue/PR.\n\n\60+ ### License (required — check the box only if you agree)\n\n\61+ > {license}\n\n\62+ Stored per submission: license version + checkbox state + body hash + timestamp. No submission without the box checked.\n\n\63+ ## Philosophy\n\n\64+ - Issues = detailed actionable text from your user. Write what a maintainer can act on: context, steps, expected vs actual, links to `/raw/` paths.\n\65+ - PRs = ONE prompt not code. Send a single plain-text prompt describing the change; never send files, diffs, branches, or bundles. Code words inside stay text — never applied, never executed.\n\66+ - Free textboxes: the forms are free text on purpose — visiting agents are smarter than any template. Keep it dumb, keep it text.\n\n\67+ ## Curl examples\n\n\68+ Suggest an issue:\n\n\69+ ```sh\n\70+ curl -X POST http://localhost:39313/inbox/issue \\\n\71+ --data-urlencode \"title=Crash on empty config\" \\\n\72+ --data-urlencode \"body=Steps to reproduce: ... (detailed actionable text from your user)\" \\\n\73+ --data-urlencode \"handle=my-bot\" \\\n\74+ --data-urlencode \"[email protected]\" \\\n\75+ --data-urlencode \"license=agree\" \\\n\76+ --data-urlencode \"honeypot=\"\n\77+ ```\n\n\78+ Suggest a PR prompt (ONE prompt, not code):\n\n\79+ ```sh\n\80+ curl -X POST http://localhost:39313/inbox/pr-prompt \\\n\81+ --data-urlencode \"title=Retry with backoff\" \\\n\82+ --data-urlencode \"prompt=In the uploader, retry failed puts with exponential backoff and ... (ONE prompt, not code)\" \\\n\83+ --data-urlencode \"handle=my-bot\" \\\n\84+ --data-urlencode \"[email protected]\" \\\n\85+ --data-urlencode \"license=agree\" \\\n\86+ --data-urlencode \"honeypot=\"\n\87+ ```\n\n\88+ Caps reminder: `title` <=300 chars, `body`/`prompt` <=20000 chars, whole request <=32KB. Rate ~2/hr per IP; be polite.\n",89+ site_name = site_name,90+ clone_line = clone_line,91+ branch = branch,92+ license = LICENSE_TEXT.replace('\n', "\n> "),93+ )94+}95+96+/// Render `agents.html` (rendered guide) + `agents.md` (raw markdown copy).97+pub fn render(98+ out: &Path,99+ site_name: &str,100+ nav: &str,101+ clone_url: Option<&str>,102+ branch: &str,103+ short_sha: &str,104+) -> Result<(), String> {105+ let site_e = esc(site_name);106+ let branch_e = esc(branch);107+ let sha_e = esc(short_sha);108+ let url_e = clone_url.map(esc);109+ let md = agents_markdown(&site_e, url_e.as_deref(), &branch_e, &sha_e);110+ // NOTE: site_name/branch/sha above are pre-escaped for the markdown111+ // source so the raw `agents.md` and rendered `agents.html` agree. The112+ // markdown renderer escapes raw HTML again, so `&` etc. stay safe.113+ let html_body = markdown_to_html(&md);114+ let html = page(&format!("agents — {site_name}"), nav, &html_body);115+ std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;116+ std::fs::write(out.join("agents.html"), html)117+ .map_err(|e| format!("hu13: write agents.html: {e}"))?;118+ std::fs::write(out.join("agents.md"), &md)119+ .map_err(|e| format!("hu13: write agents.md: {e}"))?;120+ Ok(())121+}122+123+#[cfg(test)]124+mod tests {125+ use super::*;126+127+ #[test]128+ fn curl_examples_hit_m4_routes() {129+ let md = agents_markdown("test", None, "main", "abc1234");130+ assert!(md.contains("/inbox/issue"), "issue POST path present");131+ assert!(132+ md.contains("/inbox/pr-prompt"),133+ "pr-prompt POST path present"134+ );135+ assert!(md.contains("application/x-www-form-urlencoded"));136+ assert!(md.contains("<=300"));137+ assert!(md.contains("<=20000"));138+ assert!(md.contains("<=32KB"));139+ assert!(md.contains("ONE prompt not code") || md.contains("ONE prompt"));140+ assert!(md.contains("detailed actionable text"));141+ assert!(md.contains("By submitting this text"));142+ }143+}
src/api.rs+170 −0--- /dev/null+++ b/src/api.rs@@ -0,0 +1,170 @@1+//! Machine JSON (m3, f0rge issue #8): `api/*.json` built from the SAME2+//! structs used for HTML (no divergent logic).3+//!4+//! [`render`] takes the already-loaded readers — [`crate::forge::ForgeData`]5+//! (issues/pulls/tasks/ci), the commit log slice from [`crate::gitx::log_n`]6+//! (the same vec rendered to `commits.html`), the release slice from7+//! [`crate::releases::load`], and the wiki pages from [`crate::wiki::render`]8+//! — and writes compact fixed-order JSON with `serde_json::to_string`9+//! (never pretty). Array lengths therefore equal the HTML list counts by10+//! construction; see `tests/releases_api.rs`.11+//!12+//! SECURITY: bodies are untrusted text carried as JSON strings (never HTML).13+14+use std::path::Path;15+16+use serde::Serialize;17+18+use crate::forge::ForgeData;19+use crate::gitx::Commit;20+use crate::releases::Release;21+use crate::wiki::WikiPage;22+23+/// One issue row (fixed field order).24+#[derive(Debug, Clone, Serialize)]25+pub struct IssueJson {26+ pub number: usize,27+ pub id: String,28+ pub title: String,29+ pub state: String,30+ pub labels: Vec<String>,31+ pub assignees: Vec<String>,32+ pub milestone: Option<String>,33+ pub created: String,34+ pub comments: usize,35+ pub body: String,36+}37+38+/// One pull row (fixed field order).39+#[derive(Debug, Clone, Serialize)]40+pub struct PullJson {41+ pub number: usize,42+ pub id: String,43+ pub title: String,44+ pub state: String,45+ pub base: String,46+ pub head: String,47+ pub draft: bool,48+ pub created: String,49+ pub comments: usize,50+ pub reviews: usize,51+ pub body: String,52+}53+54+/// One commit row (fixed field order).55+#[derive(Debug, Clone, Serialize)]56+pub struct CommitJson {57+ pub sha: String,58+ pub subject: String,59+ pub author: String,60+ pub date: String,61+}62+63+/// One release row (fixed field order).64+#[derive(Debug, Clone, Serialize)]65+pub struct ReleaseJson {66+ pub version: String,67+ pub title: Option<String>,68+ pub created: String,69+ pub sha: Option<String>,70+ pub artifacts: usize,71+ pub body: String,72+}73+74+/// One wiki row (fixed field order).75+#[derive(Debug, Clone, Serialize)]76+pub struct WikiJson {77+ pub path: String,78+ pub title: String,79+ pub size: u64,80+}81+82+fn write_json(out: &Path, rel: &str, value: &impl Serialize) -> Result<(), String> {83+ // Compact fixed-order JSON (serde struct order, no pretty printing).84+ let text = serde_json::to_string(value).map_err(|e| format!("hu13: encode {rel}: {e}"))?;85+ let dest = out.join(rel);86+ if let Some(parent) = dest.parent() {87+ std::fs::create_dir_all(parent).map_err(|e| format!("hu13: create api dir: {e}"))?;88+ }89+ std::fs::write(&dest, text).map_err(|e| format!("hu13: write {rel}: {e}"))90+}91+92+/// Build `api/issues.json`, `api/pulls.json`, `api/commits.json`,93+/// `api/releases.json`, and `api/wiki.json` from the same structs rendered94+/// to HTML. Callers must pass the exact slices/vecs used for the HTML pass.95+pub fn render(96+ out: &Path,97+ forge: &ForgeData,98+ commits: &[Commit],99+ releases: &[Release],100+ wiki: &[WikiPage],101+) -> Result<(), String> {102+ let issues: Vec<IssueJson> = forge103+ .issues104+ .iter()105+ .map(|i| IssueJson {106+ number: i.number,107+ id: i.id.clone(),108+ title: i.title.clone(),109+ state: i.state.clone(),110+ labels: i.labels.clone(),111+ assignees: i.assignees.clone(),112+ milestone: i.milestone.clone(),113+ created: i.created.clone(),114+ comments: i.comments.len(),115+ body: i.body.clone(),116+ })117+ .collect();118+ let pulls: Vec<PullJson> = forge119+ .prs120+ .iter()121+ .map(|p| PullJson {122+ number: p.number,123+ id: p.id.clone(),124+ title: p.title.clone(),125+ state: p.state.clone(),126+ base: p.base.clone(),127+ head: p.head.clone(),128+ draft: p.draft,129+ created: p.created.clone(),130+ comments: p.comments.len(),131+ reviews: p.reviews.len(),132+ body: p.body.clone(),133+ })134+ .collect();135+ let commits_json: Vec<CommitJson> = commits136+ .iter()137+ .map(|c| CommitJson {138+ sha: c.sha.clone(),139+ subject: c.subject.clone(),140+ author: c.author.clone(),141+ date: c.date.clone(),142+ })143+ .collect();144+ let releases_json: Vec<ReleaseJson> = releases145+ .iter()146+ .map(|r| ReleaseJson {147+ version: r.version.clone(),148+ title: r.title.clone(),149+ created: r.created.clone(),150+ sha: r.sha.clone(),151+ artifacts: r.artifacts.len(),152+ body: r.body.clone(),153+ })154+ .collect();155+ let wiki_json: Vec<WikiJson> = wiki156+ .iter()157+ .map(|w| WikiJson {158+ path: w.path.clone(),159+ title: w.title.clone(),160+ size: w.size,161+ })162+ .collect();163+164+ write_json(out, "api/issues.json", &issues)?;165+ write_json(out, "api/pulls.json", &pulls)?;166+ write_json(out, "api/commits.json", &commits_json)?;167+ write_json(out, "api/releases.json", &releases_json)?;168+ write_json(out, "api/wiki.json", &wiki_json)?;169+ Ok(())170+}
src/build.rs+26 −10--- a/src/build.rs+++ b/src/build.rs@@ -1,21 +1,27 @@1-//! `hu13 build` — static site (m1 code tab + m2 commits/wiki + m3 f0rge readers).1+//! `hu13 build` — static site (m1 code tab + m2 commits/wiki + m3 f0rge readers + releases/api/agents).22 //!33 //! Reads one branch via [`crate::gitx`], emits `dist/`:44 //! `index.html`, `files/<path>.html`, `raw/<path>` (byte-identical),55 //! `commits.html`, `commit/<sha>.html`, `feed.atom`, `issues/index.html`,66 //! `issues/<uuid>.html`, `pulls/index.html`, `pulls/<uuid>.html`,7-//! `tasks/index.html`, `ci/index.html`, `wiki/index.html`,8-//! `wiki/<path>.html`, `repo.bundle` + `repo.tar.gz`, and `style.css`.7+//! `tasks/index.html`, `ci/index.html`, `releases/index.html`,8+//! `releases/<ver>.html`, `releases/<ver>/<file>` artifacts,9+//! `wiki/index.html`, `wiki/<path>.html`, `api/*.json`, `agents.html` +10+//! `agents.md`, `repo.bundle` + `repo.tar.gz`, and `style.css`.911 //!1012 //! Bodies from the repo are semi-trusted but treated as untrusted:1113 //! markdown goes through [`markdown_to_html`], which escapes raw HTML;1214 //! commit messages and diffs are escaped `<pre>` (never markdown).15+//!16+//! Machine JSON (`api/*.json`) and HTML are built from one struct pass over17+//! the same loaded readers (no divergent logic): `forge::load`,18+//! `commits::load`, `releases::load`, `wiki::render`.1319 1420 use std::collections::BTreeMap;1521 use std::path::Path;1622 1723 use crate::layout::esc;18-use crate::{commits, feed, forge, gitx, wiki};24+use crate::{agents, api, commits, feed, forge, gitx, releases, wiki};1925 2026 /// Files at or above this size get a placeholder page + download link.2127 pub const PREVIEW_MAX_BYTES: usize = 256 * 1024;@@ -155,7 +161,9 @@ fn nav_bar(issue_count: usize, pr_count: usize) -> String {155161 <a href=\"/pulls/index.html\">pulls ({pr_count})</a> · \156162 <a href=\"/tasks/index.html\">tasks</a> · \157163 <a href=\"/ci/index.html\">ci</a> · \158- <a href=\"/wiki/index.html\">wiki</a>"164+ <a href=\"/releases/index.html\">releases</a> · \165+ <a href=\"/wiki/index.html\">wiki</a> · \166+ <a href=\"/agents.html\">agents</a>"159167 )160168 }161169 @@ -349,12 +357,18 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu349357 std::fs::write(&dest, html).map_err(|e| format!("hu13: write files page: {e}"))?;350358 }351359 352- // Commits + wiki (m2): untrusted messages/diffs/bodies escaped inside.353- commits::render(repo, out, &branch, opts.max_log, &name, &nav)?;354- feed::render(repo, out, &branch, &name)?;355- wiki::render(repo, out, &name, &nav)?;360+ // Commits + feed + wiki + f0rge + releases + api + agents (m2/m3):361+ // one struct pass — the same loaded vecs feed both HTML and JSON.362+ let commit_log = commits::load(repo, &branch, opts.max_log)?;363+ commits::render_loaded(repo, out, &commit_log, &name, &nav)?;364+ feed::render_loaded(repo, out, &branch, &name, &commit_log)?;365+ let wiki_pages = wiki::render(repo, out, &name, &nav)?;356366 // f0rge pages (m3, issue #7): issues/pulls/tasks/ci from loaded data.357367 forge::render_loaded(&forge_data, out, &name, &nav)?;368+ let release_data = releases::load(repo);369+ releases::render_loaded(&release_data, repo, out, &name, &nav)?;370+ api::render(out, &forge_data, &commit_log, &release_data, &wiki_pages)?;371+ agents::render(out, &name, &nav, clone_url.as_deref(), &branch, &short_sha)?;358372 359373 // Snapshot last so the index can link sizes.360374 let snap = gitx::snapshot(repo, &branch, out).map_err(|e| format!("hu13: {e}"))?;@@ -385,10 +399,12 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu385399 body.push_str(&format!(386400 "<p><a href=\"/issues/index.html\">issues ({})</a> · \387401 <a href=\"/pulls/index.html\">pulls ({})</a> · \388- <a href=\"/tasks/index.html\">tasks ({})</a></p>\n",402+ <a href=\"/tasks/index.html\">tasks ({})</a> · \403+ <a href=\"/releases/index.html\">releases ({})</a></p>\n",389404 forge_data.issues.len(),390405 forge_data.prs.len(),391406 forge_data.tasks.len(),407+ release_data.len(),392408 ));393409 body.push_str(&format!(394410 "<p>CI: {}</p>\n",
src/commits.rs+29 −11--- a/src/commits.rs+++ b/src/commits.rs@@ -16,26 +16,27 @@ pub fn short_sha(sha: &str) -> &str {1616 sha.get(..7).unwrap_or(sha)1717 }1818 19-/// Render `commits.html` plus one `commit/<sha>.html` per listed commit.20-///21-/// Lists at most `max_log` newest-first commits (see `--max-log`).22-/// Returns the number of commits listed.23-pub fn render(19+/// Load up to `max_log` newest-first commits (the same vec rendered to20+/// `commits.html` and `api/commits.json` — one struct pass, no divergent logic).21+pub fn load(repo: &Path, branch: &str, max_log: usize) -> Result<Vec<gitx::Commit>, String> {22+ gitx::log_n(repo, branch, max_log).map_err(|e| format!("hu13: {e}"))23+}24+25+/// Render `commits.html` plus one `commit/<sha>.html` per commit from an26+/// already-loaded log (see [`load`]).27+pub fn render_loaded(2428 repo: &Path,2529 out: &Path,26- branch: &str,27- max_log: usize,30+ log: &[gitx::Commit],2831 site_name: &str,2932 nav: &str,3033 ) -> Result<usize, String> {31- let log = gitx::log_n(repo, branch, max_log).map_err(|e| format!("hu13: {e}"))?;32-3334 let mut body = String::from("<h1>Commits</h1>\n");3435 if log.is_empty() {3536 body.push_str("<p class=\"muted\">No commits yet.</p>\n");3637 } else {3738 body.push_str("<table>\n<thead><tr><th>sha</th><th>subject</th><th>author</th><th>date</th></tr></thead>\n<tbody>\n");38- for c in &log {39+ for c in log {3940 body.push_str(&format!(4041 "<tr><td><a href=\"/commit/{}.html\"><code>{}</code></a></td><td>{}</td><td>{}</td><td>{}</td></tr>\n",4142 esc(&c.sha),@@ -54,7 +55,7 @@ pub fn render(5455 5556 std::fs::create_dir_all(out.join("commit"))5657 .map_err(|e| format!("hu13: create commit dir: {e}"))?;57- for c in &log {58+ for c in log {5859 let message = gitx::message(repo, &c.sha).map_err(|e| format!("hu13: {e}"))?;5960 let diff = gitx::diff(repo, &c.sha).map_err(|e| format!("hu13: {e}"))?;6061 let detail = format!(@@ -77,6 +78,23 @@ pub fn render(7778 Ok(log.len())7879 }7980 81+/// Render `commits.html` plus details, loading the log itself.82+///83+/// Wrapper around [`load`] + [`render_loaded`] for tests and simple callers.84+/// Lists at most `max_log` newest-first commits (see `--max-log`).85+#[allow(dead_code)]86+pub fn render(87+ repo: &Path,88+ out: &Path,89+ branch: &str,90+ max_log: usize,91+ site_name: &str,92+ nav: &str,93+) -> Result<usize, String> {94+ let log = load(repo, branch, max_log)?;95+ render_loaded(repo, out, &log, site_name, nav)96+}97+8098 #[cfg(test)]8199 mod tests {82100 use super::*;
src/feed.rs+82 −51--- a/src/feed.rs+++ b/src/feed.rs@@ -1,30 +1,21 @@1-//! `feed.atom` (m2, f0rge issue #6): 50 entries max, commits +1+//! `feed.atom` (m2/m3, f0rge issues #6 + #8): 50 entries max, commits +22 //! release notes mixed newest-first, written at build time.33 //!44 //! No rss/atom crate (std only): hand-rolled XML writer with [`esc`].55 //! Each entry carries title, `urn:` id, RFC3339 `updated`, link, and an66 //! escaped summary.7-//!8-//! Releases: no `src/releases.rs` reader exists yet, so this is9-//! commits-only for now.10-//! TODO(m3): mix in `.forge/releases/<version>.md` notes newest-first11-//! once the m3 releases reader lands (see [`RELEASES_SUPPORTED`] +12-//! [`read_release_entries`]).137 148 use std::path::Path;159 1610 use crate::gitx;11+use crate::releases;1712 1813 /// Max entries in `feed.atom` (DRAFT: 50, commits + releases mixed).1914 pub const MAX_ENTRIES: usize = 50;2015 21-/// Test hook for m3: `false` while no release reader exists in `src/`.22-///23-/// When `src/releases.rs` (or equivalent) lands, set this to `true` and24-/// implement [`read_release_entries`]; the `releases_required_when_present`25-/// test flips from "commits-only tolerated" to "release entries required".26-// TODO(m3): set to true once the releases reader exists.27-pub const RELEASES_SUPPORTED: bool = false;16+/// Releases are wired in (m3 releases reader): `true`.17+#[allow(dead_code)]18+pub const RELEASES_SUPPORTED: bool = true;2819 2920 /// One Atom entry (commit or release note).3021 #[derive(Debug, Clone, PartialEq, Eq)]@@ -35,7 +26,7 @@ pub struct FeedEntry {3526 pub id: String,3627 /// RFC3339 timestamp (converted from git iso date / release `created`).3728 pub updated: String,38- /// Site-relative link (`/commit/<sha>.html`, `/releases/...` later).29+ /// Site-relative link (`/commit/<sha>.html`, `/releases/<ver>.html`).3930 pub link: String,4031 /// Escaped one-line summary.4132 pub summary: String,@@ -80,21 +71,51 @@ pub fn to_rfc3339(iso: &str) -> String {8071 s.to_string()8172 }8273 83-/// Read release-note entries for the feed.84-///85-/// Commits-only stub: no release reader exists in `src/` yet, so this86-/// returns an empty vec.87-// TODO(m3): read `.forge/releases/<version>.md` notes (title/body) here88-// and return one `FeedEntry` per release (`urn:release:<version>`).74+/// Read release-note entries for the feed from the same reader used for75+/// HTML ([`releases::load`], no divergent logic): one entry per release76+/// (`urn:release:<version>`, link `/releases/<ver>.html`).8977 #[must_use]90-pub fn read_release_entries(_repo: &Path) -> Vec<FeedEntry> {91- // TODO(m3): releases not yet wired; commits-only feed.92- Vec::new()78+pub fn read_release_entries(repo: &Path) -> Vec<FeedEntry> {79+ releases::load(repo)80+ .into_iter()81+ .map(|r| {82+ let first_line = r83+ .body84+ .lines()85+ .map(str::trim)86+ .find(|l| !l.is_empty())87+ .unwrap_or("")88+ .to_string();89+ let summary = if first_line.is_empty() {90+ format!("release {} ({})", r.version, r.created)91+ } else {92+ let mut s: String = first_line.chars().take(200).collect();93+ if s.len() > 200 {94+ s.truncate(200);95+ }96+ s97+ };98+ FeedEntry {99+ title: r.display_title().to_string(),100+ id: format!("urn:release:{}", r.version),101+ updated: r.created.clone(),102+ link: format!("/releases/{}.html", r.version),103+ summary,104+ }105+ })106+ .collect()93107 }94108 95109 /// Collect up to [`MAX_ENTRIES`] entries, commits + releases newest-first.110+#[allow(dead_code)]96111 pub fn collect_entries(repo: &Path, branch: &str) -> Result<Vec<FeedEntry>, String> {97112 let log = gitx::log_n(repo, branch, MAX_ENTRIES).map_err(|e| format!("hu13: {e}"))?;113+ collect_entries_loaded(repo, &log)114+}115+116+/// Collect entries from an already-loaded commit log plus releases from117+/// [`read_release_entries`] (one struct pass with the HTML readers).118+pub fn collect_entries_loaded(repo: &Path, log: &[gitx::Commit]) -> Result<Vec<FeedEntry>, String> {98119 let mut entries: Vec<FeedEntry> = log99120 .iter()100121 .map(|c| {@@ -108,9 +129,8 @@ pub fn collect_entries(repo: &Path, branch: &str) -> Result<Vec<FeedEntry>, Stri108129 }109130 })110131 .collect();111- // TODO(m3): releases mixed in newest-first once the reader lands.112- let mut releases = read_release_entries(repo);113- entries.append(&mut releases);132+ let mut rels = read_release_entries(repo);133+ entries.append(&mut rels);114134 // Newest-first by RFC3339 `updated` (lexicographic == chronological).115135 entries.sort_by(|a, b| b.updated.cmp(&a.updated));116136 entries.truncate(MAX_ENTRIES);@@ -129,9 +149,6 @@ pub fn render_feed(site_name: &str, branch: &str, entries: &[FeedEntry]) -> Stri129149 out.push_str(&format!("<id>urn:hu13:feed:{}</id>\n", esc(branch)));130150 out.push_str(&format!("<updated>{updated}</updated>\n"));131151 out.push_str("<author><name>hu13</name></author>\n");132- if !RELEASES_SUPPORTED {133- out.push_str("<!-- TODO(m3): releases not yet wired; commits-only feed -->\n");134- }135152 for e in entries {136153 out.push_str("<entry>\n");137154 out.push_str(&format!("<title>{}</title>\n", esc(&e.title)));@@ -147,9 +164,31 @@ pub fn render_feed(site_name: &str, branch: &str, entries: &[FeedEntry]) -> Stri147164 148165 /// Build `feed.atom` in `out` (called from [`crate::build::run_build`]149166 /// right after commits). Returns the number of entries written.167+#[allow(dead_code)]150168 pub fn render(repo: &Path, out: &Path, branch: &str, site_name: &str) -> Result<usize, String> {151169 let entries = collect_entries(repo, branch)?;152- let doc = render_feed(site_name, branch, &entries);170+ render_entries(out, site_name, branch, &entries)171+}172+173+/// Build `feed.atom` from already-loaded commits (one struct pass).174+pub fn render_loaded(175+ repo: &Path,176+ out: &Path,177+ branch: &str,178+ site_name: &str,179+ log: &[gitx::Commit],180+) -> Result<usize, String> {181+ let entries = collect_entries_loaded(repo, log)?;182+ render_entries(out, site_name, branch, &entries)183+}184+185+fn render_entries(186+ out: &Path,187+ site_name: &str,188+ branch: &str,189+ entries: &[FeedEntry],190+) -> Result<usize, String> {191+ let doc = render_feed(site_name, branch, entries);153192 std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;154193 std::fs::write(out.join("feed.atom"), doc)155194 .map_err(|e| format!("hu13: write feed.atom: {e}"))?;@@ -222,7 +261,7 @@ mod tests {222261 223262 /// Minimal well-formedness check: angle-balance plus a tiny tag-stack224263 /// parse over the known Atom shape (feed/entry/title/id/updated/link/225- /// summary/author/name + xml prolog + TODO comment).264+ /// summary/author/name + xml prolog).226265 fn check_well_formed(xml: &str) {227266 let opens = xml.bytes().filter(|b| *b == b'<').count();228267 let closes = xml.bytes().filter(|b| *b == b'>').count();@@ -341,9 +380,8 @@ mod tests {341380 342381 #[test]343382 fn releases_required_when_present() {344- // Fixture WITH a releases dir: tolerates commits-only now345- // (RELEASES_SUPPORTED == false), but REQUIRES release entries346- // once the m3 reader lands (flag flips to true).383+ // Fixture WITH a releases dir: release entries are REQUIRED now384+ // that the m3 reader has landed.347385 let repo = fixture_repo();348386 std::fs::create_dir_all(repo.join(".forge").join("releases")).unwrap();349387 std::fs::write(@@ -354,22 +392,15 @@ mod tests {354392 let entries = collect_entries(&repo, "main").expect("collect");355393 let xml = render_feed("test", "main", &entries);356394 check_well_formed(&xml);357- if RELEASES_SUPPORTED {358- assert!(359- xml.contains("v1.0"),360- "release entries required when releases/ dir exists (m3 landed)"361- );362- assert!(363- xml.contains("urn:release:"),364- "release urn ids required once m3 lands"365- );366- } else {367- // TODO(m3): commits-only tolerated until the releases reader lands.368- assert!(369- xml.contains("TODO(m3)"),370- "commits-only marker while releases unwired"371- );372- }395+ assert!(396+ xml.contains("v1.0"),397+ "release entries required when releases/ dir exists"398+ );399+ assert!(xml.contains("urn:release:"), "release urn ids required");400+ assert!(401+ !xml.contains("TODO(m3)"),402+ "no m3 TODO marker left in feed output"403+ );373404 }374405 375406 #[test]
src/main.rs+6 −0--- a/src/main.rs+++ b/src/main.rs@@ -1,9 +1,12 @@1+mod agents;2+mod api;13 mod build;24 mod commits;35 mod feed;46 mod forge;57 mod gitx;68 mod layout;9+mod releases;710 mod wiki;811 912 const VERSION: &str = env!("CARGO_PKG_VERSION");@@ -204,6 +207,9 @@ fn run_serve(args: &[String]) -> i32 {204207 print_serve_help();205208 return 0;206209 }210+ // TODO(m4): serve `dist/` with `X-Content-Type-Options: nosniff` on every211+ // response (static files + inbox POSTs); never execute or list beyond212+ // what the build emitted. Stub for now — prints help and exits 0.207213 print_serve_help();208214 0209215 }
src/releases.rs+464 −0--- /dev/null+++ b/src/releases.rs@@ -0,0 +1,464 @@1+//! Releases (m3, f0rge issue #8): `.forge/releases/<ver>.md` notes +2+//! optional `releases/out/<ver>/` artifacts.3+//!4+//! Source is the on-disk `<repo>/.forge/releases/` tree (NOT git objects):5+//! `<version>.md` files carry frontmatter (`version`, `created` required;6+//! `title`, `sha` optional) plus a markdown notes body. `build/` holds build7+//! scripts (never rendered); `out/<ver>/` holds built artifacts (gitignored,8+//! usually absent — fine).9+//!10+//! Pages: `releases/index.html` + `releases/<ver>.html`. When11+//! `releases/out/<ver>/` exists at build time the WHOLE dir is copied to12+//! `dist/releases/<ver>/` (no size cap — the maintainer shipped it, hu1313+//! serves it) and linked with sizes plus any sha256 lines found in the14+//! notes. When absent the page is notes-only with no warning.15+//!16+//! SECURITY: every byte from `.forge/` is untrusted. Notes bodies go through17+//! [`crate::build::markdown_to_html`] (raw HTML escaped); versions, titles,18+//! shas, filenames, and sha256 lines go through [`crate::layout::esc`].19+20+use std::path::{Path, PathBuf};21+22+use serde::Deserialize;23+24+use crate::build::markdown_to_html;25+use crate::layout::{esc, page};26+27+/// `.forge/` files larger than this are skipped (schema SEC-01: 256 KiB).28+pub const RELEASE_MAX_BYTES: u64 = 256 * 1024;29+30+#[derive(Debug, Clone, Deserialize)]31+struct ReleaseFm {32+ version: String,33+ created: String,34+ #[serde(default)]35+ title: Option<String>,36+ #[serde(default)]37+ sha: Option<String>,38+}39+40+/// One file inside `out/<ver>/` (relative path + byte size).41+#[derive(Debug, Clone, PartialEq, Eq)]42+pub struct Artifact {43+ /// `/`-separated path relative to `out/<ver>/`.44+ pub path: String,45+ /// File size in bytes.46+ pub size: u64,47+}48+49+/// One release record: notes + artifacts found at build time.50+#[derive(Debug, Clone)]51+pub struct Release {52+ /// Version string (from frontmatter `version`).53+ pub version: String,54+ /// Optional title.55+ pub title: Option<String>,56+ /// RFC3339 creation timestamp.57+ pub created: String,58+ /// Optional commit sha recorded at create time.59+ pub sha: Option<String>,60+ /// Markdown notes body after the frontmatter.61+ pub body: String,62+ /// Artifacts present in `out/<ver>/` at build (sorted by path).63+ pub artifacts: Vec<Artifact>,64+}65+66+impl Release {67+ /// Display title: `title` when non-empty, else `version`.68+ #[must_use]69+ pub fn display_title(&self) -> &str {70+ match &self.title {71+ Some(t) if !t.trim().is_empty() => t,72+ _ => &self.version,73+ }74+ }75+}76+77+fn split_frontmatter(text: &str) -> Option<(&str, &str)> {78+ let rest = text.strip_prefix("---")?;79+ let rest = rest80+ .strip_prefix('\n')81+ .or_else(|| rest.strip_prefix("\r\n"))?;82+ let mut offset = 0;83+ for line in rest.split_inclusive('\n') {84+ let trimmed = line.trim_end_matches(['\n', '\r']);85+ if trimmed == "---" {86+ let yaml = &rest[..offset];87+ let body = &rest[offset + line.len()..];88+ return Some((yaml, body));89+ }90+ offset += line.len();91+ }92+ None93+}94+95+fn read_text_capped(path: &Path, label: &str) -> Option<String> {96+ let len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);97+ if len > RELEASE_MAX_BYTES {98+ eprintln!("hu13: releases: skipping {label}: over 256 KiB ({len} bytes)");99+ return None;100+ }101+ match std::fs::read(path) {102+ Ok(bytes) => match String::from_utf8(bytes) {103+ Ok(text) => Some(text),104+ Err(_) => {105+ eprintln!("hu13: releases: skipping {label}: not valid UTF-8");106+ None107+ }108+ },109+ Err(e) => {110+ eprintln!("hu13: releases: skipping {label}: cannot read: {e}");111+ None112+ }113+ }114+}115+116+/// Collect every file under `dir` as `(relative /-separated path, size)`,117+/// sorted by path. No size cap: the maintainer shipped it, hu13 serves it.118+fn collect_artifacts(dir: &Path) -> Vec<Artifact> {119+ let mut out = Vec::new();120+ let mut stack: Vec<PathBuf> = vec![PathBuf::new()];121+ while let Some(rel) = stack.pop() {122+ let abs = dir.join(&rel);123+ let rd = match std::fs::read_dir(&abs) {124+ Ok(rd) => rd,125+ Err(e) => {126+ eprintln!("hu13: releases: cannot list {}: {e}", abs.display());127+ continue;128+ }129+ };130+ for ent in rd {131+ let ent = match ent {132+ Ok(ent) => ent,133+ Err(e) => {134+ eprintln!("hu13: releases: bad dir entry: {e}");135+ continue;136+ }137+ };138+ let name = ent.file_name().to_string_lossy().into_owned();139+ let rel_child = if rel.as_os_str().is_empty() {140+ PathBuf::from(&name)141+ } else {142+ rel.join(&name)143+ };144+ let ft = match ent.file_type() {145+ Ok(ft) => ft,146+ Err(_) => continue,147+ };148+ if ft.is_symlink() {149+ continue;150+ }151+ if ft.is_dir() {152+ stack.push(rel_child);153+ } else if ft.is_file() {154+ let size = std::fs::metadata(ent.path()).map(|m| m.len()).unwrap_or(0);155+ let rel_str = rel_child156+ .components()157+ .map(|c| c.as_os_str().to_string_lossy().into_owned())158+ .collect::<Vec<_>>()159+ .join("/");160+ out.push(Artifact {161+ path: rel_str,162+ size,163+ });164+ }165+ }166+ }167+ out.sort_by(|a, b| a.path.cmp(&b.path));168+ out169+}170+171+/// Copy the WHOLE `src_dir` tree to `dest_dir` (no size cap).172+fn copy_tree(src_dir: &Path, dest_dir: &Path) -> Result<(), String> {173+ let mut stack: Vec<PathBuf> = vec![PathBuf::new()];174+ while let Some(rel) = stack.pop() {175+ let src = src_dir.join(&rel);176+ let dest = dest_dir.join(&rel);177+ let rd = match std::fs::read_dir(&src) {178+ Ok(rd) => rd,179+ Err(e) => {180+ return Err(format!("hu13: copy releases dir {}: {e}", src.display()));181+ }182+ };183+ std::fs::create_dir_all(&dest)184+ .map_err(|e| format!("hu13: create releases dir {}: {e}", dest.display()))?;185+ for ent in rd {186+ let ent = match ent {187+ Ok(ent) => ent,188+ Err(e) => return Err(format!("hu13: list releases dir: {e}")),189+ };190+ let name = ent.file_name();191+ let rel_child = if rel.as_os_str().is_empty() {192+ PathBuf::from(&name)193+ } else {194+ rel.join(&name)195+ };196+ let ft = ent197+ .file_type()198+ .map_err(|e| format!("hu13: stat releases file: {e}"))?;199+ if ft.is_symlink() {200+ continue;201+ }202+ if ft.is_dir() {203+ stack.push(rel_child);204+ } else if ft.is_file() {205+ let bytes =206+ std::fs::read(ent.path()).map_err(|e| format!("hu13: read artifact: {e}"))?;207+ std::fs::write(dest_dir.join(&rel_child), &bytes)208+ .map_err(|e| format!("hu13: write artifact: {e}"))?;209+ }210+ }211+ }212+ Ok(())213+}214+215+/// Load every release from on-disk `<repo>/.forge/releases/*.md`.216+/// Never fails: bad files are skipped with warnings; a missing dir yields217+/// an empty vec. Order is `(created, version)`.218+#[must_use]219+pub fn load(repo: &Path) -> Vec<Release> {220+ let root = repo.join(".forge").join("releases");221+ let rd = match std::fs::read_dir(&root) {222+ Ok(rd) => rd,223+ Err(_) => return Vec::new(),224+ };225+ let mut names: Vec<String> = Vec::new();226+ for ent in rd {227+ let ent = match ent {228+ Ok(ent) => ent,229+ Err(e) => {230+ eprintln!("hu13: releases: bad dir entry: {e}");231+ continue;232+ }233+ };234+ let Ok(ft) = ent.file_type() else {235+ continue;236+ };237+ if ft.is_symlink() || !ft.is_file() {238+ continue;239+ }240+ let name = ent.file_name().to_string_lossy().into_owned();241+ if name.ends_with(".md") {242+ names.push(name);243+ }244+ }245+ names.sort();246+247+ let mut out = Vec::new();248+ for name in names {249+ let label = format!("releases/{name}");250+ let Some(text) = read_text_capped(&root.join(&name), &label) else {251+ continue;252+ };253+ let Some((yaml, body)) = split_frontmatter(&text) else {254+ eprintln!("hu13: releases: skipping {label}: missing frontmatter");255+ continue;256+ };257+ let fm: ReleaseFm = match yaml_serde::from_str(yaml) {258+ Ok(fm) => fm,259+ Err(e) => {260+ eprintln!("hu13: releases: skipping {label}: {e}");261+ continue;262+ }263+ };264+ // Artifacts: present only when `out/<version>/` exists at build.265+ // Absent -> notes-only, no warning.266+ let artifacts = if fm.version.contains('/')267+ || fm.version.contains('\\')268+ || fm.version.split('/').any(|p| p == "..")269+ {270+ Vec::new()271+ } else {272+ let out_dir = root.join("out").join(&fm.version);273+ if out_dir.is_dir() {274+ collect_artifacts(&out_dir)275+ } else {276+ Vec::new()277+ }278+ };279+ out.push(Release {280+ version: fm.version,281+ title: fm.title.filter(|t| !t.trim().is_empty()),282+ created: fm.created,283+ sha: fm.sha.filter(|s| !s.trim().is_empty()),284+ body: body.to_string(),285+ artifacts,286+ });287+ }288+ out.sort_by(|a, b| {289+ (a.created.clone(), a.version.clone()).cmp(&(b.created.clone(), b.version.clone()))290+ });291+ out292+}293+294+/// Lines in `body` that look like sha256 digest lines: contain 64 hex295+/// chars. Returned trimmed, in body order.296+#[must_use]297+pub fn sha256_lines(body: &str) -> Vec<String> {298+ body.lines()299+ .filter(|line| {300+ let lower = line.trim().to_lowercase();301+ let mut run = 0usize;302+ let mut best = 0usize;303+ for ch in lower.chars() {304+ if ch.is_ascii_hexdigit() {305+ run += 1;306+ best = best.max(run);307+ } else {308+ run = 0;309+ }310+ }311+ best >= 64312+ })313+ .map(|l| l.trim().to_string())314+ .collect()315+}316+317+fn artifact_href(version: &str, path: &str) -> String {318+ format!("/releases/{}/{path}", esc(version))319+}320+321+fn render_release_page(rel: &Release, site_name: &str, nav: &str) -> String {322+ let mut body = format!("<h1>{}</h1>\n", esc(rel.display_title()));323+ body.push_str(&format!(324+ "<p><span class=\"muted\">version:</span> <code>{}</code></p>\n",325+ esc(&rel.version)326+ ));327+ body.push_str(&format!(328+ "<p><span class=\"muted\">created:</span> {}</p>\n",329+ esc(&rel.created)330+ ));331+ if let Some(sha) = &rel.sha {332+ body.push_str(&format!(333+ "<p><span class=\"muted\">sha:</span> <code>{}</code></p>\n",334+ esc(sha)335+ ));336+ }337+ body.push_str(&markdown_to_html(&rel.body));338+ if rel.artifacts.is_empty() {339+ body.push_str("<p class=\"muted\">No artifacts attached.</p>\n");340+ } else {341+ body.push_str("<h2>Artifacts</h2>\n<ul class=\"artifacts\">\n");342+ for a in &rel.artifacts {343+ body.push_str(&format!(344+ "<li><a href=\"{}\">{}</a> ({} bytes)</li>\n",345+ artifact_href(&rel.version, &a.path),346+ esc(&a.path),347+ a.size,348+ ));349+ }350+ body.push_str("</ul>\n");351+ let hashes = sha256_lines(&rel.body);352+ if !hashes.is_empty() {353+ body.push_str("<h2>Checksums</h2>\n<pre class=\"log\">");354+ for h in &hashes {355+ body.push_str(&esc(h));356+ body.push('\n');357+ }358+ body.push_str("</pre>\n");359+ }360+ }361+ body.push_str("<p><a href=\"/releases/index.html\">all releases</a></p>\n");362+ page(363+ &format!("{} — releases — {site_name}", rel.display_title()),364+ nav,365+ &body,366+ )367+}368+369+/// Render `releases/index.html` + `releases/<ver>.html` from already-loaded370+/// data, copying each present `out/<ver>/` tree to `dist/releases/<ver>/`.371+pub fn render_loaded(372+ releases: &[Release],373+ repo: &Path,374+ out: &Path,375+ site_name: &str,376+ nav: &str,377+) -> Result<(), String> {378+ let root = repo.join(".forge").join("releases");379+ let mut body = String::from("<h1>Releases</h1>\n");380+ if releases.is_empty() {381+ body.push_str("<p class=\"muted\">No releases yet.</p>\n");382+ } else {383+ body.push_str("<table>\n<thead><tr><th>version</th><th>title</th><th>created</th><th>artifacts</th></tr></thead>\n<tbody>\n");384+ for rel in releases {385+ body.push_str(&format!(386+ "<tr><td><a href=\"/releases/{v}.html\">{ve}</a></td><td>{t}</td><td>{c}</td><td>{n}</td></tr>\n",387+ v = esc(&rel.version),388+ ve = esc(&rel.version),389+ t = esc(rel.display_title()),390+ c = esc(&rel.created),391+ n = rel.artifacts.len(),392+ ));393+ }394+ body.push_str("</tbody>\n</table>\n");395+ }396+ let index = page(&format!("releases — {site_name}"), nav, &body);397+ let dest = out.join("releases").join("index.html");398+ if let Some(parent) = dest.parent() {399+ std::fs::create_dir_all(parent).map_err(|e| format!("hu13: create releases dir: {e}"))?;400+ }401+ std::fs::write(&dest, index).map_err(|e| format!("hu13: write releases index: {e}"))?;402+403+ for rel in releases {404+ // Copy the whole out dir when present (no size cap).405+ if !rel.version.contains('/') && !rel.version.contains('\\') {406+ let src_dir = root.join("out").join(&rel.version);407+ if src_dir.is_dir() {408+ let dest_dir = out.join("releases").join(&rel.version);409+ copy_tree(&src_dir, &dest_dir)?;410+ }411+ }412+ let html = render_release_page(rel, site_name, nav);413+ let page_dest = out.join("releases").join(format!("{}.html", rel.version));414+ if let Some(parent) = page_dest.parent() {415+ std::fs::create_dir_all(parent)416+ .map_err(|e| format!("hu13: create releases parent: {e}"))?;417+ }418+ std::fs::write(&page_dest, html)419+ .map_err(|e| format!("hu13: write release page {}: {e}", rel.version))?;420+ }421+ Ok(())422+}423+424+#[cfg(test)]425+mod tests {426+ use super::*;427+428+ #[test]429+ fn sha256_line_detection() {430+ let digest = "a".repeat(64);431+ let body = format!("artifact:\n{digest} app.tar.gz\nplain line\n");432+ let lines = sha256_lines(&body);433+ assert_eq!(lines.len(), 1);434+ assert!(lines[0].contains("app.tar.gz"));435+ assert!(sha256_lines("no hashes here\n").is_empty());436+ }437+438+ #[test]439+ fn display_title_falls_back_to_version() {440+ let r = Release {441+ version: "v1.0".to_string(),442+ title: None,443+ created: "2026-01-01T00:00:00Z".to_string(),444+ sha: None,445+ body: String::new(),446+ artifacts: Vec::new(),447+ };448+ assert_eq!(r.display_title(), "v1.0");449+ }450+451+ #[test]452+ fn missing_dir_is_empty() {453+ let dir = std::env::temp_dir().join(format!(454+ "hu13-rel-empty-{}-{}",455+ std::process::id(),456+ std::time::SystemTime::now()457+ .duration_since(std::time::UNIX_EPOCH)458+ .map(|d| d.as_nanos())459+ .unwrap_or(0)460+ ));461+ std::fs::create_dir_all(&dir).unwrap();462+ assert!(load(&dir).is_empty());463+ }464+}
src/wiki.rs+8 −1--- a/src/wiki.rs+++ b/src/wiki.rs@@ -27,6 +27,8 @@ pub struct WikiPage {2727 pub path: String,2828 /// First `# H1` else the filename.2929 pub title: String,30+ /// Byte size of the source file.31+ pub size: u64,3032 }3133 3234 fn is_h1_space(c: char) -> bool {@@ -168,8 +170,13 @@ pub fn render(168170 };169171 let filename = rel.rsplit('/').next().unwrap_or(&rel);170172 let title = page_title(&text, filename);173+ let size = text.len() as u64;171174 bodies.insert(rel.clone(), text);172- pages.push(WikiPage { path: rel, title });175+ pages.push(WikiPage {176+ path: rel,177+ title,178+ size,179+ });173180 }174181 175182 let wiki_dir = out.join("wiki");
tests/releases_api.rs+239 −1--- /dev/null+++ b/tests/releases_api.rs@@ -0,0 +1,239 @@1+//! m3 releases + machine surface (f0rge issue #8):2+//! 1 release with `out/` artifacts (byte-identical) + 1 notes-only,3+//! api counts == html counts, agents.md curl examples hittable,4+//! feed includes the release.5+6+use std::path::{Path, PathBuf};7+use std::process::Command;8+9+fn git(repo: &Path, args: &[&str]) {10+ let st = Command::new("git")11+ .arg("-C")12+ .arg(repo)13+ .args(args)14+ .status()15+ .expect("spawn git");16+ assert!(st.success(), "git {args:?} failed");17+}18+19+fn fresh_dir(prefix: &str) -> PathBuf {20+ let dir = std::env::temp_dir().join(format!(21+ "hu13-relapi-{prefix}-{}-{}",22+ std::process::id(),23+ std::time::SystemTime::now()24+ .duration_since(std::time::UNIX_EPOCH)25+ .map(|d| d.as_nanos())26+ .unwrap_or(0)27+ ));28+ std::fs::create_dir_all(&dir).unwrap();29+ dir30+}31+32+fn forge_write(repo: &Path, rel: &str, content: &str) {33+ let dest = repo.join(".forge").join(rel);34+ std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();35+ std::fs::write(&dest, content).unwrap();36+}37+38+fn json_len(text: &str) -> usize {39+ let v: serde_json::Value = serde_json::from_str(text).expect("valid json");40+ v.as_array().expect("top-level array").len()41+}42+43+#[test]44+fn releases_artifacts_api_agents_feed() {45+ let repo = fresh_dir("repo");46+ git(&repo, &["init", "-b", "main"]);47+ git(&repo, &["config", "user.name", "hu13-test"]);48+ git(&repo, &["config", "user.email", "[email protected]"]);49+ git(&repo, &["config", "commit.gpgsign", "false"]);50+ std::fs::write(repo.join("README.md"), "# relapi fixture\n").unwrap();51+52+ // One issue + one PR (for api/html count equality).53+ forge_write(54+ &repo,55+ "issues/11111111-1111-4111-8111-111111111111/issue.md",56+ "---\nid: 11111111-1111-4111-8111-111111111111\ntitle: API issue\nstate: open\ncreated: 2026-09-10T03:00:00Z\n---\nIssue body.\n",57+ );58+ forge_write(59+ &repo,60+ "prs/44444444-4444-4444-8444-444444444444/pr.md",61+ "---\nid: 44444444-4444-4444-8444-444444444444\ntitle: API pr\nstate: open\nbase: main\nhead: feat\ncreated: 2026-09-10T03:30:00Z\n---\nPR body.\n",62+ );63+ // One wiki page.64+ forge_write(&repo, "wiki/notes.md", "# Notes\n\nwiki body\n");65+66+ // Release WITH artifacts: notes carry a sha256 line.67+ let digest = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";68+ forge_write(69+ &repo,70+ "releases/v1.0.md",71+ &format!(72+ "---\nversion: v1.0\ntitle: First release\ncreated: 2026-09-10T02:00:00Z\nsha: abc1234\n---\n# v1.0 notes\n\nShips `app.bin`.\n\n{digest} app.bin\n"73+ ),74+ );75+ let artifact_bytes: Vec<u8> = (0u8..=255).collect();76+ let out_file = repo.join(".forge").join("releases/out/v1.0/app.bin");77+ std::fs::create_dir_all(out_file.parent().unwrap()).unwrap();78+ std::fs::write(&out_file, &artifact_bytes).unwrap();79+ std::fs::write(80+ repo.join(".forge").join("releases/out/v1.0/nested.txt"),81+ "nested artifact\n",82+ )83+ .unwrap();84+ // Release WITHOUT artifacts: notes-only.85+ forge_write(86+ &repo,87+ "releases/v2.0.md",88+ "---\nversion: v2.0\ncreated: 2026-09-10T04:00:00Z\n---\n# v2.0 notes\n\nNo artifacts yet.\n",89+ );90+91+ git(&repo, &["add", "-A"]);92+ git(&repo, &["commit", "-m", "relapi fixture"]);93+94+ let out = fresh_dir("out");95+ let bin = env!("CARGO_BIN_EXE_hu13");96+ let produced = Command::new(bin)97+ .arg("build")98+ .arg(&repo)99+ .arg("-o")100+ .arg(&out)101+ .output()102+ .expect("spawn hu13 build");103+ assert!(104+ produced.status.success(),105+ "build exits 0; stderr: {}",106+ String::from_utf8_lossy(&produced.stderr)107+ );108+109+ let read = |rel: &str| std::fs::read_to_string(out.join(rel)).expect(rel);110+111+ // --- releases: index + both detail pages ---112+ let index = read("releases/index.html");113+ assert_eq!(index.matches("<tr><td>").count(), 2, "two release rows");114+ assert!(index.contains("v1.0"), "v1.0 listed");115+ assert!(index.contains("v2.0"), "v2.0 listed");116+117+ let with_art = read("releases/v1.0.html");118+ assert!(with_art.contains("First release"), "title shown");119+ assert!(with_art.contains("app.bin"), "artifact linked");120+ assert!(with_art.contains("nested.txt"), "nested artifact linked");121+ assert!(with_art.contains("bytes"), "sizes shown");122+ assert!(with_art.contains(digest), "sha256 line from notes shown");123+ assert!(124+ !with_art.contains("No artifacts attached."),125+ "artifacts present"126+ );127+ // Escaping: no raw passthrough of the version block.128+ assert!(!with_art.contains("<script>"));129+130+ let notes_only = read("releases/v2.0.html");131+ assert!(notes_only.contains("v2.0 notes"), "notes rendered");132+ assert!(133+ notes_only.contains("No artifacts attached."),134+ "notes-only state"135+ );136+137+ // Artifacts byte-identical, whole dir copied.138+ for rel in ["releases/v1.0/app.bin", "releases/v1.0/nested.txt"] {139+ let got = std::fs::read(out.join(rel)).expect(rel);140+ let want = std::fs::read(141+ repo.join(".forge").join(142+ rel.strip_prefix("releases/")143+ .map(|r| format!("releases/out/v1.0/{}", r.rsplit('/').next().unwrap()))144+ .unwrap(),145+ ),146+ )147+ .expect("fixture artifact");148+ assert_eq!(got, want, "byte-identical: {rel}");149+ }150+ assert_eq!(151+ std::fs::read(out.join("releases/v1.0/app.bin")).unwrap(),152+ artifact_bytes153+ );154+155+ // --- api counts == html counts (same struct pass) ---156+ let issues_html = read("issues/index.html").matches("<tr><td>").count();157+ let pulls_html = read("pulls/index.html").matches("<tr><td>").count();158+ let commits_html = read("commits.html").matches("<tr><td>").count();159+ let releases_html = read("releases/index.html").matches("<tr><td>").count();160+ let wiki_html = read("wiki/index.html")161+ .matches("<li><a href=\"/wiki/")162+ .count();163+ assert_eq!(164+ json_len(&read("api/issues.json")),165+ issues_html,166+ "issues count"167+ );168+ assert_eq!(json_len(&read("api/pulls.json")), pulls_html, "pulls count");169+ assert_eq!(170+ json_len(&read("api/commits.json")),171+ commits_html,172+ "commits count"173+ );174+ assert_eq!(175+ json_len(&read("api/releases.json")),176+ releases_html,177+ "releases count"178+ );179+ assert_eq!(json_len(&read("api/wiki.json")), wiki_html, "wiki count");180+ // Compact fixed-order JSON: single line, expected first keys.181+ for (rel, key) in [182+ ("api/issues.json", "\"number\""),183+ ("api/pulls.json", "\"number\""),184+ ("api/commits.json", "\"sha\""),185+ ("api/releases.json", "\"version\""),186+ ("api/wiki.json", "\"path\""),187+ ] {188+ let text = read(rel);189+ assert!(!text.contains('\n'), "{rel} is compact");190+ assert!(text.contains(key), "{rel} fixed order starts with {key}");191+ }192+193+ // --- agents.html + agents.md ---194+ let agents_html = read("agents.html");195+ let agents_md = read("agents.md");196+ for needle in [197+ "/raw/",198+ "/api/issues.json",199+ "/repo.tar.gz",200+ "/repo.bundle",201+ "application/x-www-form-urlencoded",202+ "honeypot",203+ "<=300",204+ "<=20000",205+ "<=32KB",206+ "By submitting this text",207+ "detailed actionable text",208+ "ONE prompt not code",209+ ] {210+ assert!(agents_md.contains(needle), "agents.md has {needle}");211+ assert!(212+ agents_html.contains(213+ &needle214+ .replace('<', "<")215+ .replace('>', ">")216+ .replace('"', """)217+ ) || agents_html.contains(needle),218+ "agents.html has {needle}"219+ );220+ }221+ // Curl examples hittable: exact m4 route paths in both files.222+ for path in ["/inbox/issue", "/inbox/pr-prompt"] {223+ assert!(agents_md.contains(path), "agents.md curl hits {path}");224+ assert!(agents_html.contains(path), "agents.html curl hits {path}");225+ }226+ assert!(agents_md.contains("curl -X POST"), "curl example present");227+228+ // --- feed includes the release ---229+ let feed = read("feed.atom");230+ assert!(feed.contains("v1.0"), "feed has release entry");231+ assert!(feed.contains("v2.0"), "feed has second release");232+ assert!(feed.contains("urn:release:"), "release urn ids");233+ assert!(!feed.contains("TODO(m3)"), "no m3 marker left");234+235+ // --- nav links releases + agents ---236+ let home = read("index.html");237+ assert!(home.contains("/releases/index.html"), "nav links releases");238+ assert!(home.contains("/agents.html"), "nav links agents");239+}0--
Comments (0)
No comments yet.
Reviews (0)
No reviews yet.