#6 m2/feed
Patch
src/build.rs+3 −2--- a/src/build.rs+++ b/src/build.rs@@ -2,7 +2,7 @@22 //!33 //! Reads one branch via [`crate::gitx`], emits `dist/`:44 //! `index.html`, `files/<path>.html`, `raw/<path>` (byte-identical),5-//! `commits.html`, `commit/<sha>.html`, `wiki/index.html`,5+//! `commits.html`, `commit/<sha>.html`, `feed.atom`, `wiki/index.html`,66 //! `wiki/<path>.html`, `repo.bundle` + `repo.tar.gz`, and `style.css`.77 //!88 //! Bodies from the repo are semi-trusted but treated as untrusted:@@ -13,7 +13,7 @@ use std::collections::BTreeMap;1313 use std::path::Path;1414 1515 use crate::layout::esc;16-use crate::{commits, gitx, wiki};16+use crate::{commits, feed, gitx, wiki};1717 1818 /// Files at or above this size get a placeholder page + download link.1919 pub const PREVIEW_MAX_BYTES: usize = 256 * 1024;@@ -337,6 +337,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu337337 // Commits + wiki (m2): untrusted messages/diffs/bodies escaped inside.338338 let nav = nav_code();339339 commits::render(repo, out, &branch, opts.max_log, &name, &nav)?;340+ feed::render(repo, out, &branch, &name)?;340341 wiki::render(repo, out, &name, &nav)?;341342 342343 // Snapshot last so the index can link sizes.
src/feed.rs+397 −0--- /dev/null+++ b/src/feed.rs@@ -0,0 +1,397 @@1+//! `feed.atom` (m2, f0rge issue #6): 50 entries max, commits +2+//! release notes mixed newest-first, written at build time.3+//!4+//! No rss/atom crate (std only): hand-rolled XML writer with [`esc`].5+//! Each entry carries title, `urn:` id, RFC3339 `updated`, link, and an6+//! 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`]).13+14+use std::path::Path;15+16+use crate::gitx;17+18+/// Max entries in `feed.atom` (DRAFT: 50, commits + releases mixed).19+pub const MAX_ENTRIES: usize = 50;20+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;28+29+/// One Atom entry (commit or release note).30+#[derive(Debug, Clone, PartialEq, Eq)]31+pub struct FeedEntry {32+ /// Entry title (commit subject / release title).33+ pub title: String,34+ /// `urn:sha:<sha>` for commits, `urn:release:<version>` for releases.35+ pub id: String,36+ /// RFC3339 timestamp (converted from git iso date / release `created`).37+ pub updated: String,38+ /// Site-relative link (`/commit/<sha>.html`, `/releases/...` later).39+ pub link: String,40+ /// Escaped one-line summary.41+ pub summary: String,42+}43+44+/// Escape `&`, `<`, `>`, `"`, `'` for XML text/attribute contexts.45+#[must_use]46+pub fn esc(input: &str) -> String {47+ let mut out = String::with_capacity(input.len());48+ for ch in input.chars() {49+ match ch {50+ '&' => out.push_str("&"),51+ '<' => out.push_str("<"),52+ '>' => out.push_str(">"),53+ '"' => out.push_str("""),54+ '\'' => out.push_str("'"),55+ _ => out.push(ch),56+ }57+ }58+ out59+}60+61+/// Convert a `git --date=iso` date (`2024-01-01 00:00:00 +0000`) to62+/// RFC3339 (`2024-01-01T00:00:00+00:00`).63+///64+/// Input already containing `T` (or any unparsable shape) is returned65+/// trimmed and unchanged; callers always escape it via [`esc`].66+#[must_use]67+pub fn to_rfc3339(iso: &str) -> String {68+ let s = iso.trim();69+ if s.contains('T') {70+ return s.to_string();71+ }72+ let parts: Vec<&str> = s.split_whitespace().collect();73+ if parts.len() == 3 && parts[2].len() == 5 {74+ let tz = parts[2];75+ let sign = tz.as_bytes()[0];76+ if (sign == b'+' || sign == b'-') && tz[1..].chars().all(|c| c.is_ascii_digit()) {77+ return format!("{}T{}{}:{}", parts[0], parts[1], &tz[..3], &tz[3..]);78+ }79+ }80+ s.to_string()81+}82+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>`).89+#[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()93+}94+95+/// Collect up to [`MAX_ENTRIES`] entries, commits + releases newest-first.96+pub fn collect_entries(repo: &Path, branch: &str) -> Result<Vec<FeedEntry>, String> {97+ let log = gitx::log_n(repo, branch, MAX_ENTRIES).map_err(|e| format!("hu13: {e}"))?;98+ let mut entries: Vec<FeedEntry> = log99+ .iter()100+ .map(|c| {101+ let updated = to_rfc3339(&c.date);102+ FeedEntry {103+ title: c.subject.clone(),104+ id: format!("urn:sha:{}", c.sha),105+ updated,106+ link: format!("/commit/{}.html", c.sha),107+ summary: format!("{} — {} ({})", c.subject, c.author, c.date),108+ }109+ })110+ .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);114+ // Newest-first by RFC3339 `updated` (lexicographic == chronological).115+ entries.sort_by(|a, b| b.updated.cmp(&a.updated));116+ entries.truncate(MAX_ENTRIES);117+ Ok(entries)118+}119+120+/// Render the full `feed.atom` document for `entries`.121+#[must_use]122+pub fn render_feed(site_name: &str, branch: &str, entries: &[FeedEntry]) -> String {123+ let updated = entries124+ .first()125+ .map_or_else(|| "1970-01-01T00:00:00Z".to_string(), |e| esc(&e.updated));126+ let mut out = String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");127+ out.push_str("<feed xmlns=\"http://www.w3.org/2005/Atom\">\n");128+ out.push_str(&format!("<title>{}</title>\n", esc(site_name)));129+ out.push_str(&format!("<id>urn:hu13:feed:{}</id>\n", esc(branch)));130+ out.push_str(&format!("<updated>{updated}</updated>\n"));131+ 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+ }135+ for e in entries {136+ out.push_str("<entry>\n");137+ out.push_str(&format!("<title>{}</title>\n", esc(&e.title)));138+ out.push_str(&format!("<id>{}</id>\n", esc(&e.id)));139+ out.push_str(&format!("<updated>{}</updated>\n", esc(&e.updated)));140+ out.push_str(&format!("<link href=\"{}\"/>\n", esc(&e.link)));141+ out.push_str(&format!("<summary>{}</summary>\n", esc(&e.summary)));142+ out.push_str("</entry>\n");143+ }144+ out.push_str("</feed>\n");145+ out146+}147+148+/// Build `feed.atom` in `out` (called from [`crate::build::run_build`]149+/// right after commits). Returns the number of entries written.150+pub fn render(repo: &Path, out: &Path, branch: &str, site_name: &str) -> Result<usize, String> {151+ let entries = collect_entries(repo, branch)?;152+ let doc = render_feed(site_name, branch, &entries);153+ std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;154+ std::fs::write(out.join("feed.atom"), doc)155+ .map_err(|e| format!("hu13: write feed.atom: {e}"))?;156+ Ok(entries.len())157+}158+159+#[cfg(test)]160+mod tests {161+ use super::*;162+ use std::path::PathBuf;163+ use std::process::Command;164+ use std::sync::atomic::{AtomicU64, Ordering};165+166+ static COUNTER: AtomicU64 = AtomicU64::new(0);167+168+ fn mktemp(prefix: &str) -> PathBuf {169+ let id = COUNTER.fetch_add(1, Ordering::SeqCst);170+ let dir =171+ std::env::temp_dir().join(format!("hu13-feed-{prefix}-{}-{id}", std::process::id()));172+ std::fs::create_dir_all(&dir).expect("mktemp");173+ dir174+ }175+176+ fn git(repo: &Path, args: &[&str]) {177+ let st = Command::new("git")178+ .arg("-C")179+ .arg(repo)180+ .args(args)181+ .status()182+ .expect("spawn git");183+ assert!(st.success(), "git {args:?} failed in {}", repo.display());184+ }185+186+ fn git_dated(repo: &Path, msg: &str, date: &str) {187+ let st = Command::new("git")188+ .arg("-C")189+ .arg(repo)190+ .arg("commit")191+ .arg("-m")192+ .arg(msg)193+ .env("GIT_AUTHOR_DATE", date)194+ .env("GIT_COMMITTER_DATE", date)195+ .status()196+ .expect("spawn git commit");197+ assert!(st.success(), "dated commit {msg:?} failed");198+ }199+200+ /// Fixture: 3 commits with explicit increasing dates.201+ fn fixture_repo() -> PathBuf {202+ let dir = mktemp("fixture");203+ git(&dir, &["init", "-b", "main"]);204+ git(&dir, &["config", "user.name", "hu13-test"]);205+ git(&dir, &["config", "user.email", "[email protected]"]);206+ git(&dir, &["config", "commit.gpgsign", "false"]);207+ std::fs::write(dir.join("a.txt"), "one\n").unwrap();208+ git(&dir, &["add", "-A"]);209+ git_dated(&dir, "oldest commit", "2024-01-01T00:00:00+00:00");210+ std::fs::write(dir.join("b.txt"), "two\n").unwrap();211+ git(&dir, &["add", "-A"]);212+ git_dated(&dir, "middle commit", "2024-06-01T00:00:00+00:00");213+ std::fs::write(dir.join("c.txt"), "three\n").unwrap();214+ git(&dir, &["add", "-A"]);215+ git_dated(216+ &dir,217+ "newest commit <b>bold</b>",218+ "2025-01-01T00:00:00+00:00",219+ );220+ dir221+ }222+223+ /// Minimal well-formedness check: angle-balance plus a tiny tag-stack224+ /// parse over the known Atom shape (feed/entry/title/id/updated/link/225+ /// summary/author/name + xml prolog + TODO comment).226+ fn check_well_formed(xml: &str) {227+ let opens = xml.bytes().filter(|b| *b == b'<').count();228+ let closes = xml.bytes().filter(|b| *b == b'>').count();229+ assert_eq!(opens, closes, "angle-balance: {opens} < vs {closes} >");230+ assert!(xml.starts_with("<?xml"), "xml prolog present");231+ let mut stack: Vec<String> = Vec::new();232+ let bytes = xml.as_bytes();233+ let mut i = 0;234+ while i < bytes.len() {235+ if bytes[i] != b'<' {236+ i += 1;237+ continue;238+ }239+ let Some(end) = xml[i..].find('>') else {240+ panic!("unterminated tag at offset {i}");241+ };242+ let tag = &xml[i + 1..i + end];243+ i += end + 1;244+ if tag.starts_with('?') || tag.starts_with('!') {245+ continue; // prolog / comment, no stack effect246+ }247+ if let Some(stripped) = tag.strip_prefix('/') {248+ let name = stripped.trim().to_string();249+ let open = stack.pop().expect("closing tag without opener");250+ assert_eq!(open, name, "mismatched close: {name}");251+ } else if tag.ends_with('/') {252+ continue; // self-closing <link .../>253+ } else {254+ let name = tag.split_whitespace().next().unwrap_or("").to_string();255+ assert!(!name.is_empty(), "empty tag name");256+ stack.push(name);257+ }258+ }259+ assert!(stack.is_empty(), "unclosed tags: {stack:?}");260+ }261+262+ fn entry_titles(xml: &str) -> Vec<String> {263+ let mut titles = Vec::new();264+ let mut rest = xml;265+ while let Some(s) = rest.find("<entry>") {266+ rest = &rest[s + "<entry>".len()..];267+ let e = rest.find("</entry>").expect("entry close");268+ let body = &rest[..e];269+ let t0 = body.find("<title>").expect("entry title open") + "<title>".len();270+ let t1 = body.find("</title>").expect("entry title close");271+ titles.push(body[t0..t1].to_string());272+ rest = &rest[e + "</entry>".len()..];273+ }274+ titles275+ }276+277+ #[test]278+ fn esc_escapes_xml_specials() {279+ assert_eq!(esc("<>&\"'"), "<>&"'");280+ assert_eq!(esc("plain 123"), "plain 123");281+ }282+283+ #[test]284+ fn dates_become_rfc3339() {285+ assert_eq!(286+ to_rfc3339("2024-01-01 00:00:00 +0000"),287+ "2024-01-01T00:00:00+00:00"288+ );289+ assert_eq!(290+ to_rfc3339("2025-09-08 02:00:00 +0530"),291+ "2025-09-08T02:00:00+05:30"292+ );293+ assert_eq!(to_rfc3339("2025-09-08T02:00:00Z"), "2025-09-08T02:00:00Z");294+ }295+296+ #[test]297+ fn feed_well_formed_count_capped_newest_first() {298+ let repo = fixture_repo();299+ let entries = collect_entries(&repo, "main").expect("collect");300+ assert!(entries.len() <= MAX_ENTRIES);301+ let xml = render_feed("test-site", "main", &entries);302+ check_well_formed(&xml);303+ // Newest-first on the dated fixture.304+ let titles = entry_titles(&xml);305+ assert_eq!(titles.len(), 3);306+ assert!(titles[0].contains("newest commit"), "first: {titles:?}");307+ assert!(titles[1].contains("middle commit"), "second: {titles:?}");308+ assert!(titles[2].contains("oldest commit"), "third: {titles:?}");309+ // Entry shape: urn id, RFC3339 updated, link, escaped summary.310+ assert!(xml.contains("urn:sha:"), "urn ids");311+ assert!(xml.contains("2025-01-01T00:00:00+00:00"), "RFC3339 date");312+ assert!(xml.contains("/commit/"), "detail links");313+ assert!(314+ !xml.contains("<b>bold</b>"),315+ "subject summary escaped, not raw HTML"316+ );317+ assert!(318+ xml.contains("<b>bold</b>"),319+ "escaped form present"320+ );321+ }322+323+ #[test]324+ fn synthetic_entries_truncate_at_50() {325+ let entries: Vec<FeedEntry> = (0..60)326+ .map(|i| FeedEntry {327+ title: format!("entry {i:02}"),328+ id: format!("urn:sha:{i:040}"),329+ updated: format!("2025-01-{:02}T00:00:00Z", (i % 28) + 1),330+ link: format!("/commit/{i}.html"),331+ summary: format!("summary {i}"),332+ })333+ .collect();334+ let mut sorted = entries.clone();335+ sorted.sort_by(|a, b| b.updated.cmp(&a.updated));336+ sorted.truncate(MAX_ENTRIES);337+ let xml = render_feed("test", "main", &sorted);338+ check_well_formed(&xml);339+ assert_eq!(xml.matches("<entry>").count(), 50, "capped at 50");340+ }341+342+ #[test]343+ 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).347+ let repo = fixture_repo();348+ std::fs::create_dir_all(repo.join(".forge").join("releases")).unwrap();349+ std::fs::write(350+ repo.join(".forge").join("releases").join("v1.0.md"),351+ "---\nversion: v1.0\ncreated: 2025-06-01T00:00:00Z\n---\n# v1.0 notes\n",352+ )353+ .unwrap();354+ let entries = collect_entries(&repo, "main").expect("collect");355+ let xml = render_feed("test", "main", &entries);356+ 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+ }373+ }374+375+ #[test]376+ fn build_wires_feed_atom() {377+ // End-to-end: full build emits feed.atom right after commits.378+ let repo = fixture_repo();379+ let out = mktemp("out");380+ let opts = crate::build::BuildOptions {381+ branch_flag: None,382+ clone_url_flag: None,383+ title_override: None,384+ max_log: 100,385+ };386+ crate::build::run_build(&repo, &out, &opts).expect("build");387+ assert!(out.join("commits.html").exists(), "commits built first");388+ let feed = std::fs::read_to_string(out.join("feed.atom")).expect("feed.atom emitted");389+ check_well_formed(&feed);390+ assert!(feed.matches("<entry>").count() <= MAX_ENTRIES);391+ let titles = entry_titles(&feed);392+ assert!(393+ titles[0].contains("newest commit"),394+ "newest first: {titles:?}"395+ );396+ }397+}
src/main.rs+1 −1--- a/src/main.rs+++ b/src/main.rs@@ -1,5 +1,6 @@11 mod build;22 mod commits;3+mod feed;34 mod gitx;45 mod layout;56 mod wiki;6--
Comments (0)
No comments yet.
Reviews (0)
No reviews yet.