#6 m2/feed

mergedpassopened
details
branches
master ← m2/feed
updated
id
fe2355ca-9204-4dec-9c70-f01b83a4e0e9

Patch

src/build.rs+3 −2
diff --git a/src/build.rs b/src/build.rsindex 4530f1a..ae9f381 100644--- 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
diff --git a/src/feed.rs b/src/feed.rsnew file mode 100644index 0000000..2fa59f8--- /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("&amp;"),51+            '<' => out.push_str("&lt;"),52+            '>' => out.push_str("&gt;"),53+            '"' => out.push_str("&quot;"),54+            '\'' => out.push_str("&apos;"),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("<>&\"'"), "&lt;&gt;&amp;&quot;&apos;");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("&lt;b&gt;bold&lt;/b&gt;"),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
diff --git a/src/main.rs b/src/main.rsindex 589e3bd..d15fb9e 100644--- 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-- 2.55.0

Comments (0)

No comments yet.

Reviews (0)

No reviews yet.

all pulls