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("&"),43 '<' => out.push_str("<"),44 '>' => out.push_str(">"),45 '"' => out.push_str("""),46 '\'' => out.push_str("'"),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("<>&\"'"), "<>&"'");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("<b>bold</b>"),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 }