src/feed.rs 17.7 KiB 494 lines Raw
1 //! `feed.atom` (m2/m3, f0rge issues #6 + #8): 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 use std::path::Path;9 10 use crate::build::encode_path;11 use crate::gitx;12 use crate::releases;13 14 /// Max entries in `feed.atom` (DRAFT: 50, commits + releases mixed).15 pub const MAX_ENTRIES: usize = 50;16 17 /// Releases are wired in (m3 releases reader): `true`.18 #[allow(dead_code)]19 pub const RELEASES_SUPPORTED: bool = true;20 21 /// One Atom entry (commit or release note).22 #[derive(Debug, Clone, PartialEq, Eq)]23 pub struct FeedEntry {24     /// Entry title (commit subject / release title).25     pub title: String,26     /// `urn:sha:<sha>` for commits, `urn:release:<version>` for releases.27     pub id: String,28     /// RFC3339 timestamp (converted from git iso date / release `created`).29     pub updated: String,30     /// Site-relative link (`/commit/<sha>.html`, `/releases/<ver>.html`).31     pub link: String,32     /// Escaped one-line summary.33     pub summary: String,34 }35 36 /// Escape `&`, `<`, `>`, `"`, `'` for XML text/attribute contexts.37 #[must_use]38 pub fn esc(input: &str) -> String {39     let mut out = String::with_capacity(input.len());40     for ch in input.chars() {41         match ch {42             '&' => out.push_str("&amp;"),43             '<' => out.push_str("&lt;"),44             '>' => out.push_str("&gt;"),45             '"' => out.push_str("&quot;"),46             '\'' => out.push_str("&apos;"),47             _ => out.push(ch),48         }49     }50     out51 }52 53 /// Convert a `git --date=iso` date (`2024-01-01 00:00:00 +0000`) to54 /// RFC3339 (`2024-01-01T00:00:00+00:00`).55 ///56 /// Input already containing `T` (or any unparsable shape) is returned57 /// trimmed and unchanged; callers always escape it via [`esc`].58 #[must_use]59 pub fn to_rfc3339(iso: &str) -> String {60     let s = iso.trim();61     if s.contains('T') {62         return s.to_string();63     }64     let parts: Vec<&str> = s.split_whitespace().collect();65     if parts.len() == 3 && parts[2].len() == 5 {66         let tz = parts[2];67         let sign = tz.as_bytes()[0];68         if (sign == b'+' || sign == b'-') && tz[1..].chars().all(|c| c.is_ascii_digit()) {69             return format!("{}T{}{}:{}", parts[0], parts[1], &tz[..3], &tz[3..]);70         }71     }72     s.to_string()73 }74 75 /// Read release-note entries for the feed from the same reader used for76 /// HTML ([`releases::load`], no divergent logic): one entry per release77 /// (`urn:release:<version>`, link `/releases/<ver>.html`).78 #[must_use]79 pub fn read_release_entries(repo: &Path) -> Vec<FeedEntry> {80     releases::load(repo)81         .into_iter()82         .map(|r| {83             let first_line = r84                 .body85                 .lines()86                 .map(str::trim)87                 .find(|l| !l.is_empty())88                 .unwrap_or("")89                 .to_string();90             let summary = if first_line.is_empty() {91                 format!("release {} ({})", r.version, r.created)92             } else {93                 // 200 Unicode scalar values, never a byte offset: a94                 // multibyte cut used to panic in `truncate` (f0rge #99).95                 first_line.chars().take(200).collect()96             };97             FeedEntry {98                 title: r.display_title().to_string(),99                 id: format!("urn:release:{}", encode_path(&r.version)),100                 updated: r.created.clone(),101                 link: format!("/releases/{}.html", encode_path(&r.version)),102                 summary,103             }104         })105         .collect()106 }107 108 /// Collect up to [`MAX_ENTRIES`] entries, commits + releases newest-first.109 #[allow(dead_code)]110 pub fn collect_entries(repo: &Path, branch: &str) -> Result<Vec<FeedEntry>, String> {111     let log = gitx::log_n(repo, branch, MAX_ENTRIES).map_err(|e| format!("hu13: {e}"))?;112     collect_entries_loaded(repo, &log)113 }114 115 /// Collect entries from an already-loaded commit log plus releases from116 /// [`read_release_entries`] (one struct pass with the HTML readers).117 pub fn collect_entries_loaded(repo: &Path, log: &[gitx::Commit]) -> Result<Vec<FeedEntry>, String> {118     let mut entries: Vec<FeedEntry> = log119         .iter()120         .map(|c| {121             let updated = to_rfc3339(&c.date);122             FeedEntry {123                 title: c.subject.clone(),124                 id: format!("urn:sha:{}", c.sha),125                 updated,126                 link: format!("/commit/{}.html", c.sha),127                 summary: format!("{} — {} ({})", c.subject, c.author, c.date),128             }129         })130         .collect();131     let mut rels = read_release_entries(repo);132     entries.append(&mut rels);133     // Newest-first by RFC3339 `updated` (lexicographic == chronological).134     entries.sort_by(|a, b| b.updated.cmp(&a.updated));135     entries.truncate(MAX_ENTRIES);136     Ok(entries)137 }138 139 /// Render the full `feed.atom` document for `entries`.140 ///141 /// `site_name` becomes the feed `<title>`; `description` (m6, issue #21),142 /// when present, becomes the feed `<subtitle>` (escaped; hostile markup143 /// renders as text). `None` omits `<subtitle>` entirely.144 #[must_use]145 #[allow(dead_code)]146 pub fn render_feed(147     site_name: &str,148     branch: &str,149     entries: &[FeedEntry],150     description: Option<&str>,151 ) -> String {152     render_feed_with_origin(site_name, branch, entries, description, None)153 }154 155 /// [`render_feed`] with absolute entry links (f0rge #96): Atom readers156 /// disagree on relative IRIs, so prefix `/...` links with the public157 /// origin when one is configured. Already-absolute links stay untouched.158 #[must_use]159 pub fn render_feed_with_origin(160     site_name: &str,161     branch: &str,162     entries: &[FeedEntry],163     description: Option<&str>,164     public_origin: Option<&str>,165 ) -> String {166     let origin = public_origin167         .map(str::trim)168         .map(|s| s.trim_end_matches('/'))169         .filter(|s| !s.is_empty());170     let updated = entries171         .first()172         .map_or_else(|| "1970-01-01T00:00:00Z".to_string(), |e| esc(&e.updated));173     let mut out = String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");174     out.push_str("<feed xmlns=\"http://www.w3.org/2005/Atom\">\n");175     out.push_str(&format!("<title>{}</title>\n", esc(site_name)));176     if let Some(d) = description {177         out.push_str(&format!("<subtitle>{}</subtitle>\n", esc(d)));178     }179     out.push_str(&format!("<id>urn:hu13:feed:{}</id>\n", esc(branch)));180     out.push_str(&format!("<updated>{updated}</updated>\n"));181     out.push_str("<author><name>hu13</name></author>\n");182     if let Some(base) = origin {183         out.push_str(&format!(184             "<link rel=\"self\" href=\"{}/feed.atom\"/>\n",185             esc(base)186         ));187     }188     for e in entries {189         out.push_str("<entry>\n");190         out.push_str(&format!("<title>{}</title>\n", esc(&e.title)));191         out.push_str(&format!("<id>{}</id>\n", esc(&e.id)));192         out.push_str(&format!("<updated>{}</updated>\n", esc(&e.updated)));193         let link = match origin {194             Some(base) if e.link.starts_with('/') => format!("{base}{}", e.link),195             _ => e.link.clone(),196         };197         out.push_str(&format!("<link href=\"{}\"/>\n", esc(&link)));198         out.push_str(&format!("<summary>{}</summary>\n", esc(&e.summary)));199         out.push_str("</entry>\n");200     }201     out.push_str("</feed>\n");202     out203 }204 205 /// Build `feed.atom` in `out` (called from [`crate::build::run_build`]206 /// right after commits). Returns the number of entries written.207 #[allow(dead_code)]208 pub fn render(209     repo: &Path,210     out: &Path,211     branch: &str,212     site_name: &str,213     description: Option<&str>,214 ) -> Result<usize, String> {215     let entries = collect_entries(repo, branch)?;216     render_entries(out, site_name, branch, &entries, description, None)217 }218 219 /// Build `feed.atom` from already-loaded commits (one struct pass).220 #[allow(dead_code)]221 pub fn render_loaded(222     repo: &Path,223     out: &Path,224     branch: &str,225     site_name: &str,226     log: &[gitx::Commit],227     description: Option<&str>,228 ) -> Result<usize, String> {229     render_loaded_with_origin(repo, out, branch, site_name, log, description, None)230 }231 232 /// [`render_loaded`] with absolute links for a configured public origin.233 #[allow(clippy::too_many_arguments)]234 pub fn render_loaded_with_origin(235     repo: &Path,236     out: &Path,237     branch: &str,238     site_name: &str,239     log: &[gitx::Commit],240     description: Option<&str>,241     public_origin: Option<&str>,242 ) -> Result<usize, String> {243     let entries = collect_entries_loaded(repo, log)?;244     render_entries(out, site_name, branch, &entries, description, public_origin)245 }246 247 fn render_entries(248     out: &Path,249     site_name: &str,250     branch: &str,251     entries: &[FeedEntry],252     description: Option<&str>,253     public_origin: Option<&str>,254 ) -> Result<usize, String> {255     let doc = render_feed_with_origin(site_name, branch, entries, description, public_origin);256     std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;257     std::fs::write(out.join("feed.atom"), doc)258         .map_err(|e| format!("hu13: write feed.atom: {e}"))?;259     Ok(entries.len())260 }261 262 #[cfg(test)]263 mod tests {264     use super::*;265     use std::path::PathBuf;266     use std::process::Command;267     use std::sync::atomic::{AtomicU64, Ordering};268 269     static COUNTER: AtomicU64 = AtomicU64::new(0);270 271     fn mktemp(prefix: &str) -> PathBuf {272         let id = COUNTER.fetch_add(1, Ordering::SeqCst);273         let dir =274             std::env::temp_dir().join(format!("hu13-feed-{prefix}-{}-{id}", std::process::id()));275         std::fs::create_dir_all(&dir).expect("mktemp");276         dir277     }278 279     fn git(repo: &Path, args: &[&str]) {280         let st = Command::new("git")281             .arg("-C")282             .arg(repo)283             .args(args)284             .status()285             .expect("spawn git");286         assert!(st.success(), "git {args:?} failed in {}", repo.display());287     }288 289     fn git_dated(repo: &Path, msg: &str, date: &str) {290         let st = Command::new("git")291             .arg("-C")292             .arg(repo)293             .arg("commit")294             .arg("-m")295             .arg(msg)296             .env("GIT_AUTHOR_DATE", date)297             .env("GIT_COMMITTER_DATE", date)298             .status()299             .expect("spawn git commit");300         assert!(st.success(), "dated commit {msg:?} failed");301     }302 303     /// Fixture: 3 commits with explicit increasing dates.304     fn fixture_repo() -> PathBuf {305         let dir = mktemp("fixture");306         git(&dir, &["init", "-b", "main"]);307         git(&dir, &["config", "user.name", "hu13-test"]);308         git(&dir, &["config", "user.email", "[email protected]"]);309         git(&dir, &["config", "commit.gpgsign", "false"]);310         std::fs::write(dir.join("a.txt"), "one\n").unwrap();311         git(&dir, &["add", "-A"]);312         git_dated(&dir, "oldest commit", "2024-01-01T00:00:00+00:00");313         std::fs::write(dir.join("b.txt"), "two\n").unwrap();314         git(&dir, &["add", "-A"]);315         git_dated(&dir, "middle commit", "2024-06-01T00:00:00+00:00");316         std::fs::write(dir.join("c.txt"), "three\n").unwrap();317         git(&dir, &["add", "-A"]);318         git_dated(319             &dir,320             "newest commit <b>bold</b>",321             "2025-01-01T00:00:00+00:00",322         );323         dir324     }325 326     /// Minimal well-formedness check: angle-balance plus a tiny tag-stack327     /// parse over the known Atom shape (feed/entry/title/id/updated/link/328     /// summary/author/name + xml prolog).329     fn check_well_formed(xml: &str) {330         let opens = xml.bytes().filter(|b| *b == b'<').count();331         let closes = xml.bytes().filter(|b| *b == b'>').count();332         assert_eq!(opens, closes, "angle-balance: {opens} < vs {closes} >");333         assert!(xml.starts_with("<?xml"), "xml prolog present");334         let mut stack: Vec<String> = Vec::new();335         let bytes = xml.as_bytes();336         let mut i = 0;337         while i < bytes.len() {338             if bytes[i] != b'<' {339                 i += 1;340                 continue;341             }342             let Some(end) = xml[i..].find('>') else {343                 panic!("unterminated tag at offset {i}");344             };345             let tag = &xml[i + 1..i + end];346             i += end + 1;347             if tag.starts_with('?') || tag.starts_with('!') {348                 continue; // prolog / comment, no stack effect349             }350             if let Some(stripped) = tag.strip_prefix('/') {351                 let name = stripped.trim().to_string();352                 let open = stack.pop().expect("closing tag without opener");353                 assert_eq!(open, name, "mismatched close: {name}");354             } else if tag.ends_with('/') {355                 continue; // self-closing <link .../>356             } else {357                 let name = tag.split_whitespace().next().unwrap_or("").to_string();358                 assert!(!name.is_empty(), "empty tag name");359                 stack.push(name);360             }361         }362         assert!(stack.is_empty(), "unclosed tags: {stack:?}");363     }364 365     fn entry_titles(xml: &str) -> Vec<String> {366         let mut titles = Vec::new();367         let mut rest = xml;368         while let Some(s) = rest.find("<entry>") {369             rest = &rest[s + "<entry>".len()..];370             let e = rest.find("</entry>").expect("entry close");371             let body = &rest[..e];372             let t0 = body.find("<title>").expect("entry title open") + "<title>".len();373             let t1 = body.find("</title>").expect("entry title close");374             titles.push(body[t0..t1].to_string());375             rest = &rest[e + "</entry>".len()..];376         }377         titles378     }379 380     #[test]381     fn esc_escapes_xml_specials() {382         assert_eq!(esc("<>&\"'"), "&lt;&gt;&amp;&quot;&apos;");383         assert_eq!(esc("plain 123"), "plain 123");384     }385 386     #[test]387     fn dates_become_rfc3339() {388         assert_eq!(389             to_rfc3339("2024-01-01 00:00:00 +0000"),390             "2024-01-01T00:00:00+00:00"391         );392         assert_eq!(393             to_rfc3339("2025-09-08 02:00:00 +0530"),394             "2025-09-08T02:00:00+05:30"395         );396         assert_eq!(to_rfc3339("2025-09-08T02:00:00Z"), "2025-09-08T02:00:00Z");397     }398 399     #[test]400     fn feed_well_formed_count_capped_newest_first() {401         let repo = fixture_repo();402         let entries = collect_entries(&repo, "main").expect("collect");403         assert!(entries.len() <= MAX_ENTRIES);404         let xml = render_feed("test-site", "main", &entries, None);405         check_well_formed(&xml);406         // Newest-first on the dated fixture.407         let titles = entry_titles(&xml);408         assert_eq!(titles.len(), 3);409         assert!(titles[0].contains("newest commit"), "first: {titles:?}");410         assert!(titles[1].contains("middle commit"), "second: {titles:?}");411         assert!(titles[2].contains("oldest commit"), "third: {titles:?}");412         // Entry shape: urn id, RFC3339 updated, link, escaped summary.413         assert!(xml.contains("urn:sha:"), "urn ids");414         assert!(xml.contains("2025-01-01T00:00:00+00:00"), "RFC3339 date");415         assert!(xml.contains("/commit/"), "detail links");416         assert!(417             !xml.contains("<b>bold</b>"),418             "subject summary escaped, not raw HTML"419         );420         assert!(421             xml.contains("&lt;b&gt;bold&lt;/b&gt;"),422             "escaped form present"423         );424     }425 426     #[test]427     fn synthetic_entries_truncate_at_50() {428         let entries: Vec<FeedEntry> = (0..60)429             .map(|i| FeedEntry {430                 title: format!("entry {i:02}"),431                 id: format!("urn:sha:{i:040}"),432                 updated: format!("2025-01-{:02}T00:00:00Z", (i % 28) + 1),433                 link: format!("/commit/{i}.html"),434                 summary: format!("summary {i}"),435             })436             .collect();437         let mut sorted = entries.clone();438         sorted.sort_by(|a, b| b.updated.cmp(&a.updated));439         sorted.truncate(MAX_ENTRIES);440         let xml = render_feed("test", "main", &sorted, None);441         check_well_formed(&xml);442         assert_eq!(xml.matches("<entry>").count(), 50, "capped at 50");443     }444 445     #[test]446     fn releases_required_when_present() {447         // Fixture WITH a releases dir: release entries are REQUIRED now448         // that the m3 reader has landed.449         let repo = fixture_repo();450         std::fs::create_dir_all(repo.join(".forge").join("releases")).unwrap();451         std::fs::write(452             repo.join(".forge").join("releases").join("v1.0.md"),453             "---\nversion: v1.0\ncreated: 2025-06-01T00:00:00Z\n---\n# v1.0 notes\n",454         )455         .unwrap();456         let entries = collect_entries(&repo, "main").expect("collect");457         let xml = render_feed("test", "main", &entries, None);458         check_well_formed(&xml);459         assert!(460             xml.contains("v1.0"),461             "release entries required when releases/ dir exists"462         );463         assert!(xml.contains("urn:release:"), "release urn ids required");464         assert!(465             !xml.contains("TODO(m3)"),466             "no m3 TODO marker left in feed output"467         );468     }469 470     #[test]471     fn build_wires_feed_atom() {472         // End-to-end: full build emits feed.atom right after commits.473         let repo = fixture_repo();474         let out = mktemp("out");475         let opts = crate::build::BuildOptions {476             branch_flag: None,477             clone_url_flag: None,478             public_origin_flag: None,479             repo_name_flag: None,480             description_flag: None,481             max_log: 100,482         };483         crate::build::run_build(&repo, &out, &opts).expect("build");484         assert!(out.join("commits.html").exists(), "commits built first");485         let feed = std::fs::read_to_string(out.join("feed.atom")).expect("feed.atom emitted");486         check_well_formed(&feed);487         assert!(feed.matches("<entry>").count() <= MAX_ENTRIES);488         let titles = entry_titles(&feed);489         assert!(490             titles[0].contains("newest commit"),491             "newest first: {titles:?}"492         );493     }494 }