1 From ab1af5575d38e08fb595f9f4c70501f19be2bb4e Mon Sep 17 00:00:00 20012 From: gl0w <gl0w@world>3 Date: Thu, 10 Sep 2026 05:19:45 +01004 Subject: [PATCH] m2/feed: feed.atom 50-max, commits+releases (43 tests)5 6 ---7 src/build.rs | 5 +-8 src/feed.rs | 397 +++++++++++++++++++++++++++++++++++++++++++++++++++9 src/main.rs | 1 +10 3 files changed, 401 insertions(+), 2 deletions(-)11 create mode 100644 src/feed.rs12 13 diff --git a/src/build.rs b/src/build.rs14 index 4530f1a..ae9f381 10064415 --- a/src/build.rs16 +++ b/src/build.rs17 @@ -2,7 +2,7 @@18 //!19 //! Reads one branch via [`crate::gitx`], emits `dist/`:20 //! `index.html`, `files/<path>.html`, `raw/<path>` (byte-identical),21 -//! `commits.html`, `commit/<sha>.html`, `wiki/index.html`,22 +//! `commits.html`, `commit/<sha>.html`, `feed.atom`, `wiki/index.html`,23 //! `wiki/<path>.html`, `repo.bundle` + `repo.tar.gz`, and `style.css`.24 //!25 //! Bodies from the repo are semi-trusted but treated as untrusted:26 @@ -13,7 +13,7 @@ use std::collections::BTreeMap;27 use std::path::Path;28 29 use crate::layout::esc;30 -use crate::{commits, gitx, wiki};31 +use crate::{commits, feed, gitx, wiki};32 33 /// Files at or above this size get a placeholder page + download link.34 pub const PREVIEW_MAX_BYTES: usize = 256 * 1024;35 @@ -337,6 +337,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu36 // Commits + wiki (m2): untrusted messages/diffs/bodies escaped inside.37 let nav = nav_code();38 commits::render(repo, out, &branch, opts.max_log, &name, &nav)?;39 + feed::render(repo, out, &branch, &name)?;40 wiki::render(repo, out, &name, &nav)?;41 42 // Snapshot last so the index can link sizes.43 diff --git a/src/feed.rs b/src/feed.rs44 new file mode 10064445 index 0000000..2fa59f846 --- /dev/null47 +++ b/src/feed.rs48 @@ -0,0 +1,397 @@49 +//! `feed.atom` (m2, f0rge issue #6): 50 entries max, commits +50 +//! release notes mixed newest-first, written at build time.51 +//!52 +//! No rss/atom crate (std only): hand-rolled XML writer with [`esc`].53 +//! Each entry carries title, `urn:` id, RFC3339 `updated`, link, and an54 +//! escaped summary.55 +//!56 +//! Releases: no `src/releases.rs` reader exists yet, so this is57 +//! commits-only for now.58 +//! TODO(m3): mix in `.forge/releases/<version>.md` notes newest-first59 +//! once the m3 releases reader lands (see [`RELEASES_SUPPORTED`] +60 +//! [`read_release_entries`]).61 +62 +use std::path::Path;63 +64 +use crate::gitx;65 +66 +/// Max entries in `feed.atom` (DRAFT: 50, commits + releases mixed).67 +pub const MAX_ENTRIES: usize = 50;68 +69 +/// Test hook for m3: `false` while no release reader exists in `src/`.70 +///71 +/// When `src/releases.rs` (or equivalent) lands, set this to `true` and72 +/// implement [`read_release_entries`]; the `releases_required_when_present`73 +/// test flips from "commits-only tolerated" to "release entries required".74 +// TODO(m3): set to true once the releases reader exists.75 +pub const RELEASES_SUPPORTED: bool = false;76 +77 +/// One Atom entry (commit or release note).78 +#[derive(Debug, Clone, PartialEq, Eq)]79 +pub struct FeedEntry {80 + /// Entry title (commit subject / release title).81 + pub title: String,82 + /// `urn:sha:<sha>` for commits, `urn:release:<version>` for releases.83 + pub id: String,84 + /// RFC3339 timestamp (converted from git iso date / release `created`).85 + pub updated: String,86 + /// Site-relative link (`/commit/<sha>.html`, `/releases/...` later).87 + pub link: String,88 + /// Escaped one-line summary.89 + pub summary: String,90 +}91 +92 +/// Escape `&`, `<`, `>`, `"`, `'` for XML text/attribute contexts.93 +#[must_use]94 +pub fn esc(input: &str) -> String {95 + let mut out = String::with_capacity(input.len());96 + for ch in input.chars() {97 + match ch {98 + '&' => out.push_str("&"),99 + '<' => out.push_str("<"),100 + '>' => out.push_str(">"),101 + '"' => out.push_str("""),102 + '\'' => out.push_str("'"),103 + _ => out.push(ch),104 + }105 + }106 + out107 +}108 +109 +/// Convert a `git --date=iso` date (`2024-01-01 00:00:00 +0000`) to110 +/// RFC3339 (`2024-01-01T00:00:00+00:00`).111 +///112 +/// Input already containing `T` (or any unparsable shape) is returned113 +/// trimmed and unchanged; callers always escape it via [`esc`].114 +#[must_use]115 +pub fn to_rfc3339(iso: &str) -> String {116 + let s = iso.trim();117 + if s.contains('T') {118 + return s.to_string();119 + }120 + let parts: Vec<&str> = s.split_whitespace().collect();121 + if parts.len() == 3 && parts[2].len() == 5 {122 + let tz = parts[2];123 + let sign = tz.as_bytes()[0];124 + if (sign == b'+' || sign == b'-') && tz[1..].chars().all(|c| c.is_ascii_digit()) {125 + return format!("{}T{}{}:{}", parts[0], parts[1], &tz[..3], &tz[3..]);126 + }127 + }128 + s.to_string()129 +}130 +131 +/// Read release-note entries for the feed.132 +///133 +/// Commits-only stub: no release reader exists in `src/` yet, so this134 +/// returns an empty vec.135 +// TODO(m3): read `.forge/releases/<version>.md` notes (title/body) here136 +// and return one `FeedEntry` per release (`urn:release:<version>`).137 +#[must_use]138 +pub fn read_release_entries(_repo: &Path) -> Vec<FeedEntry> {139 + // TODO(m3): releases not yet wired; commits-only feed.140 + Vec::new()141 +}142 +143 +/// Collect up to [`MAX_ENTRIES`] entries, commits + releases newest-first.144 +pub fn collect_entries(repo: &Path, branch: &str) -> Result<Vec<FeedEntry>, String> {145 + let log = gitx::log_n(repo, branch, MAX_ENTRIES).map_err(|e| format!("hu13: {e}"))?;146 + let mut entries: Vec<FeedEntry> = log147 + .iter()148 + .map(|c| {149 + let updated = to_rfc3339(&c.date);150 + FeedEntry {151 + title: c.subject.clone(),152 + id: format!("urn:sha:{}", c.sha),153 + updated,154 + link: format!("/commit/{}.html", c.sha),155 + summary: format!("{} — {} ({})", c.subject, c.author, c.date),156 + }157 + })158 + .collect();159 + // TODO(m3): releases mixed in newest-first once the reader lands.160 + let mut releases = read_release_entries(repo);161 + entries.append(&mut releases);162 + // Newest-first by RFC3339 `updated` (lexicographic == chronological).163 + entries.sort_by(|a, b| b.updated.cmp(&a.updated));164 + entries.truncate(MAX_ENTRIES);165 + Ok(entries)166 +}167 +168 +/// Render the full `feed.atom` document for `entries`.169 +#[must_use]170 +pub fn render_feed(site_name: &str, branch: &str, entries: &[FeedEntry]) -> String {171 + let updated = entries172 + .first()173 + .map_or_else(|| "1970-01-01T00:00:00Z".to_string(), |e| esc(&e.updated));174 + let mut out = String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");175 + out.push_str("<feed xmlns=\"http://www.w3.org/2005/Atom\">\n");176 + out.push_str(&format!("<title>{}</title>\n", esc(site_name)));177 + out.push_str(&format!("<id>urn:hu13:feed:{}</id>\n", esc(branch)));178 + out.push_str(&format!("<updated>{updated}</updated>\n"));179 + out.push_str("<author><name>hu13</name></author>\n");180 + if !RELEASES_SUPPORTED {181 + out.push_str("<!-- TODO(m3): releases not yet wired; commits-only feed -->\n");182 + }183 + for e in entries {184 + out.push_str("<entry>\n");185 + out.push_str(&format!("<title>{}</title>\n", esc(&e.title)));186 + out.push_str(&format!("<id>{}</id>\n", esc(&e.id)));187 + out.push_str(&format!("<updated>{}</updated>\n", esc(&e.updated)));188 + out.push_str(&format!("<link href=\"{}\"/>\n", esc(&e.link)));189 + out.push_str(&format!("<summary>{}</summary>\n", esc(&e.summary)));190 + out.push_str("</entry>\n");191 + }192 + out.push_str("</feed>\n");193 + out194 +}195 +196 +/// Build `feed.atom` in `out` (called from [`crate::build::run_build`]197 +/// right after commits). Returns the number of entries written.198 +pub fn render(repo: &Path, out: &Path, branch: &str, site_name: &str) -> Result<usize, String> {199 + let entries = collect_entries(repo, branch)?;200 + let doc = render_feed(site_name, branch, &entries);201 + std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;202 + std::fs::write(out.join("feed.atom"), doc)203 + .map_err(|e| format!("hu13: write feed.atom: {e}"))?;204 + Ok(entries.len())205 +}206 +207 +#[cfg(test)]208 +mod tests {209 + use super::*;210 + use std::path::PathBuf;211 + use std::process::Command;212 + use std::sync::atomic::{AtomicU64, Ordering};213 +214 + static COUNTER: AtomicU64 = AtomicU64::new(0);215 +216 + fn mktemp(prefix: &str) -> PathBuf {217 + let id = COUNTER.fetch_add(1, Ordering::SeqCst);218 + let dir =219 + std::env::temp_dir().join(format!("hu13-feed-{prefix}-{}-{id}", std::process::id()));220 + std::fs::create_dir_all(&dir).expect("mktemp");221 + dir222 + }223 +224 + fn git(repo: &Path, args: &[&str]) {225 + let st = Command::new("git")226 + .arg("-C")227 + .arg(repo)228 + .args(args)229 + .status()230 + .expect("spawn git");231 + assert!(st.success(), "git {args:?} failed in {}", repo.display());232 + }233 +234 + fn git_dated(repo: &Path, msg: &str, date: &str) {235 + let st = Command::new("git")236 + .arg("-C")237 + .arg(repo)238 + .arg("commit")239 + .arg("-m")240 + .arg(msg)241 + .env("GIT_AUTHOR_DATE", date)242 + .env("GIT_COMMITTER_DATE", date)243 + .status()244 + .expect("spawn git commit");245 + assert!(st.success(), "dated commit {msg:?} failed");246 + }247 +248 + /// Fixture: 3 commits with explicit increasing dates.249 + fn fixture_repo() -> PathBuf {250 + let dir = mktemp("fixture");251 + git(&dir, &["init", "-b", "main"]);252 + git(&dir, &["config", "user.name", "hu13-test"]);253 + git(&dir, &["config", "user.email", "[email protected]"]);254 + git(&dir, &["config", "commit.gpgsign", "false"]);255 + std::fs::write(dir.join("a.txt"), "one\n").unwrap();256 + git(&dir, &["add", "-A"]);257 + git_dated(&dir, "oldest commit", "2024-01-01T00:00:00+00:00");258 + std::fs::write(dir.join("b.txt"), "two\n").unwrap();259 + git(&dir, &["add", "-A"]);260 + git_dated(&dir, "middle commit", "2024-06-01T00:00:00+00:00");261 + std::fs::write(dir.join("c.txt"), "three\n").unwrap();262 + git(&dir, &["add", "-A"]);263 + git_dated(264 + &dir,265 + "newest commit <b>bold</b>",266 + "2025-01-01T00:00:00+00:00",267 + );268 + dir269 + }270 +271 + /// Minimal well-formedness check: angle-balance plus a tiny tag-stack272 + /// parse over the known Atom shape (feed/entry/title/id/updated/link/273 + /// summary/author/name + xml prolog + TODO comment).274 + fn check_well_formed(xml: &str) {275 + let opens = xml.bytes().filter(|b| *b == b'<').count();276 + let closes = xml.bytes().filter(|b| *b == b'>').count();277 + assert_eq!(opens, closes, "angle-balance: {opens} < vs {closes} >");278 + assert!(xml.starts_with("<?xml"), "xml prolog present");279 + let mut stack: Vec<String> = Vec::new();280 + let bytes = xml.as_bytes();281 + let mut i = 0;282 + while i < bytes.len() {283 + if bytes[i] != b'<' {284 + i += 1;285 + continue;286 + }287 + let Some(end) = xml[i..].find('>') else {288 + panic!("unterminated tag at offset {i}");289 + };290 + let tag = &xml[i + 1..i + end];291 + i += end + 1;292 + if tag.starts_with('?') || tag.starts_with('!') {293 + continue; // prolog / comment, no stack effect294 + }295 + if let Some(stripped) = tag.strip_prefix('/') {296 + let name = stripped.trim().to_string();297 + let open = stack.pop().expect("closing tag without opener");298 + assert_eq!(open, name, "mismatched close: {name}");299 + } else if tag.ends_with('/') {300 + continue; // self-closing <link .../>301 + } else {302 + let name = tag.split_whitespace().next().unwrap_or("").to_string();303 + assert!(!name.is_empty(), "empty tag name");304 + stack.push(name);305 + }306 + }307 + assert!(stack.is_empty(), "unclosed tags: {stack:?}");308 + }309 +310 + fn entry_titles(xml: &str) -> Vec<String> {311 + let mut titles = Vec::new();312 + let mut rest = xml;313 + while let Some(s) = rest.find("<entry>") {314 + rest = &rest[s + "<entry>".len()..];315 + let e = rest.find("</entry>").expect("entry close");316 + let body = &rest[..e];317 + let t0 = body.find("<title>").expect("entry title open") + "<title>".len();318 + let t1 = body.find("</title>").expect("entry title close");319 + titles.push(body[t0..t1].to_string());320 + rest = &rest[e + "</entry>".len()..];321 + }322 + titles323 + }324 +325 + #[test]326 + fn esc_escapes_xml_specials() {327 + assert_eq!(esc("<>&\"'"), "<>&"'");328 + assert_eq!(esc("plain 123"), "plain 123");329 + }330 +331 + #[test]332 + fn dates_become_rfc3339() {333 + assert_eq!(334 + to_rfc3339("2024-01-01 00:00:00 +0000"),335 + "2024-01-01T00:00:00+00:00"336 + );337 + assert_eq!(338 + to_rfc3339("2025-09-08 02:00:00 +0530"),339 + "2025-09-08T02:00:00+05:30"340 + );341 + assert_eq!(to_rfc3339("2025-09-08T02:00:00Z"), "2025-09-08T02:00:00Z");342 + }343 +344 + #[test]345 + fn feed_well_formed_count_capped_newest_first() {346 + let repo = fixture_repo();347 + let entries = collect_entries(&repo, "main").expect("collect");348 + assert!(entries.len() <= MAX_ENTRIES);349 + let xml = render_feed("test-site", "main", &entries);350 + check_well_formed(&xml);351 + // Newest-first on the dated fixture.352 + let titles = entry_titles(&xml);353 + assert_eq!(titles.len(), 3);354 + assert!(titles[0].contains("newest commit"), "first: {titles:?}");355 + assert!(titles[1].contains("middle commit"), "second: {titles:?}");356 + assert!(titles[2].contains("oldest commit"), "third: {titles:?}");357 + // Entry shape: urn id, RFC3339 updated, link, escaped summary.358 + assert!(xml.contains("urn:sha:"), "urn ids");359 + assert!(xml.contains("2025-01-01T00:00:00+00:00"), "RFC3339 date");360 + assert!(xml.contains("/commit/"), "detail links");361 + assert!(362 + !xml.contains("<b>bold</b>"),363 + "subject summary escaped, not raw HTML"364 + );365 + assert!(366 + xml.contains("<b>bold</b>"),367 + "escaped form present"368 + );369 + }370 +371 + #[test]372 + fn synthetic_entries_truncate_at_50() {373 + let entries: Vec<FeedEntry> = (0..60)374 + .map(|i| FeedEntry {375 + title: format!("entry {i:02}"),376 + id: format!("urn:sha:{i:040}"),377 + updated: format!("2025-01-{:02}T00:00:00Z", (i % 28) + 1),378 + link: format!("/commit/{i}.html"),379 + summary: format!("summary {i}"),380 + })381 + .collect();382 + let mut sorted = entries.clone();383 + sorted.sort_by(|a, b| b.updated.cmp(&a.updated));384 + sorted.truncate(MAX_ENTRIES);385 + let xml = render_feed("test", "main", &sorted);386 + check_well_formed(&xml);387 + assert_eq!(xml.matches("<entry>").count(), 50, "capped at 50");388 + }389 +390 + #[test]391 + fn releases_required_when_present() {392 + // Fixture WITH a releases dir: tolerates commits-only now393 + // (RELEASES_SUPPORTED == false), but REQUIRES release entries394 + // once the m3 reader lands (flag flips to true).395 + let repo = fixture_repo();396 + std::fs::create_dir_all(repo.join(".forge").join("releases")).unwrap();397 + std::fs::write(398 + repo.join(".forge").join("releases").join("v1.0.md"),399 + "---\nversion: v1.0\ncreated: 2025-06-01T00:00:00Z\n---\n# v1.0 notes\n",400 + )401 + .unwrap();402 + let entries = collect_entries(&repo, "main").expect("collect");403 + let xml = render_feed("test", "main", &entries);404 + check_well_formed(&xml);405 + if RELEASES_SUPPORTED {406 + assert!(407 + xml.contains("v1.0"),408 + "release entries required when releases/ dir exists (m3 landed)"409 + );410 + assert!(411 + xml.contains("urn:release:"),412 + "release urn ids required once m3 lands"413 + );414 + } else {415 + // TODO(m3): commits-only tolerated until the releases reader lands.416 + assert!(417 + xml.contains("TODO(m3)"),418 + "commits-only marker while releases unwired"419 + );420 + }421 + }422 +423 + #[test]424 + fn build_wires_feed_atom() {425 + // End-to-end: full build emits feed.atom right after commits.426 + let repo = fixture_repo();427 + let out = mktemp("out");428 + let opts = crate::build::BuildOptions {429 + branch_flag: None,430 + clone_url_flag: None,431 + title_override: None,432 + max_log: 100,433 + };434 + crate::build::run_build(&repo, &out, &opts).expect("build");435 + assert!(out.join("commits.html").exists(), "commits built first");436 + let feed = std::fs::read_to_string(out.join("feed.atom")).expect("feed.atom emitted");437 + check_well_formed(&feed);438 + assert!(feed.matches("<entry>").count() <= MAX_ENTRIES);439 + let titles = entry_titles(&feed);440 + assert!(441 + titles[0].contains("newest commit"),442 + "newest first: {titles:?}"443 + );444 + }445 +}446 diff --git a/src/main.rs b/src/main.rs447 index 589e3bd..d15fb9e 100644448 --- a/src/main.rs449 +++ b/src/main.rs450 @@ -1,5 +1,6 @@451 mod build;452 mod commits;453 +mod feed;454 mod gitx;455 mod layout;456 mod wiki;457 -- 458 2.55.0459