From ab1af5575d38e08fb595f9f4c70501f19be2bb4e Mon Sep 17 00:00:00 2001 From: gl0w Date: Thu, 10 Sep 2026 05:19:45 +0100 Subject: [PATCH] m2/feed: feed.atom 50-max, commits+releases (43 tests) --- src/build.rs | 5 +- src/feed.rs | 397 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + 3 files changed, 401 insertions(+), 2 deletions(-) create mode 100644 src/feed.rs diff --git a/src/build.rs b/src/build.rs index 4530f1a..ae9f381 100644 --- a/src/build.rs +++ b/src/build.rs @@ -2,7 +2,7 @@ //! //! Reads one branch via [`crate::gitx`], emits `dist/`: //! `index.html`, `files/.html`, `raw/` (byte-identical), -//! `commits.html`, `commit/.html`, `wiki/index.html`, +//! `commits.html`, `commit/.html`, `feed.atom`, `wiki/index.html`, //! `wiki/.html`, `repo.bundle` + `repo.tar.gz`, and `style.css`. //! //! Bodies from the repo are semi-trusted but treated as untrusted: @@ -13,7 +13,7 @@ use std::collections::BTreeMap; use std::path::Path; use crate::layout::esc; -use crate::{commits, gitx, wiki}; +use crate::{commits, feed, gitx, wiki}; /// Files at or above this size get a placeholder page + download link. pub const PREVIEW_MAX_BYTES: usize = 256 * 1024; @@ -337,6 +337,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result.md` notes newest-first +//! once the m3 releases reader lands (see [`RELEASES_SUPPORTED`] + +//! [`read_release_entries`]). + +use std::path::Path; + +use crate::gitx; + +/// Max entries in `feed.atom` (DRAFT: 50, commits + releases mixed). +pub const MAX_ENTRIES: usize = 50; + +/// Test hook for m3: `false` while no release reader exists in `src/`. +/// +/// When `src/releases.rs` (or equivalent) lands, set this to `true` and +/// implement [`read_release_entries`]; the `releases_required_when_present` +/// test flips from "commits-only tolerated" to "release entries required". +// TODO(m3): set to true once the releases reader exists. +pub const RELEASES_SUPPORTED: bool = false; + +/// One Atom entry (commit or release note). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FeedEntry { + /// Entry title (commit subject / release title). + pub title: String, + /// `urn:sha:` for commits, `urn:release:` for releases. + pub id: String, + /// RFC3339 timestamp (converted from git iso date / release `created`). + pub updated: String, + /// Site-relative link (`/commit/.html`, `/releases/...` later). + pub link: String, + /// Escaped one-line summary. + pub summary: String, +} + +/// Escape `&`, `<`, `>`, `"`, `'` for XML text/attribute contexts. +#[must_use] +pub fn esc(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + for ch in input.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(ch), + } + } + out +} + +/// Convert a `git --date=iso` date (`2024-01-01 00:00:00 +0000`) to +/// RFC3339 (`2024-01-01T00:00:00+00:00`). +/// +/// Input already containing `T` (or any unparsable shape) is returned +/// trimmed and unchanged; callers always escape it via [`esc`]. +#[must_use] +pub fn to_rfc3339(iso: &str) -> String { + let s = iso.trim(); + if s.contains('T') { + return s.to_string(); + } + let parts: Vec<&str> = s.split_whitespace().collect(); + if parts.len() == 3 && parts[2].len() == 5 { + let tz = parts[2]; + let sign = tz.as_bytes()[0]; + if (sign == b'+' || sign == b'-') && tz[1..].chars().all(|c| c.is_ascii_digit()) { + return format!("{}T{}{}:{}", parts[0], parts[1], &tz[..3], &tz[3..]); + } + } + s.to_string() +} + +/// Read release-note entries for the feed. +/// +/// Commits-only stub: no release reader exists in `src/` yet, so this +/// returns an empty vec. +// TODO(m3): read `.forge/releases/.md` notes (title/body) here +// and return one `FeedEntry` per release (`urn:release:`). +#[must_use] +pub fn read_release_entries(_repo: &Path) -> Vec { + // TODO(m3): releases not yet wired; commits-only feed. + Vec::new() +} + +/// Collect up to [`MAX_ENTRIES`] entries, commits + releases newest-first. +pub fn collect_entries(repo: &Path, branch: &str) -> Result, String> { + let log = gitx::log_n(repo, branch, MAX_ENTRIES).map_err(|e| format!("hu13: {e}"))?; + let mut entries: Vec = log + .iter() + .map(|c| { + let updated = to_rfc3339(&c.date); + FeedEntry { + title: c.subject.clone(), + id: format!("urn:sha:{}", c.sha), + updated, + link: format!("/commit/{}.html", c.sha), + summary: format!("{} — {} ({})", c.subject, c.author, c.date), + } + }) + .collect(); + // TODO(m3): releases mixed in newest-first once the reader lands. + let mut releases = read_release_entries(repo); + entries.append(&mut releases); + // Newest-first by RFC3339 `updated` (lexicographic == chronological). + entries.sort_by(|a, b| b.updated.cmp(&a.updated)); + entries.truncate(MAX_ENTRIES); + Ok(entries) +} + +/// Render the full `feed.atom` document for `entries`. +#[must_use] +pub fn render_feed(site_name: &str, branch: &str, entries: &[FeedEntry]) -> String { + let updated = entries + .first() + .map_or_else(|| "1970-01-01T00:00:00Z".to_string(), |e| esc(&e.updated)); + let mut out = String::from("\n"); + out.push_str("\n"); + out.push_str(&format!("{}\n", esc(site_name))); + out.push_str(&format!("urn:hu13:feed:{}\n", esc(branch))); + out.push_str(&format!("{updated}\n")); + out.push_str("hu13\n"); + if !RELEASES_SUPPORTED { + out.push_str("\n"); + } + for e in entries { + out.push_str("\n"); + out.push_str(&format!("{}\n", esc(&e.title))); + out.push_str(&format!("{}\n", esc(&e.id))); + out.push_str(&format!("{}\n", esc(&e.updated))); + out.push_str(&format!("\n", esc(&e.link))); + out.push_str(&format!("{}\n", esc(&e.summary))); + out.push_str("\n"); + } + out.push_str("\n"); + out +} + +/// Build `feed.atom` in `out` (called from [`crate::build::run_build`] +/// right after commits). Returns the number of entries written. +pub fn render(repo: &Path, out: &Path, branch: &str, site_name: &str) -> Result { + let entries = collect_entries(repo, branch)?; + let doc = render_feed(site_name, branch, &entries); + std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?; + std::fs::write(out.join("feed.atom"), doc) + .map_err(|e| format!("hu13: write feed.atom: {e}"))?; + Ok(entries.len()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + use std::process::Command; + use std::sync::atomic::{AtomicU64, Ordering}; + + static COUNTER: AtomicU64 = AtomicU64::new(0); + + fn mktemp(prefix: &str) -> PathBuf { + let id = COUNTER.fetch_add(1, Ordering::SeqCst); + let dir = + std::env::temp_dir().join(format!("hu13-feed-{prefix}-{}-{id}", std::process::id())); + std::fs::create_dir_all(&dir).expect("mktemp"); + dir + } + + fn git(repo: &Path, args: &[&str]) { + let st = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .status() + .expect("spawn git"); + assert!(st.success(), "git {args:?} failed in {}", repo.display()); + } + + fn git_dated(repo: &Path, msg: &str, date: &str) { + let st = Command::new("git") + .arg("-C") + .arg(repo) + .arg("commit") + .arg("-m") + .arg(msg) + .env("GIT_AUTHOR_DATE", date) + .env("GIT_COMMITTER_DATE", date) + .status() + .expect("spawn git commit"); + assert!(st.success(), "dated commit {msg:?} failed"); + } + + /// Fixture: 3 commits with explicit increasing dates. + fn fixture_repo() -> PathBuf { + let dir = mktemp("fixture"); + git(&dir, &["init", "-b", "main"]); + git(&dir, &["config", "user.name", "hu13-test"]); + git(&dir, &["config", "user.email", "hu13@test.local"]); + git(&dir, &["config", "commit.gpgsign", "false"]); + std::fs::write(dir.join("a.txt"), "one\n").unwrap(); + git(&dir, &["add", "-A"]); + git_dated(&dir, "oldest commit", "2024-01-01T00:00:00+00:00"); + std::fs::write(dir.join("b.txt"), "two\n").unwrap(); + git(&dir, &["add", "-A"]); + git_dated(&dir, "middle commit", "2024-06-01T00:00:00+00:00"); + std::fs::write(dir.join("c.txt"), "three\n").unwrap(); + git(&dir, &["add", "-A"]); + git_dated( + &dir, + "newest commit bold", + "2025-01-01T00:00:00+00:00", + ); + dir + } + + /// Minimal well-formedness check: angle-balance plus a tiny tag-stack + /// parse over the known Atom shape (feed/entry/title/id/updated/link/ + /// summary/author/name + xml prolog + TODO comment). + fn check_well_formed(xml: &str) { + let opens = xml.bytes().filter(|b| *b == b'<').count(); + let closes = xml.bytes().filter(|b| *b == b'>').count(); + assert_eq!(opens, closes, "angle-balance: {opens} < vs {closes} >"); + assert!(xml.starts_with(" = Vec::new(); + let bytes = xml.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] != b'<' { + i += 1; + continue; + } + let Some(end) = xml[i..].find('>') else { + panic!("unterminated tag at offset {i}"); + }; + let tag = &xml[i + 1..i + end]; + i += end + 1; + if tag.starts_with('?') || tag.starts_with('!') { + continue; // prolog / comment, no stack effect + } + if let Some(stripped) = tag.strip_prefix('/') { + let name = stripped.trim().to_string(); + let open = stack.pop().expect("closing tag without opener"); + assert_eq!(open, name, "mismatched close: {name}"); + } else if tag.ends_with('/') { + continue; // self-closing + } else { + let name = tag.split_whitespace().next().unwrap_or("").to_string(); + assert!(!name.is_empty(), "empty tag name"); + stack.push(name); + } + } + assert!(stack.is_empty(), "unclosed tags: {stack:?}"); + } + + fn entry_titles(xml: &str) -> Vec { + let mut titles = Vec::new(); + let mut rest = xml; + while let Some(s) = rest.find("") { + rest = &rest[s + "".len()..]; + let e = rest.find("").expect("entry close"); + let body = &rest[..e]; + let t0 = body.find("").expect("entry title open") + "<title>".len(); + let t1 = body.find("").expect("entry title close"); + titles.push(body[t0..t1].to_string()); + rest = &rest[e + "".len()..]; + } + titles + } + + #[test] + fn esc_escapes_xml_specials() { + assert_eq!(esc("<>&\"'"), "<>&"'"); + assert_eq!(esc("plain 123"), "plain 123"); + } + + #[test] + fn dates_become_rfc3339() { + assert_eq!( + to_rfc3339("2024-01-01 00:00:00 +0000"), + "2024-01-01T00:00:00+00:00" + ); + assert_eq!( + to_rfc3339("2025-09-08 02:00:00 +0530"), + "2025-09-08T02:00:00+05:30" + ); + assert_eq!(to_rfc3339("2025-09-08T02:00:00Z"), "2025-09-08T02:00:00Z"); + } + + #[test] + fn feed_well_formed_count_capped_newest_first() { + let repo = fixture_repo(); + let entries = collect_entries(&repo, "main").expect("collect"); + assert!(entries.len() <= MAX_ENTRIES); + let xml = render_feed("test-site", "main", &entries); + check_well_formed(&xml); + // Newest-first on the dated fixture. + let titles = entry_titles(&xml); + assert_eq!(titles.len(), 3); + assert!(titles[0].contains("newest commit"), "first: {titles:?}"); + assert!(titles[1].contains("middle commit"), "second: {titles:?}"); + assert!(titles[2].contains("oldest commit"), "third: {titles:?}"); + // Entry shape: urn id, RFC3339 updated, link, escaped summary. + assert!(xml.contains("urn:sha:"), "urn ids"); + assert!(xml.contains("2025-01-01T00:00:00+00:00"), "RFC3339 date"); + assert!(xml.contains("/commit/"), "detail links"); + assert!( + !xml.contains("bold"), + "subject summary escaped, not raw HTML" + ); + assert!( + xml.contains("<b>bold</b>"), + "escaped form present" + ); + } + + #[test] + fn synthetic_entries_truncate_at_50() { + let entries: Vec = (0..60) + .map(|i| FeedEntry { + title: format!("entry {i:02}"), + id: format!("urn:sha:{i:040}"), + updated: format!("2025-01-{:02}T00:00:00Z", (i % 28) + 1), + link: format!("/commit/{i}.html"), + summary: format!("summary {i}"), + }) + .collect(); + let mut sorted = entries.clone(); + sorted.sort_by(|a, b| b.updated.cmp(&a.updated)); + sorted.truncate(MAX_ENTRIES); + let xml = render_feed("test", "main", &sorted); + check_well_formed(&xml); + assert_eq!(xml.matches("").count(), 50, "capped at 50"); + } + + #[test] + fn releases_required_when_present() { + // Fixture WITH a releases dir: tolerates commits-only now + // (RELEASES_SUPPORTED == false), but REQUIRES release entries + // once the m3 reader lands (flag flips to true). + let repo = fixture_repo(); + std::fs::create_dir_all(repo.join(".forge").join("releases")).unwrap(); + std::fs::write( + repo.join(".forge").join("releases").join("v1.0.md"), + "---\nversion: v1.0\ncreated: 2025-06-01T00:00:00Z\n---\n# v1.0 notes\n", + ) + .unwrap(); + let entries = collect_entries(&repo, "main").expect("collect"); + let xml = render_feed("test", "main", &entries); + check_well_formed(&xml); + if RELEASES_SUPPORTED { + assert!( + xml.contains("v1.0"), + "release entries required when releases/ dir exists (m3 landed)" + ); + assert!( + xml.contains("urn:release:"), + "release urn ids required once m3 lands" + ); + } else { + // TODO(m3): commits-only tolerated until the releases reader lands. + assert!( + xml.contains("TODO(m3)"), + "commits-only marker while releases unwired" + ); + } + } + + #[test] + fn build_wires_feed_atom() { + // End-to-end: full build emits feed.atom right after commits. + let repo = fixture_repo(); + let out = mktemp("out"); + let opts = crate::build::BuildOptions { + branch_flag: None, + clone_url_flag: None, + title_override: None, + max_log: 100, + }; + crate::build::run_build(&repo, &out, &opts).expect("build"); + assert!(out.join("commits.html").exists(), "commits built first"); + let feed = std::fs::read_to_string(out.join("feed.atom")).expect("feed.atom emitted"); + check_well_formed(&feed); + assert!(feed.matches("").count() <= MAX_ENTRIES); + let titles = entry_titles(&feed); + assert!( + titles[0].contains("newest commit"), + "newest first: {titles:?}" + ); + } +} diff --git a/src/main.rs b/src/main.rs index 589e3bd..d15fb9e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod build; mod commits; +mod feed; mod gitx; mod layout; mod wiki; -- 2.55.0