1 From c8d4fbc66217f344fccc8249d713b0b3c61240df Mon Sep 17 00:00:00 20012 From: gl0w <gl0w@world>3 Date: Thu, 10 Sep 2026 05:36:21 +01004 Subject: [PATCH] m3/releases-api: releases+artifacts, api JSON, agents page5 (60 tests)6 7 ---8 src/agents.rs | 143 +++++++++++++9 src/api.rs | 170 ++++++++++++++++10 src/build.rs | 36 +++-11 src/commits.rs | 40 +++-12 src/feed.rs | 133 +++++++-----13 src/main.rs | 6 +14 src/releases.rs | 464 ++++++++++++++++++++++++++++++++++++++++++15 src/wiki.rs | 9 +-16 tests/releases_api.rs | 239 ++++++++++++++++++++++17 9 files changed, 1167 insertions(+), 73 deletions(-)18 create mode 100644 src/agents.rs19 create mode 100644 src/api.rs20 create mode 100644 src/releases.rs21 create mode 100644 tests/releases_api.rs22 23 diff --git a/src/agents.rs b/src/agents.rs24 new file mode 10064425 index 0000000..3de113326 --- /dev/null27 +++ b/src/agents.rs28 @@ -0,0 +1,143 @@29 +//! Agent surface (m3, f0rge issue #8): `agents.html` + `agents.md`.30 +//!31 +//! Static instructions for agents: read via raw/api/tarball/bundle, submit32 +//! via the two inbox POSTs. The markdown source is written verbatim to33 +//! `dist/agents.md`; `dist/agents.html` is the same text rendered through34 +//! [`crate::build::markdown_to_html`].35 +//!36 +//! The curl examples POST to the exact m4 route paths `/inbox/issue` and37 +//! `/inbox/pr-prompt` (the m4 server implements those paths; keep the38 +//! examples in sync). License text is quoted verbatim from `DRAFT.md`.39 +//!40 +//! SECURITY: all untrusted values (site name, clone URL, branch, sha) go41 +//! through [`crate::layout::esc`]; the guide body is static text.42 +43 +use std::path::Path;44 +45 +use crate::build::markdown_to_html;46 +use crate::layout::{esc, page};47 +48 +/// License grant quoted verbatim from `DRAFT.md` (receiver-friendly v1).49 +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.";50 +51 +fn agents_markdown(52 + site_name: &str,53 + clone_url: Option<&str>,54 + branch: &str,55 + short_sha: &str,56 +) -> String {57 + let clone_line = match clone_url {58 + Some(u) => format!("Clone: `git clone {u}` (branch `{branch}` @ `{short_sha}`)."),59 + None => format!("No clone URL configured; branch `{branch}` @ `{short_sha}`."),60 + };61 + format!(62 + "# Agents guide — {site_name}\n\n\63 + You are an agent visiting a hu13 mirror of one repo. No accounts, no JS, no auth.\n\64 + Read with curl; suggest with two dumb text POSTs. A human (or their agent) triages later.\n\n\65 + ## Read\n\n\66 + - Site: `{site_name}`, {clone_line}\n\67 + - Raw file bytes: `/raw/<path>` (byte-identical to the repo).\n\68 + - 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\69 + - Snapshots: `/repo.tar.gz` (extract) + `/repo.bundle` (`git clone -b {branch} repo.bundle`). The bundle is authoritative when no clone URL is configured.\n\70 + - Releases + artifacts: `/releases/index.html`, `/releases/<ver>.html`, downloads under `/releases/<ver>/<file>`.\n\71 + - Feed: `/feed.atom` (up to 50 entries, commits + releases newest-first — poll this, not the HTML).\n\72 + - 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\73 + ## Submit (the only write path)\n\n\74 + 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\75 + - `POST /inbox/issue` — suggest an issue. Fields: `title` + `body`.\n\76 + - `POST /inbox/pr-prompt` — suggest a change. Fields: `title` + `prompt`.\n\n\77 + Both POSTs are `Content-Type: application/x-www-form-urlencoded` (urlencoded-only; `multipart/*` is rejected), UTF-8 only.\n\n\78 + | field | rule |\n\79 + | ----- | ---- |\n\80 + | `title` | required, plain text, <=300 chars |\n\81 + | `body` / `prompt` | required, plain text, <=20000 chars |\n\82 + | `handle` | optional, plain text, <=40 chars |\n\83 + | `contact` | optional, plain text, <=100 chars |\n\84 + | `license` | required checkbox — must be checked, else `400` |\n\85 + | honeypot | `honeypot` field must be left empty (filled = silently dropped, still says received) |\n\86 + | whole request | <=32KB else `413` |\n\n\87 + 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\88 + ### License (required — check the box only if you agree)\n\n\89 + > {license}\n\n\90 + Stored per submission: license version + checkbox state + body hash + timestamp. No submission without the box checked.\n\n\91 + ## Philosophy\n\n\92 + - Issues = detailed actionable text from your user. Write what a maintainer can act on: context, steps, expected vs actual, links to `/raw/` paths.\n\93 + - 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\94 + - Free textboxes: the forms are free text on purpose — visiting agents are smarter than any template. Keep it dumb, keep it text.\n\n\95 + ## Curl examples\n\n\96 + Suggest an issue:\n\n\97 + ```sh\n\98 + curl -X POST http://localhost:39313/inbox/issue \\\n\99 + --data-urlencode \"title=Crash on empty config\" \\\n\100 + --data-urlencode \"body=Steps to reproduce: ... (detailed actionable text from your user)\" \\\n\101 + --data-urlencode \"handle=my-bot\" \\\n\102 + --data-urlencode \"[email protected]\" \\\n\103 + --data-urlencode \"license=agree\" \\\n\104 + --data-urlencode \"honeypot=\"\n\105 + ```\n\n\106 + Suggest a PR prompt (ONE prompt, not code):\n\n\107 + ```sh\n\108 + curl -X POST http://localhost:39313/inbox/pr-prompt \\\n\109 + --data-urlencode \"title=Retry with backoff\" \\\n\110 + --data-urlencode \"prompt=In the uploader, retry failed puts with exponential backoff and ... (ONE prompt, not code)\" \\\n\111 + --data-urlencode \"handle=my-bot\" \\\n\112 + --data-urlencode \"[email protected]\" \\\n\113 + --data-urlencode \"license=agree\" \\\n\114 + --data-urlencode \"honeypot=\"\n\115 + ```\n\n\116 + Caps reminder: `title` <=300 chars, `body`/`prompt` <=20000 chars, whole request <=32KB. Rate ~2/hr per IP; be polite.\n",117 + site_name = site_name,118 + clone_line = clone_line,119 + branch = branch,120 + license = LICENSE_TEXT.replace('\n', "\n> "),121 + )122 +}123 +124 +/// Render `agents.html` (rendered guide) + `agents.md` (raw markdown copy).125 +pub fn render(126 + out: &Path,127 + site_name: &str,128 + nav: &str,129 + clone_url: Option<&str>,130 + branch: &str,131 + short_sha: &str,132 +) -> Result<(), String> {133 + let site_e = esc(site_name);134 + let branch_e = esc(branch);135 + let sha_e = esc(short_sha);136 + let url_e = clone_url.map(esc);137 + let md = agents_markdown(&site_e, url_e.as_deref(), &branch_e, &sha_e);138 + // NOTE: site_name/branch/sha above are pre-escaped for the markdown139 + // source so the raw `agents.md` and rendered `agents.html` agree. The140 + // markdown renderer escapes raw HTML again, so `&` etc. stay safe.141 + let html_body = markdown_to_html(&md);142 + let html = page(&format!("agents — {site_name}"), nav, &html_body);143 + std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;144 + std::fs::write(out.join("agents.html"), html)145 + .map_err(|e| format!("hu13: write agents.html: {e}"))?;146 + std::fs::write(out.join("agents.md"), &md)147 + .map_err(|e| format!("hu13: write agents.md: {e}"))?;148 + Ok(())149 +}150 +151 +#[cfg(test)]152 +mod tests {153 + use super::*;154 +155 + #[test]156 + fn curl_examples_hit_m4_routes() {157 + let md = agents_markdown("test", None, "main", "abc1234");158 + assert!(md.contains("/inbox/issue"), "issue POST path present");159 + assert!(160 + md.contains("/inbox/pr-prompt"),161 + "pr-prompt POST path present"162 + );163 + assert!(md.contains("application/x-www-form-urlencoded"));164 + assert!(md.contains("<=300"));165 + assert!(md.contains("<=20000"));166 + assert!(md.contains("<=32KB"));167 + assert!(md.contains("ONE prompt not code") || md.contains("ONE prompt"));168 + assert!(md.contains("detailed actionable text"));169 + assert!(md.contains("By submitting this text"));170 + }171 +}172 diff --git a/src/api.rs b/src/api.rs173 new file mode 100644174 index 0000000..cf43f6a175 --- /dev/null176 +++ b/src/api.rs177 @@ -0,0 +1,170 @@178 +//! Machine JSON (m3, f0rge issue #8): `api/*.json` built from the SAME179 +//! structs used for HTML (no divergent logic).180 +//!181 +//! [`render`] takes the already-loaded readers — [`crate::forge::ForgeData`]182 +//! (issues/pulls/tasks/ci), the commit log slice from [`crate::gitx::log_n`]183 +//! (the same vec rendered to `commits.html`), the release slice from184 +//! [`crate::releases::load`], and the wiki pages from [`crate::wiki::render`]185 +//! — and writes compact fixed-order JSON with `serde_json::to_string`186 +//! (never pretty). Array lengths therefore equal the HTML list counts by187 +//! construction; see `tests/releases_api.rs`.188 +//!189 +//! SECURITY: bodies are untrusted text carried as JSON strings (never HTML).190 +191 +use std::path::Path;192 +193 +use serde::Serialize;194 +195 +use crate::forge::ForgeData;196 +use crate::gitx::Commit;197 +use crate::releases::Release;198 +use crate::wiki::WikiPage;199 +200 +/// One issue row (fixed field order).201 +#[derive(Debug, Clone, Serialize)]202 +pub struct IssueJson {203 + pub number: usize,204 + pub id: String,205 + pub title: String,206 + pub state: String,207 + pub labels: Vec<String>,208 + pub assignees: Vec<String>,209 + pub milestone: Option<String>,210 + pub created: String,211 + pub comments: usize,212 + pub body: String,213 +}214 +215 +/// One pull row (fixed field order).216 +#[derive(Debug, Clone, Serialize)]217 +pub struct PullJson {218 + pub number: usize,219 + pub id: String,220 + pub title: String,221 + pub state: String,222 + pub base: String,223 + pub head: String,224 + pub draft: bool,225 + pub created: String,226 + pub comments: usize,227 + pub reviews: usize,228 + pub body: String,229 +}230 +231 +/// One commit row (fixed field order).232 +#[derive(Debug, Clone, Serialize)]233 +pub struct CommitJson {234 + pub sha: String,235 + pub subject: String,236 + pub author: String,237 + pub date: String,238 +}239 +240 +/// One release row (fixed field order).241 +#[derive(Debug, Clone, Serialize)]242 +pub struct ReleaseJson {243 + pub version: String,244 + pub title: Option<String>,245 + pub created: String,246 + pub sha: Option<String>,247 + pub artifacts: usize,248 + pub body: String,249 +}250 +251 +/// One wiki row (fixed field order).252 +#[derive(Debug, Clone, Serialize)]253 +pub struct WikiJson {254 + pub path: String,255 + pub title: String,256 + pub size: u64,257 +}258 +259 +fn write_json(out: &Path, rel: &str, value: &impl Serialize) -> Result<(), String> {260 + // Compact fixed-order JSON (serde struct order, no pretty printing).261 + let text = serde_json::to_string(value).map_err(|e| format!("hu13: encode {rel}: {e}"))?;262 + let dest = out.join(rel);263 + if let Some(parent) = dest.parent() {264 + std::fs::create_dir_all(parent).map_err(|e| format!("hu13: create api dir: {e}"))?;265 + }266 + std::fs::write(&dest, text).map_err(|e| format!("hu13: write {rel}: {e}"))267 +}268 +269 +/// Build `api/issues.json`, `api/pulls.json`, `api/commits.json`,270 +/// `api/releases.json`, and `api/wiki.json` from the same structs rendered271 +/// to HTML. Callers must pass the exact slices/vecs used for the HTML pass.272 +pub fn render(273 + out: &Path,274 + forge: &ForgeData,275 + commits: &[Commit],276 + releases: &[Release],277 + wiki: &[WikiPage],278 +) -> Result<(), String> {279 + let issues: Vec<IssueJson> = forge280 + .issues281 + .iter()282 + .map(|i| IssueJson {283 + number: i.number,284 + id: i.id.clone(),285 + title: i.title.clone(),286 + state: i.state.clone(),287 + labels: i.labels.clone(),288 + assignees: i.assignees.clone(),289 + milestone: i.milestone.clone(),290 + created: i.created.clone(),291 + comments: i.comments.len(),292 + body: i.body.clone(),293 + })294 + .collect();295 + let pulls: Vec<PullJson> = forge296 + .prs297 + .iter()298 + .map(|p| PullJson {299 + number: p.number,300 + id: p.id.clone(),301 + title: p.title.clone(),302 + state: p.state.clone(),303 + base: p.base.clone(),304 + head: p.head.clone(),305 + draft: p.draft,306 + created: p.created.clone(),307 + comments: p.comments.len(),308 + reviews: p.reviews.len(),309 + body: p.body.clone(),310 + })311 + .collect();312 + let commits_json: Vec<CommitJson> = commits313 + .iter()314 + .map(|c| CommitJson {315 + sha: c.sha.clone(),316 + subject: c.subject.clone(),317 + author: c.author.clone(),318 + date: c.date.clone(),319 + })320 + .collect();321 + let releases_json: Vec<ReleaseJson> = releases322 + .iter()323 + .map(|r| ReleaseJson {324 + version: r.version.clone(),325 + title: r.title.clone(),326 + created: r.created.clone(),327 + sha: r.sha.clone(),328 + artifacts: r.artifacts.len(),329 + body: r.body.clone(),330 + })331 + .collect();332 + let wiki_json: Vec<WikiJson> = wiki333 + .iter()334 + .map(|w| WikiJson {335 + path: w.path.clone(),336 + title: w.title.clone(),337 + size: w.size,338 + })339 + .collect();340 +341 + write_json(out, "api/issues.json", &issues)?;342 + write_json(out, "api/pulls.json", &pulls)?;343 + write_json(out, "api/commits.json", &commits_json)?;344 + write_json(out, "api/releases.json", &releases_json)?;345 + write_json(out, "api/wiki.json", &wiki_json)?;346 + Ok(())347 +}348 diff --git a/src/build.rs b/src/build.rs349 index 9920bf8..5acf830 100644350 --- a/src/build.rs351 +++ b/src/build.rs352 @@ -1,21 +1,27 @@353 -//! `hu13 build` — static site (m1 code tab + m2 commits/wiki + m3 f0rge readers).354 +//! `hu13 build` — static site (m1 code tab + m2 commits/wiki + m3 f0rge readers + releases/api/agents).355 //!356 //! Reads one branch via [`crate::gitx`], emits `dist/`:357 //! `index.html`, `files/<path>.html`, `raw/<path>` (byte-identical),358 //! `commits.html`, `commit/<sha>.html`, `feed.atom`, `issues/index.html`,359 //! `issues/<uuid>.html`, `pulls/index.html`, `pulls/<uuid>.html`,360 -//! `tasks/index.html`, `ci/index.html`, `wiki/index.html`,361 -//! `wiki/<path>.html`, `repo.bundle` + `repo.tar.gz`, and `style.css`.362 +//! `tasks/index.html`, `ci/index.html`, `releases/index.html`,363 +//! `releases/<ver>.html`, `releases/<ver>/<file>` artifacts,364 +//! `wiki/index.html`, `wiki/<path>.html`, `api/*.json`, `agents.html` +365 +//! `agents.md`, `repo.bundle` + `repo.tar.gz`, and `style.css`.366 //!367 //! Bodies from the repo are semi-trusted but treated as untrusted:368 //! markdown goes through [`markdown_to_html`], which escapes raw HTML;369 //! commit messages and diffs are escaped `<pre>` (never markdown).370 +//!371 +//! Machine JSON (`api/*.json`) and HTML are built from one struct pass over372 +//! the same loaded readers (no divergent logic): `forge::load`,373 +//! `commits::load`, `releases::load`, `wiki::render`.374 375 use std::collections::BTreeMap;376 use std::path::Path;377 378 use crate::layout::esc;379 -use crate::{commits, feed, forge, gitx, wiki};380 +use crate::{agents, api, commits, feed, forge, gitx, releases, wiki};381 382 /// Files at or above this size get a placeholder page + download link.383 pub const PREVIEW_MAX_BYTES: usize = 256 * 1024;384 @@ -155,7 +161,9 @@ fn nav_bar(issue_count: usize, pr_count: usize) -> String {385 <a href=\"/pulls/index.html\">pulls ({pr_count})</a> · \386 <a href=\"/tasks/index.html\">tasks</a> · \387 <a href=\"/ci/index.html\">ci</a> · \388 - <a href=\"/wiki/index.html\">wiki</a>"389 + <a href=\"/releases/index.html\">releases</a> · \390 + <a href=\"/wiki/index.html\">wiki</a> · \391 + <a href=\"/agents.html\">agents</a>"392 )393 }394 395 @@ -349,12 +357,18 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu396 std::fs::write(&dest, html).map_err(|e| format!("hu13: write files page: {e}"))?;397 }398 399 - // Commits + wiki (m2): untrusted messages/diffs/bodies escaped inside.400 - commits::render(repo, out, &branch, opts.max_log, &name, &nav)?;401 - feed::render(repo, out, &branch, &name)?;402 - wiki::render(repo, out, &name, &nav)?;403 + // Commits + feed + wiki + f0rge + releases + api + agents (m2/m3):404 + // one struct pass — the same loaded vecs feed both HTML and JSON.405 + let commit_log = commits::load(repo, &branch, opts.max_log)?;406 + commits::render_loaded(repo, out, &commit_log, &name, &nav)?;407 + feed::render_loaded(repo, out, &branch, &name, &commit_log)?;408 + let wiki_pages = wiki::render(repo, out, &name, &nav)?;409 // f0rge pages (m3, issue #7): issues/pulls/tasks/ci from loaded data.410 forge::render_loaded(&forge_data, out, &name, &nav)?;411 + let release_data = releases::load(repo);412 + releases::render_loaded(&release_data, repo, out, &name, &nav)?;413 + api::render(out, &forge_data, &commit_log, &release_data, &wiki_pages)?;414 + agents::render(out, &name, &nav, clone_url.as_deref(), &branch, &short_sha)?;415 416 // Snapshot last so the index can link sizes.417 let snap = gitx::snapshot(repo, &branch, out).map_err(|e| format!("hu13: {e}"))?;418 @@ -385,10 +399,12 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu419 body.push_str(&format!(420 "<p><a href=\"/issues/index.html\">issues ({})</a> · \421 <a href=\"/pulls/index.html\">pulls ({})</a> · \422 - <a href=\"/tasks/index.html\">tasks ({})</a></p>\n",423 + <a href=\"/tasks/index.html\">tasks ({})</a> · \424 + <a href=\"/releases/index.html\">releases ({})</a></p>\n",425 forge_data.issues.len(),426 forge_data.prs.len(),427 forge_data.tasks.len(),428 + release_data.len(),429 ));430 body.push_str(&format!(431 "<p>CI: {}</p>\n",432 diff --git a/src/commits.rs b/src/commits.rs433 index 834971c..593c791 100644434 --- a/src/commits.rs435 +++ b/src/commits.rs436 @@ -16,26 +16,27 @@ pub fn short_sha(sha: &str) -> &str {437 sha.get(..7).unwrap_or(sha)438 }439 440 -/// Render `commits.html` plus one `commit/<sha>.html` per listed commit.441 -///442 -/// Lists at most `max_log` newest-first commits (see `--max-log`).443 -/// Returns the number of commits listed.444 -pub fn render(445 +/// Load up to `max_log` newest-first commits (the same vec rendered to446 +/// `commits.html` and `api/commits.json` — one struct pass, no divergent logic).447 +pub fn load(repo: &Path, branch: &str, max_log: usize) -> Result<Vec<gitx::Commit>, String> {448 + gitx::log_n(repo, branch, max_log).map_err(|e| format!("hu13: {e}"))449 +}450 +451 +/// Render `commits.html` plus one `commit/<sha>.html` per commit from an452 +/// already-loaded log (see [`load`]).453 +pub fn render_loaded(454 repo: &Path,455 out: &Path,456 - branch: &str,457 - max_log: usize,458 + log: &[gitx::Commit],459 site_name: &str,460 nav: &str,461 ) -> Result<usize, String> {462 - let log = gitx::log_n(repo, branch, max_log).map_err(|e| format!("hu13: {e}"))?;463 -464 let mut body = String::from("<h1>Commits</h1>\n");465 if log.is_empty() {466 body.push_str("<p class=\"muted\">No commits yet.</p>\n");467 } else {468 body.push_str("<table>\n<thead><tr><th>sha</th><th>subject</th><th>author</th><th>date</th></tr></thead>\n<tbody>\n");469 - for c in &log {470 + for c in log {471 body.push_str(&format!(472 "<tr><td><a href=\"/commit/{}.html\"><code>{}</code></a></td><td>{}</td><td>{}</td><td>{}</td></tr>\n",473 esc(&c.sha),474 @@ -54,7 +55,7 @@ pub fn render(475 476 std::fs::create_dir_all(out.join("commit"))477 .map_err(|e| format!("hu13: create commit dir: {e}"))?;478 - for c in &log {479 + for c in log {480 let message = gitx::message(repo, &c.sha).map_err(|e| format!("hu13: {e}"))?;481 let diff = gitx::diff(repo, &c.sha).map_err(|e| format!("hu13: {e}"))?;482 let detail = format!(483 @@ -77,6 +78,23 @@ pub fn render(484 Ok(log.len())485 }486 487 +/// Render `commits.html` plus details, loading the log itself.488 +///489 +/// Wrapper around [`load`] + [`render_loaded`] for tests and simple callers.490 +/// Lists at most `max_log` newest-first commits (see `--max-log`).491 +#[allow(dead_code)]492 +pub fn render(493 + repo: &Path,494 + out: &Path,495 + branch: &str,496 + max_log: usize,497 + site_name: &str,498 + nav: &str,499 +) -> Result<usize, String> {500 + let log = load(repo, branch, max_log)?;501 + render_loaded(repo, out, &log, site_name, nav)502 +}503 +504 #[cfg(test)]505 mod tests {506 use super::*;507 diff --git a/src/feed.rs b/src/feed.rs508 index 2fa59f8..437d787 100644509 --- a/src/feed.rs510 +++ b/src/feed.rs511 @@ -1,30 +1,21 @@512 -//! `feed.atom` (m2, f0rge issue #6): 50 entries max, commits +513 +//! `feed.atom` (m2/m3, f0rge issues #6 + #8): 50 entries max, commits +514 //! release notes mixed newest-first, written at build time.515 //!516 //! No rss/atom crate (std only): hand-rolled XML writer with [`esc`].517 //! Each entry carries title, `urn:` id, RFC3339 `updated`, link, and an518 //! escaped summary.519 -//!520 -//! Releases: no `src/releases.rs` reader exists yet, so this is521 -//! commits-only for now.522 -//! TODO(m3): mix in `.forge/releases/<version>.md` notes newest-first523 -//! once the m3 releases reader lands (see [`RELEASES_SUPPORTED`] +524 -//! [`read_release_entries`]).525 526 use std::path::Path;527 528 use crate::gitx;529 +use crate::releases;530 531 /// Max entries in `feed.atom` (DRAFT: 50, commits + releases mixed).532 pub const MAX_ENTRIES: usize = 50;533 534 -/// Test hook for m3: `false` while no release reader exists in `src/`.535 -///536 -/// When `src/releases.rs` (or equivalent) lands, set this to `true` and537 -/// implement [`read_release_entries`]; the `releases_required_when_present`538 -/// test flips from "commits-only tolerated" to "release entries required".539 -// TODO(m3): set to true once the releases reader exists.540 -pub const RELEASES_SUPPORTED: bool = false;541 +/// Releases are wired in (m3 releases reader): `true`.542 +#[allow(dead_code)]543 +pub const RELEASES_SUPPORTED: bool = true;544 545 /// One Atom entry (commit or release note).546 #[derive(Debug, Clone, PartialEq, Eq)]547 @@ -35,7 +26,7 @@ pub struct FeedEntry {548 pub id: String,549 /// RFC3339 timestamp (converted from git iso date / release `created`).550 pub updated: String,551 - /// Site-relative link (`/commit/<sha>.html`, `/releases/...` later).552 + /// Site-relative link (`/commit/<sha>.html`, `/releases/<ver>.html`).553 pub link: String,554 /// Escaped one-line summary.555 pub summary: String,556 @@ -80,21 +71,51 @@ pub fn to_rfc3339(iso: &str) -> String {557 s.to_string()558 }559 560 -/// Read release-note entries for the feed.561 -///562 -/// Commits-only stub: no release reader exists in `src/` yet, so this563 -/// returns an empty vec.564 -// TODO(m3): read `.forge/releases/<version>.md` notes (title/body) here565 -// and return one `FeedEntry` per release (`urn:release:<version>`).566 +/// Read release-note entries for the feed from the same reader used for567 +/// HTML ([`releases::load`], no divergent logic): one entry per release568 +/// (`urn:release:<version>`, link `/releases/<ver>.html`).569 #[must_use]570 -pub fn read_release_entries(_repo: &Path) -> Vec<FeedEntry> {571 - // TODO(m3): releases not yet wired; commits-only feed.572 - Vec::new()573 +pub fn read_release_entries(repo: &Path) -> Vec<FeedEntry> {574 + releases::load(repo)575 + .into_iter()576 + .map(|r| {577 + let first_line = r578 + .body579 + .lines()580 + .map(str::trim)581 + .find(|l| !l.is_empty())582 + .unwrap_or("")583 + .to_string();584 + let summary = if first_line.is_empty() {585 + format!("release {} ({})", r.version, r.created)586 + } else {587 + let mut s: String = first_line.chars().take(200).collect();588 + if s.len() > 200 {589 + s.truncate(200);590 + }591 + s592 + };593 + FeedEntry {594 + title: r.display_title().to_string(),595 + id: format!("urn:release:{}", r.version),596 + updated: r.created.clone(),597 + link: format!("/releases/{}.html", r.version),598 + summary,599 + }600 + })601 + .collect()602 }603 604 /// Collect up to [`MAX_ENTRIES`] entries, commits + releases newest-first.605 +#[allow(dead_code)]606 pub fn collect_entries(repo: &Path, branch: &str) -> Result<Vec<FeedEntry>, String> {607 let log = gitx::log_n(repo, branch, MAX_ENTRIES).map_err(|e| format!("hu13: {e}"))?;608 + collect_entries_loaded(repo, &log)609 +}610 +611 +/// Collect entries from an already-loaded commit log plus releases from612 +/// [`read_release_entries`] (one struct pass with the HTML readers).613 +pub fn collect_entries_loaded(repo: &Path, log: &[gitx::Commit]) -> Result<Vec<FeedEntry>, String> {614 let mut entries: Vec<FeedEntry> = log615 .iter()616 .map(|c| {617 @@ -108,9 +129,8 @@ pub fn collect_entries(repo: &Path, branch: &str) -> Result<Vec<FeedEntry>, Stri618 }619 })620 .collect();621 - // TODO(m3): releases mixed in newest-first once the reader lands.622 - let mut releases = read_release_entries(repo);623 - entries.append(&mut releases);624 + let mut rels = read_release_entries(repo);625 + entries.append(&mut rels);626 // Newest-first by RFC3339 `updated` (lexicographic == chronological).627 entries.sort_by(|a, b| b.updated.cmp(&a.updated));628 entries.truncate(MAX_ENTRIES);629 @@ -129,9 +149,6 @@ pub fn render_feed(site_name: &str, branch: &str, entries: &[FeedEntry]) -> Stri630 out.push_str(&format!("<id>urn:hu13:feed:{}</id>\n", esc(branch)));631 out.push_str(&format!("<updated>{updated}</updated>\n"));632 out.push_str("<author><name>hu13</name></author>\n");633 - if !RELEASES_SUPPORTED {634 - out.push_str("<!-- TODO(m3): releases not yet wired; commits-only feed -->\n");635 - }636 for e in entries {637 out.push_str("<entry>\n");638 out.push_str(&format!("<title>{}</title>\n", esc(&e.title)));639 @@ -147,9 +164,31 @@ pub fn render_feed(site_name: &str, branch: &str, entries: &[FeedEntry]) -> Stri640 641 /// Build `feed.atom` in `out` (called from [`crate::build::run_build`]642 /// right after commits). Returns the number of entries written.643 +#[allow(dead_code)]644 pub fn render(repo: &Path, out: &Path, branch: &str, site_name: &str) -> Result<usize, String> {645 let entries = collect_entries(repo, branch)?;646 - let doc = render_feed(site_name, branch, &entries);647 + render_entries(out, site_name, branch, &entries)648 +}649 +650 +/// Build `feed.atom` from already-loaded commits (one struct pass).651 +pub fn render_loaded(652 + repo: &Path,653 + out: &Path,654 + branch: &str,655 + site_name: &str,656 + log: &[gitx::Commit],657 +) -> Result<usize, String> {658 + let entries = collect_entries_loaded(repo, log)?;659 + render_entries(out, site_name, branch, &entries)660 +}661 +662 +fn render_entries(663 + out: &Path,664 + site_name: &str,665 + branch: &str,666 + entries: &[FeedEntry],667 +) -> Result<usize, String> {668 + let doc = render_feed(site_name, branch, entries);669 std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;670 std::fs::write(out.join("feed.atom"), doc)671 .map_err(|e| format!("hu13: write feed.atom: {e}"))?;672 @@ -222,7 +261,7 @@ mod tests {673 674 /// Minimal well-formedness check: angle-balance plus a tiny tag-stack675 /// parse over the known Atom shape (feed/entry/title/id/updated/link/676 - /// summary/author/name + xml prolog + TODO comment).677 + /// summary/author/name + xml prolog).678 fn check_well_formed(xml: &str) {679 let opens = xml.bytes().filter(|b| *b == b'<').count();680 let closes = xml.bytes().filter(|b| *b == b'>').count();681 @@ -341,9 +380,8 @@ mod tests {682 683 #[test]684 fn releases_required_when_present() {685 - // Fixture WITH a releases dir: tolerates commits-only now686 - // (RELEASES_SUPPORTED == false), but REQUIRES release entries687 - // once the m3 reader lands (flag flips to true).688 + // Fixture WITH a releases dir: release entries are REQUIRED now689 + // that the m3 reader has landed.690 let repo = fixture_repo();691 std::fs::create_dir_all(repo.join(".forge").join("releases")).unwrap();692 std::fs::write(693 @@ -354,22 +392,15 @@ mod tests {694 let entries = collect_entries(&repo, "main").expect("collect");695 let xml = render_feed("test", "main", &entries);696 check_well_formed(&xml);697 - if RELEASES_SUPPORTED {698 - assert!(699 - xml.contains("v1.0"),700 - "release entries required when releases/ dir exists (m3 landed)"701 - );702 - assert!(703 - xml.contains("urn:release:"),704 - "release urn ids required once m3 lands"705 - );706 - } else {707 - // TODO(m3): commits-only tolerated until the releases reader lands.708 - assert!(709 - xml.contains("TODO(m3)"),710 - "commits-only marker while releases unwired"711 - );712 - }713 + assert!(714 + xml.contains("v1.0"),715 + "release entries required when releases/ dir exists"716 + );717 + assert!(xml.contains("urn:release:"), "release urn ids required");718 + assert!(719 + !xml.contains("TODO(m3)"),720 + "no m3 TODO marker left in feed output"721 + );722 }723 724 #[test]725 diff --git a/src/main.rs b/src/main.rs726 index 6d84b9b..c2b1c2c 100644727 --- a/src/main.rs728 +++ b/src/main.rs729 @@ -1,9 +1,12 @@730 +mod agents;731 +mod api;732 mod build;733 mod commits;734 mod feed;735 mod forge;736 mod gitx;737 mod layout;738 +mod releases;739 mod wiki;740 741 const VERSION: &str = env!("CARGO_PKG_VERSION");742 @@ -204,6 +207,9 @@ fn run_serve(args: &[String]) -> i32 {743 print_serve_help();744 return 0;745 }746 + // TODO(m4): serve `dist/` with `X-Content-Type-Options: nosniff` on every747 + // response (static files + inbox POSTs); never execute or list beyond748 + // what the build emitted. Stub for now — prints help and exits 0.749 print_serve_help();750 0751 }752 diff --git a/src/releases.rs b/src/releases.rs753 new file mode 100644754 index 0000000..0abcb92755 --- /dev/null756 +++ b/src/releases.rs757 @@ -0,0 +1,464 @@758 +//! Releases (m3, f0rge issue #8): `.forge/releases/<ver>.md` notes +759 +//! optional `releases/out/<ver>/` artifacts.760 +//!761 +//! Source is the on-disk `<repo>/.forge/releases/` tree (NOT git objects):762 +//! `<version>.md` files carry frontmatter (`version`, `created` required;763 +//! `title`, `sha` optional) plus a markdown notes body. `build/` holds build764 +//! scripts (never rendered); `out/<ver>/` holds built artifacts (gitignored,765 +//! usually absent — fine).766 +//!767 +//! Pages: `releases/index.html` + `releases/<ver>.html`. When768 +//! `releases/out/<ver>/` exists at build time the WHOLE dir is copied to769 +//! `dist/releases/<ver>/` (no size cap — the maintainer shipped it, hu13770 +//! serves it) and linked with sizes plus any sha256 lines found in the771 +//! notes. When absent the page is notes-only with no warning.772 +//!773 +//! SECURITY: every byte from `.forge/` is untrusted. Notes bodies go through774 +//! [`crate::build::markdown_to_html`] (raw HTML escaped); versions, titles,775 +//! shas, filenames, and sha256 lines go through [`crate::layout::esc`].776 +777 +use std::path::{Path, PathBuf};778 +779 +use serde::Deserialize;780 +781 +use crate::build::markdown_to_html;782 +use crate::layout::{esc, page};783 +784 +/// `.forge/` files larger than this are skipped (schema SEC-01: 256 KiB).785 +pub const RELEASE_MAX_BYTES: u64 = 256 * 1024;786 +787 +#[derive(Debug, Clone, Deserialize)]788 +struct ReleaseFm {789 + version: String,790 + created: String,791 + #[serde(default)]792 + title: Option<String>,793 + #[serde(default)]794 + sha: Option<String>,795 +}796 +797 +/// One file inside `out/<ver>/` (relative path + byte size).798 +#[derive(Debug, Clone, PartialEq, Eq)]799 +pub struct Artifact {800 + /// `/`-separated path relative to `out/<ver>/`.801 + pub path: String,802 + /// File size in bytes.803 + pub size: u64,804 +}805 +806 +/// One release record: notes + artifacts found at build time.807 +#[derive(Debug, Clone)]808 +pub struct Release {809 + /// Version string (from frontmatter `version`).810 + pub version: String,811 + /// Optional title.812 + pub title: Option<String>,813 + /// RFC3339 creation timestamp.814 + pub created: String,815 + /// Optional commit sha recorded at create time.816 + pub sha: Option<String>,817 + /// Markdown notes body after the frontmatter.818 + pub body: String,819 + /// Artifacts present in `out/<ver>/` at build (sorted by path).820 + pub artifacts: Vec<Artifact>,821 +}822 +823 +impl Release {824 + /// Display title: `title` when non-empty, else `version`.825 + #[must_use]826 + pub fn display_title(&self) -> &str {827 + match &self.title {828 + Some(t) if !t.trim().is_empty() => t,829 + _ => &self.version,830 + }831 + }832 +}833 +834 +fn split_frontmatter(text: &str) -> Option<(&str, &str)> {835 + let rest = text.strip_prefix("---")?;836 + let rest = rest837 + .strip_prefix('\n')838 + .or_else(|| rest.strip_prefix("\r\n"))?;839 + let mut offset = 0;840 + for line in rest.split_inclusive('\n') {841 + let trimmed = line.trim_end_matches(['\n', '\r']);842 + if trimmed == "---" {843 + let yaml = &rest[..offset];844 + let body = &rest[offset + line.len()..];845 + return Some((yaml, body));846 + }847 + offset += line.len();848 + }849 + None850 +}851 +852 +fn read_text_capped(path: &Path, label: &str) -> Option<String> {853 + let len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);854 + if len > RELEASE_MAX_BYTES {855 + eprintln!("hu13: releases: skipping {label}: over 256 KiB ({len} bytes)");856 + return None;857 + }858 + match std::fs::read(path) {859 + Ok(bytes) => match String::from_utf8(bytes) {860 + Ok(text) => Some(text),861 + Err(_) => {862 + eprintln!("hu13: releases: skipping {label}: not valid UTF-8");863 + None864 + }865 + },866 + Err(e) => {867 + eprintln!("hu13: releases: skipping {label}: cannot read: {e}");868 + None869 + }870 + }871 +}872 +873 +/// Collect every file under `dir` as `(relative /-separated path, size)`,874 +/// sorted by path. No size cap: the maintainer shipped it, hu13 serves it.875 +fn collect_artifacts(dir: &Path) -> Vec<Artifact> {876 + let mut out = Vec::new();877 + let mut stack: Vec<PathBuf> = vec![PathBuf::new()];878 + while let Some(rel) = stack.pop() {879 + let abs = dir.join(&rel);880 + let rd = match std::fs::read_dir(&abs) {881 + Ok(rd) => rd,882 + Err(e) => {883 + eprintln!("hu13: releases: cannot list {}: {e}", abs.display());884 + continue;885 + }886 + };887 + for ent in rd {888 + let ent = match ent {889 + Ok(ent) => ent,890 + Err(e) => {891 + eprintln!("hu13: releases: bad dir entry: {e}");892 + continue;893 + }894 + };895 + let name = ent.file_name().to_string_lossy().into_owned();896 + let rel_child = if rel.as_os_str().is_empty() {897 + PathBuf::from(&name)898 + } else {899 + rel.join(&name)900 + };901 + let ft = match ent.file_type() {902 + Ok(ft) => ft,903 + Err(_) => continue,904 + };905 + if ft.is_symlink() {906 + continue;907 + }908 + if ft.is_dir() {909 + stack.push(rel_child);910 + } else if ft.is_file() {911 + let size = std::fs::metadata(ent.path()).map(|m| m.len()).unwrap_or(0);912 + let rel_str = rel_child913 + .components()914 + .map(|c| c.as_os_str().to_string_lossy().into_owned())915 + .collect::<Vec<_>>()916 + .join("/");917 + out.push(Artifact {918 + path: rel_str,919 + size,920 + });921 + }922 + }923 + }924 + out.sort_by(|a, b| a.path.cmp(&b.path));925 + out926 +}927 +928 +/// Copy the WHOLE `src_dir` tree to `dest_dir` (no size cap).929 +fn copy_tree(src_dir: &Path, dest_dir: &Path) -> Result<(), String> {930 + let mut stack: Vec<PathBuf> = vec![PathBuf::new()];931 + while let Some(rel) = stack.pop() {932 + let src = src_dir.join(&rel);933 + let dest = dest_dir.join(&rel);934 + let rd = match std::fs::read_dir(&src) {935 + Ok(rd) => rd,936 + Err(e) => {937 + return Err(format!("hu13: copy releases dir {}: {e}", src.display()));938 + }939 + };940 + std::fs::create_dir_all(&dest)941 + .map_err(|e| format!("hu13: create releases dir {}: {e}", dest.display()))?;942 + for ent in rd {943 + let ent = match ent {944 + Ok(ent) => ent,945 + Err(e) => return Err(format!("hu13: list releases dir: {e}")),946 + };947 + let name = ent.file_name();948 + let rel_child = if rel.as_os_str().is_empty() {949 + PathBuf::from(&name)950 + } else {951 + rel.join(&name)952 + };953 + let ft = ent954 + .file_type()955 + .map_err(|e| format!("hu13: stat releases file: {e}"))?;956 + if ft.is_symlink() {957 + continue;958 + }959 + if ft.is_dir() {960 + stack.push(rel_child);961 + } else if ft.is_file() {962 + let bytes =963 + std::fs::read(ent.path()).map_err(|e| format!("hu13: read artifact: {e}"))?;964 + std::fs::write(dest_dir.join(&rel_child), &bytes)965 + .map_err(|e| format!("hu13: write artifact: {e}"))?;966 + }967 + }968 + }969 + Ok(())970 +}971 +972 +/// Load every release from on-disk `<repo>/.forge/releases/*.md`.973 +/// Never fails: bad files are skipped with warnings; a missing dir yields974 +/// an empty vec. Order is `(created, version)`.975 +#[must_use]976 +pub fn load(repo: &Path) -> Vec<Release> {977 + let root = repo.join(".forge").join("releases");978 + let rd = match std::fs::read_dir(&root) {979 + Ok(rd) => rd,980 + Err(_) => return Vec::new(),981 + };982 + let mut names: Vec<String> = Vec::new();983 + for ent in rd {984 + let ent = match ent {985 + Ok(ent) => ent,986 + Err(e) => {987 + eprintln!("hu13: releases: bad dir entry: {e}");988 + continue;989 + }990 + };991 + let Ok(ft) = ent.file_type() else {992 + continue;993 + };994 + if ft.is_symlink() || !ft.is_file() {995 + continue;996 + }997 + let name = ent.file_name().to_string_lossy().into_owned();998 + if name.ends_with(".md") {999 + names.push(name);1000 + }1001 + }1002 + names.sort();1003 +1004 + let mut out = Vec::new();1005 + for name in names {1006 + let label = format!("releases/{name}");1007 + let Some(text) = read_text_capped(&root.join(&name), &label) else {1008 + continue;1009 + };1010 + let Some((yaml, body)) = split_frontmatter(&text) else {1011 + eprintln!("hu13: releases: skipping {label}: missing frontmatter");1012 + continue;1013 + };1014 + let fm: ReleaseFm = match yaml_serde::from_str(yaml) {1015 + Ok(fm) => fm,1016 + Err(e) => {1017 + eprintln!("hu13: releases: skipping {label}: {e}");1018 + continue;1019 + }1020 + };1021 + // Artifacts: present only when `out/<version>/` exists at build.1022 + // Absent -> notes-only, no warning.1023 + let artifacts = if fm.version.contains('/')1024 + || fm.version.contains('\\')1025 + || fm.version.split('/').any(|p| p == "..")1026 + {1027 + Vec::new()1028 + } else {1029 + let out_dir = root.join("out").join(&fm.version);1030 + if out_dir.is_dir() {1031 + collect_artifacts(&out_dir)1032 + } else {1033 + Vec::new()1034 + }1035 + };1036 + out.push(Release {1037 + version: fm.version,1038 + title: fm.title.filter(|t| !t.trim().is_empty()),1039 + created: fm.created,1040 + sha: fm.sha.filter(|s| !s.trim().is_empty()),1041 + body: body.to_string(),1042 + artifacts,1043 + });1044 + }1045 + out.sort_by(|a, b| {1046 + (a.created.clone(), a.version.clone()).cmp(&(b.created.clone(), b.version.clone()))1047 + });1048 + out1049 +}1050 +1051 +/// Lines in `body` that look like sha256 digest lines: contain 64 hex1052 +/// chars. Returned trimmed, in body order.1053 +#[must_use]1054 +pub fn sha256_lines(body: &str) -> Vec<String> {1055 + body.lines()1056 + .filter(|line| {1057 + let lower = line.trim().to_lowercase();1058 + let mut run = 0usize;1059 + let mut best = 0usize;1060 + for ch in lower.chars() {1061 + if ch.is_ascii_hexdigit() {1062 + run += 1;1063 + best = best.max(run);1064 + } else {1065 + run = 0;1066 + }1067 + }1068 + best >= 641069 + })1070 + .map(|l| l.trim().to_string())1071 + .collect()1072 +}1073 +1074 +fn artifact_href(version: &str, path: &str) -> String {1075 + format!("/releases/{}/{path}", esc(version))1076 +}1077 +1078 +fn render_release_page(rel: &Release, site_name: &str, nav: &str) -> String {1079 + let mut body = format!("<h1>{}</h1>\n", esc(rel.display_title()));1080 + body.push_str(&format!(1081 + "<p><span class=\"muted\">version:</span> <code>{}</code></p>\n",1082 + esc(&rel.version)1083 + ));1084 + body.push_str(&format!(1085 + "<p><span class=\"muted\">created:</span> {}</p>\n",1086 + esc(&rel.created)1087 + ));1088 + if let Some(sha) = &rel.sha {1089 + body.push_str(&format!(1090 + "<p><span class=\"muted\">sha:</span> <code>{}</code></p>\n",1091 + esc(sha)1092 + ));1093 + }1094 + body.push_str(&markdown_to_html(&rel.body));1095 + if rel.artifacts.is_empty() {1096 + body.push_str("<p class=\"muted\">No artifacts attached.</p>\n");1097 + } else {1098 + body.push_str("<h2>Artifacts</h2>\n<ul class=\"artifacts\">\n");1099 + for a in &rel.artifacts {1100 + body.push_str(&format!(1101 + "<li><a href=\"{}\">{}</a> ({} bytes)</li>\n",1102 + artifact_href(&rel.version, &a.path),1103 + esc(&a.path),1104 + a.size,1105 + ));1106 + }1107 + body.push_str("</ul>\n");1108 + let hashes = sha256_lines(&rel.body);1109 + if !hashes.is_empty() {1110 + body.push_str("<h2>Checksums</h2>\n<pre class=\"log\">");1111 + for h in &hashes {1112 + body.push_str(&esc(h));1113 + body.push('\n');1114 + }1115 + body.push_str("</pre>\n");1116 + }1117 + }1118 + body.push_str("<p><a href=\"/releases/index.html\">all releases</a></p>\n");1119 + page(1120 + &format!("{} — releases — {site_name}", rel.display_title()),1121 + nav,1122 + &body,1123 + )1124 +}1125 +1126 +/// Render `releases/index.html` + `releases/<ver>.html` from already-loaded1127 +/// data, copying each present `out/<ver>/` tree to `dist/releases/<ver>/`.1128 +pub fn render_loaded(1129 + releases: &[Release],1130 + repo: &Path,1131 + out: &Path,1132 + site_name: &str,1133 + nav: &str,1134 +) -> Result<(), String> {1135 + let root = repo.join(".forge").join("releases");1136 + let mut body = String::from("<h1>Releases</h1>\n");1137 + if releases.is_empty() {1138 + body.push_str("<p class=\"muted\">No releases yet.</p>\n");1139 + } else {1140 + body.push_str("<table>\n<thead><tr><th>version</th><th>title</th><th>created</th><th>artifacts</th></tr></thead>\n<tbody>\n");1141 + for rel in releases {1142 + body.push_str(&format!(1143 + "<tr><td><a href=\"/releases/{v}.html\">{ve}</a></td><td>{t}</td><td>{c}</td><td>{n}</td></tr>\n",1144 + v = esc(&rel.version),1145 + ve = esc(&rel.version),1146 + t = esc(rel.display_title()),1147 + c = esc(&rel.created),1148 + n = rel.artifacts.len(),1149 + ));1150 + }1151 + body.push_str("</tbody>\n</table>\n");1152 + }1153 + let index = page(&format!("releases — {site_name}"), nav, &body);1154 + let dest = out.join("releases").join("index.html");1155 + if let Some(parent) = dest.parent() {1156 + std::fs::create_dir_all(parent).map_err(|e| format!("hu13: create releases dir: {e}"))?;1157 + }1158 + std::fs::write(&dest, index).map_err(|e| format!("hu13: write releases index: {e}"))?;1159 +1160 + for rel in releases {1161 + // Copy the whole out dir when present (no size cap).1162 + if !rel.version.contains('/') && !rel.version.contains('\\') {1163 + let src_dir = root.join("out").join(&rel.version);1164 + if src_dir.is_dir() {1165 + let dest_dir = out.join("releases").join(&rel.version);1166 + copy_tree(&src_dir, &dest_dir)?;1167 + }1168 + }1169 + let html = render_release_page(rel, site_name, nav);1170 + let page_dest = out.join("releases").join(format!("{}.html", rel.version));1171 + if let Some(parent) = page_dest.parent() {1172 + std::fs::create_dir_all(parent)1173 + .map_err(|e| format!("hu13: create releases parent: {e}"))?;1174 + }1175 + std::fs::write(&page_dest, html)1176 + .map_err(|e| format!("hu13: write release page {}: {e}", rel.version))?;1177 + }1178 + Ok(())1179 +}1180 +1181 +#[cfg(test)]1182 +mod tests {1183 + use super::*;1184 +1185 + #[test]1186 + fn sha256_line_detection() {1187 + let digest = "a".repeat(64);1188 + let body = format!("artifact:\n{digest} app.tar.gz\nplain line\n");1189 + let lines = sha256_lines(&body);1190 + assert_eq!(lines.len(), 1);1191 + assert!(lines[0].contains("app.tar.gz"));1192 + assert!(sha256_lines("no hashes here\n").is_empty());1193 + }1194 +1195 + #[test]1196 + fn display_title_falls_back_to_version() {1197 + let r = Release {1198 + version: "v1.0".to_string(),1199 + title: None,1200 + created: "2026-01-01T00:00:00Z".to_string(),1201 + sha: None,1202 + body: String::new(),1203 + artifacts: Vec::new(),1204 + };1205 + assert_eq!(r.display_title(), "v1.0");1206 + }1207 +1208 + #[test]1209 + fn missing_dir_is_empty() {1210 + let dir = std::env::temp_dir().join(format!(1211 + "hu13-rel-empty-{}-{}",1212 + std::process::id(),1213 + std::time::SystemTime::now()1214 + .duration_since(std::time::UNIX_EPOCH)1215 + .map(|d| d.as_nanos())1216 + .unwrap_or(0)1217 + ));1218 + std::fs::create_dir_all(&dir).unwrap();1219 + assert!(load(&dir).is_empty());1220 + }1221 +}1222 diff --git a/src/wiki.rs b/src/wiki.rs1223 index 899cb1e..345792d 1006441224 --- a/src/wiki.rs1225 +++ b/src/wiki.rs1226 @@ -27,6 +27,8 @@ pub struct WikiPage {1227 pub path: String,1228 /// First `# H1` else the filename.1229 pub title: String,1230 + /// Byte size of the source file.1231 + pub size: u64,1232 }1233 1234 fn is_h1_space(c: char) -> bool {1235 @@ -168,8 +170,13 @@ pub fn render(1236 };1237 let filename = rel.rsplit('/').next().unwrap_or(&rel);1238 let title = page_title(&text, filename);1239 + let size = text.len() as u64;1240 bodies.insert(rel.clone(), text);1241 - pages.push(WikiPage { path: rel, title });1242 + pages.push(WikiPage {1243 + path: rel,1244 + title,1245 + size,1246 + });1247 }1248 1249 let wiki_dir = out.join("wiki");1250 diff --git a/tests/releases_api.rs b/tests/releases_api.rs1251 new file mode 1006441252 index 0000000..57345e31253 --- /dev/null1254 +++ b/tests/releases_api.rs1255 @@ -0,0 +1,239 @@1256 +//! m3 releases + machine surface (f0rge issue #8):1257 +//! 1 release with `out/` artifacts (byte-identical) + 1 notes-only,1258 +//! api counts == html counts, agents.md curl examples hittable,1259 +//! feed includes the release.1260 +1261 +use std::path::{Path, PathBuf};1262 +use std::process::Command;1263 +1264 +fn git(repo: &Path, args: &[&str]) {1265 + let st = Command::new("git")1266 + .arg("-C")1267 + .arg(repo)1268 + .args(args)1269 + .status()1270 + .expect("spawn git");1271 + assert!(st.success(), "git {args:?} failed");1272 +}1273 +1274 +fn fresh_dir(prefix: &str) -> PathBuf {1275 + let dir = std::env::temp_dir().join(format!(1276 + "hu13-relapi-{prefix}-{}-{}",1277 + std::process::id(),1278 + std::time::SystemTime::now()1279 + .duration_since(std::time::UNIX_EPOCH)1280 + .map(|d| d.as_nanos())1281 + .unwrap_or(0)1282 + ));1283 + std::fs::create_dir_all(&dir).unwrap();1284 + dir1285 +}1286 +1287 +fn forge_write(repo: &Path, rel: &str, content: &str) {1288 + let dest = repo.join(".forge").join(rel);1289 + std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();1290 + std::fs::write(&dest, content).unwrap();1291 +}1292 +1293 +fn json_len(text: &str) -> usize {1294 + let v: serde_json::Value = serde_json::from_str(text).expect("valid json");1295 + v.as_array().expect("top-level array").len()1296 +}1297 +1298 +#[test]1299 +fn releases_artifacts_api_agents_feed() {1300 + let repo = fresh_dir("repo");1301 + git(&repo, &["init", "-b", "main"]);1302 + git(&repo, &["config", "user.name", "hu13-test"]);1303 + git(&repo, &["config", "user.email", "[email protected]"]);1304 + git(&repo, &["config", "commit.gpgsign", "false"]);1305 + std::fs::write(repo.join("README.md"), "# relapi fixture\n").unwrap();1306 +1307 + // One issue + one PR (for api/html count equality).1308 + forge_write(1309 + &repo,1310 + "issues/11111111-1111-4111-8111-111111111111/issue.md",1311 + "---\nid: 11111111-1111-4111-8111-111111111111\ntitle: API issue\nstate: open\ncreated: 2026-09-10T03:00:00Z\n---\nIssue body.\n",1312 + );1313 + forge_write(1314 + &repo,1315 + "prs/44444444-4444-4444-8444-444444444444/pr.md",1316 + "---\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",1317 + );1318 + // One wiki page.1319 + forge_write(&repo, "wiki/notes.md", "# Notes\n\nwiki body\n");1320 +1321 + // Release WITH artifacts: notes carry a sha256 line.1322 + let digest = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";1323 + forge_write(1324 + &repo,1325 + "releases/v1.0.md",1326 + &format!(1327 + "---\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"1328 + ),1329 + );1330 + let artifact_bytes: Vec<u8> = (0u8..=255).collect();1331 + let out_file = repo.join(".forge").join("releases/out/v1.0/app.bin");1332 + std::fs::create_dir_all(out_file.parent().unwrap()).unwrap();1333 + std::fs::write(&out_file, &artifact_bytes).unwrap();1334 + std::fs::write(1335 + repo.join(".forge").join("releases/out/v1.0/nested.txt"),1336 + "nested artifact\n",1337 + )1338 + .unwrap();1339 + // Release WITHOUT artifacts: notes-only.1340 + forge_write(1341 + &repo,1342 + "releases/v2.0.md",1343 + "---\nversion: v2.0\ncreated: 2026-09-10T04:00:00Z\n---\n# v2.0 notes\n\nNo artifacts yet.\n",1344 + );1345 +1346 + git(&repo, &["add", "-A"]);1347 + git(&repo, &["commit", "-m", "relapi fixture"]);1348 +1349 + let out = fresh_dir("out");1350 + let bin = env!("CARGO_BIN_EXE_hu13");1351 + let produced = Command::new(bin)1352 + .arg("build")1353 + .arg(&repo)1354 + .arg("-o")1355 + .arg(&out)1356 + .output()1357 + .expect("spawn hu13 build");1358 + assert!(1359 + produced.status.success(),1360 + "build exits 0; stderr: {}",1361 + String::from_utf8_lossy(&produced.stderr)1362 + );1363 +1364 + let read = |rel: &str| std::fs::read_to_string(out.join(rel)).expect(rel);1365 +1366 + // --- releases: index + both detail pages ---1367 + let index = read("releases/index.html");1368 + assert_eq!(index.matches("<tr><td>").count(), 2, "two release rows");1369 + assert!(index.contains("v1.0"), "v1.0 listed");1370 + assert!(index.contains("v2.0"), "v2.0 listed");1371 +1372 + let with_art = read("releases/v1.0.html");1373 + assert!(with_art.contains("First release"), "title shown");1374 + assert!(with_art.contains("app.bin"), "artifact linked");1375 + assert!(with_art.contains("nested.txt"), "nested artifact linked");1376 + assert!(with_art.contains("bytes"), "sizes shown");1377 + assert!(with_art.contains(digest), "sha256 line from notes shown");1378 + assert!(1379 + !with_art.contains("No artifacts attached."),1380 + "artifacts present"1381 + );1382 + // Escaping: no raw passthrough of the version block.1383 + assert!(!with_art.contains("<script>"));1384 +1385 + let notes_only = read("releases/v2.0.html");1386 + assert!(notes_only.contains("v2.0 notes"), "notes rendered");1387 + assert!(1388 + notes_only.contains("No artifacts attached."),1389 + "notes-only state"1390 + );1391 +1392 + // Artifacts byte-identical, whole dir copied.1393 + for rel in ["releases/v1.0/app.bin", "releases/v1.0/nested.txt"] {1394 + let got = std::fs::read(out.join(rel)).expect(rel);1395 + let want = std::fs::read(1396 + repo.join(".forge").join(1397 + rel.strip_prefix("releases/")1398 + .map(|r| format!("releases/out/v1.0/{}", r.rsplit('/').next().unwrap()))1399 + .unwrap(),1400 + ),1401 + )1402 + .expect("fixture artifact");1403 + assert_eq!(got, want, "byte-identical: {rel}");1404 + }1405 + assert_eq!(1406 + std::fs::read(out.join("releases/v1.0/app.bin")).unwrap(),1407 + artifact_bytes1408 + );1409 +1410 + // --- api counts == html counts (same struct pass) ---1411 + let issues_html = read("issues/index.html").matches("<tr><td>").count();1412 + let pulls_html = read("pulls/index.html").matches("<tr><td>").count();1413 + let commits_html = read("commits.html").matches("<tr><td>").count();1414 + let releases_html = read("releases/index.html").matches("<tr><td>").count();1415 + let wiki_html = read("wiki/index.html")1416 + .matches("<li><a href=\"/wiki/")1417 + .count();1418 + assert_eq!(1419 + json_len(&read("api/issues.json")),1420 + issues_html,1421 + "issues count"1422 + );1423 + assert_eq!(json_len(&read("api/pulls.json")), pulls_html, "pulls count");1424 + assert_eq!(1425 + json_len(&read("api/commits.json")),1426 + commits_html,1427 + "commits count"1428 + );1429 + assert_eq!(1430 + json_len(&read("api/releases.json")),1431 + releases_html,1432 + "releases count"1433 + );1434 + assert_eq!(json_len(&read("api/wiki.json")), wiki_html, "wiki count");1435 + // Compact fixed-order JSON: single line, expected first keys.1436 + for (rel, key) in [1437 + ("api/issues.json", "\"number\""),1438 + ("api/pulls.json", "\"number\""),1439 + ("api/commits.json", "\"sha\""),1440 + ("api/releases.json", "\"version\""),1441 + ("api/wiki.json", "\"path\""),1442 + ] {1443 + let text = read(rel);1444 + assert!(!text.contains('\n'), "{rel} is compact");1445 + assert!(text.contains(key), "{rel} fixed order starts with {key}");1446 + }1447 +1448 + // --- agents.html + agents.md ---1449 + let agents_html = read("agents.html");1450 + let agents_md = read("agents.md");1451 + for needle in [1452 + "/raw/",1453 + "/api/issues.json",1454 + "/repo.tar.gz",1455 + "/repo.bundle",1456 + "application/x-www-form-urlencoded",1457 + "honeypot",1458 + "<=300",1459 + "<=20000",1460 + "<=32KB",1461 + "By submitting this text",1462 + "detailed actionable text",1463 + "ONE prompt not code",1464 + ] {1465 + assert!(agents_md.contains(needle), "agents.md has {needle}");1466 + assert!(1467 + agents_html.contains(1468 + &needle1469 + .replace('<', "<")1470 + .replace('>', ">")1471 + .replace('"', """)1472 + ) || agents_html.contains(needle),1473 + "agents.html has {needle}"1474 + );1475 + }1476 + // Curl examples hittable: exact m4 route paths in both files.1477 + for path in ["/inbox/issue", "/inbox/pr-prompt"] {1478 + assert!(agents_md.contains(path), "agents.md curl hits {path}");1479 + assert!(agents_html.contains(path), "agents.html curl hits {path}");1480 + }1481 + assert!(agents_md.contains("curl -X POST"), "curl example present");1482 +1483 + // --- feed includes the release ---1484 + let feed = read("feed.atom");1485 + assert!(feed.contains("v1.0"), "feed has release entry");1486 + assert!(feed.contains("v2.0"), "feed has second release");1487 + assert!(feed.contains("urn:release:"), "release urn ids");1488 + assert!(!feed.contains("TODO(m3)"), "no m3 marker left");1489 +1490 + // --- nav links releases + agents ---1491 + let home = read("index.html");1492 + assert!(home.contains("/releases/index.html"), "nav links releases");1493 + assert!(home.contains("/agents.html"), "nav links agents");1494 +}1495 -- 1496 2.55.01497