.forge/prs/fe2355ca-9204-4dec-9c70-f01b83a4e0e9/patch.diff 17.4 KiB 459 lines Raw
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("&amp;"),99 +            '<' => out.push_str("&lt;"),100 +            '>' => out.push_str("&gt;"),101 +            '"' => out.push_str("&quot;"),102 +            '\'' => out.push_str("&apos;"),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("<>&\"'"), "&lt;&gt;&amp;&quot;&apos;");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("&lt;b&gt;bold&lt;/b&gt;"),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