//! Publishing: the EPUB library folder, XTC delivery, OPDS feed, retention //! (spec §3.11). //! //! `publish.epub_dir` holds both EPUB editions and is what the OPDS feed lists; //! BookOrbit may watch the same folder but nothing here depends on it. //! //! Everything here is deliberately dumb about *how* artifacts were produced: the //! EPUB/XTC stages hand over finished files, this module only copies, indexes and //! prunes them. Copies are atomic (temp file in the destination directory, then //! `rename`) so BookOrbit's watcher and CrossPoint's OPDS client never observe a //! half-written book. //! //! [`crate::pipeline`] ends a non-dry run with one call — //! `publish_issue(config, &issue, &artifacts, xtc_path.as_deref())`, where //! `artifacts` are the `epub::build_all` outputs and `xtc_path` is //! `epub::x4::convert`'s output (`None` when the converter is disabled or //! failed) — and feeds the returned [`Published`] paths into //! `db.upsert_issue(..., epub_path, x4_path, xtc_path, ...)`. //! //! The OPDS feed ([`build_opds`]) is rendered per request by //! [`crate::server`] rather than written here, and lists **EPUBs only** — see //! its docs for why XTC cannot be delivered over OPDS. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use jiff::civil::Date; use jiff::{Timestamp, Zoned}; use sqlx::Row; use tokio::io::AsyncWriteExt; use crate::config::Config; use crate::db::Db; use crate::types::{Artifact, Edition, Issue}; /// Number of issue *days* listed in the OPDS feed; each contributes one entry /// per edition (§3.11). pub const OPDS_FEED_ISSUES: usize = 14; /// Every published file starts with this (the retention sweep keys off it). pub const FILE_PREFIX: &str = "The Daily EPUB - "; /// Extensions the retention sweep is allowed to delete (§3.11). pub const PRUNABLE_EXTENSIONS: [&str; 3] = ["epub", "xtc", "xtch"]; /// Extensions of the XTC artifacts, for the counted sweep of `xtc_dir` (§3.11). pub const XTC_EXTENSIONS: [&str; 2] = ["xtc", "xtch"]; /// Canonical path of the OPDS feed, relative to `server.public_url` (§3.11). pub const OPDS_PATH: &str = "/opds/daily.xml"; /// The one acquisition type CrossPoint's OPDS parser accepts (§3.11). pub const EPUB_CONTENT_TYPE: &str = "application/epub+zip"; #[derive(Debug, thiserror::Error)] pub enum PublishError { #[error("io error at {path}: {source}")] Io { path: PathBuf, #[source] source: std::io::Error, }, #[error("publish directory does not exist: {0}")] MissingDir(PathBuf), } impl PublishError { fn at(path: impl Into) -> impl FnOnce(std::io::Error) -> PublishError { let path = path.into(); move |source| PublishError::Io { path, source } } } /// Everything one `generate` run published (§3.11). #[derive(Debug, Clone, Default, PartialEq)] pub struct Published { /// The EPUB artifacts, rewritten to point at their published locations. pub epubs: Vec, /// The XTC artifact's published location, when the converter produced one. pub xtc: Option, /// How many expired files the retention sweep removed. pub pruned: usize, } /// Canonical published filename: `The Daily EPUB - 2026-08-15 (X4).epub` (§3.11). pub fn issue_filename(date: Date, edition: Edition, extension: &str) -> String { format!("{FILE_PREFIX}{date}{}.{extension}", edition.file_suffix()) } /// Parse the issue date back out of a published filename, `None` when the name /// is not one of ours (the retention sweep must never touch foreign files). pub fn date_from_filename(name: &str) -> Option { let rest = name.strip_prefix(FILE_PREFIX)?; let extension = Path::new(name).extension()?.to_str()?.to_ascii_lowercase(); if !PRUNABLE_EXTENSIONS.contains(&extension.as_str()) { return None; } rest.get(..10)?.parse::().ok() } // --------------------------------------------------------------------------- // Copying // --------------------------------------------------------------------------- /// Atomic copy: write to a temp file in the destination dir, then rename (§3.11). /// /// The temp file is created beside the destination so the rename stays within one /// filesystem; a failed copy leaves the previous version of `dest` intact. pub async fn atomic_copy(src: &Path, dest: &Path) -> Result<(), PublishError> { let dir = dest.parent().unwrap_or_else(|| Path::new(".")); ensure_dir(dir).await?; let tmp = dir.join(format!( ".{}.{}.{}.tmp", dest.file_name() .and_then(|n| n.to_str()) .unwrap_or("daily-epub"), std::process::id(), Timestamp::now().as_nanosecond() )); let bytes = tokio::fs::read(src).await.map_err(PublishError::at(src))?; let write = async { let mut file = tokio::fs::File::create(&tmp).await?; file.write_all(&bytes).await?; file.sync_all().await?; Ok::<(), std::io::Error>(()) }; if let Err(source) = write.await { let _ = tokio::fs::remove_file(&tmp).await; return Err(PublishError::Io { path: tmp, source }); } if let Err(source) = tokio::fs::rename(&tmp, dest).await { let _ = tokio::fs::remove_file(&tmp).await; return Err(PublishError::Io { path: dest.to_path_buf(), source, }); } tracing::debug!( src = %src.display(), dest = %dest.display(), bytes = bytes.len(), "published atomically" ); Ok(()) } async fn ensure_dir(dir: &Path) -> Result<(), PublishError> { tokio::fs::create_dir_all(dir) .await .map_err(PublishError::at(dir)) } /// Copy both EPUB editions into `publish.epub_dir` (§3.11). /// /// Returns the published paths in the same order as `artifacts`. pub async fn publish_epubs( artifacts: &[Artifact], issue: &Issue, cfg: &Config, ) -> Result, PublishError> { let dir = &cfg.publish.epub_dir; ensure_dir(dir).await?; let mut published = Vec::with_capacity(artifacts.len()); for artifact in artifacts { let extension = artifact .path .extension() .and_then(|e| e.to_str()) .unwrap_or("epub"); let dest = dir.join(issue_filename(issue.meta.date, artifact.edition, extension)); atomic_copy(&artifact.path, &dest).await?; tracing::info!( edition = ?artifact.edition, dest = %dest.display(), bytes = artifact.bytes, "published edition to the EPUB library" ); published.push(dest); } Ok(published) } /// Copy the `.xtc`/`.xtch` artifact into `publish.xtc_dir` (§3.11). pub async fn publish_xtc(xtc: &Path, cfg: &Config) -> Result { let dir = &cfg.publish.xtc_dir; ensure_dir(dir).await?; let name = xtc .file_name() .and_then(|n| n.to_str()) .unwrap_or("daily-epub.xtch"); let dest = dir.join(name); atomic_copy(xtc, &dest).await?; tracing::info!(dest = %dest.display(), "published XTC artifact"); Ok(dest) } /// Publish everything one run produced and prune (§3.11). /// /// `xtc` is `None` when the converter is disabled or failed — that is not an /// error, since the OPDS feed lists the EPUB editions either way. pub async fn publish_issue( cfg: &Config, issue: &Issue, artifacts: &[Artifact], xtc: Option<&Path>, ) -> Result { let span = tracing::info_span!("publish", date = %issue.meta.date); let _guard = span.enter(); let paths = publish_epubs(artifacts, issue, cfg).await?; let epubs = artifacts .iter() .zip(paths) .map(|(artifact, path)| Artifact { path, ..artifact.clone() }) .collect(); let xtc = match xtc { Some(src) => Some(publish_xtc(src, cfg).await?), None => None, }; // The OPDS feed is rendered per request from the publish directory, so // there is nothing to write here (§3.11). let pruned = prune(cfg, issue.meta.date).await?; Ok(Published { epubs, xtc, pruned }) } // --------------------------------------------------------------------------- // OPDS 1.2 acquisition feed (§3.11) // --------------------------------------------------------------------------- /// One published EPUB, as listed in the feed. #[derive(Debug, Clone, PartialEq, Eq)] struct EpubFile { name: String, date: Option, edition: Edition, modified: Timestamp, bytes: u64, } /// Render the OPDS 1.2 acquisition feed for the EPUB publish directory (§3.11). /// /// Rendered per request rather than written to disk: the directory is the only /// source of truth, so the feed cannot go stale behind a failed publish, and /// there is no generated file for the retention sweep to step around. /// /// XTC artifacts are deliberately **not** listed. CrossPoint's OPDS browser only /// acquires links typed `application/epub+zip` and always saves the result with /// a `.epub` extension, which its reader dispatches on — so an XTC offered here /// would either be invisible or download into a file that cannot be opened. pub async fn build_opds(db: &Db, cfg: &Config) -> Result { let dir = &cfg.publish.epub_dir; ensure_dir(dir).await?; let files = scan_epub_dir(dir).await?; let numbers = issue_numbers(db, &files).await; Ok(render_opds( &files, &numbers, &cfg.server.public_url, Timestamp::now(), )) } /// Published EPUBs in `dir`, newest first, limited to the last /// [`OPDS_FEED_ISSUES`] issue days (both editions of each). async fn scan_epub_dir(dir: &Path) -> Result, PublishError> { let mut entries = tokio::fs::read_dir(dir) .await .map_err(PublishError::at(dir))?; let mut files = Vec::new(); while let Some(entry) = entries.next_entry().await.map_err(PublishError::at(dir))? { let name = entry.file_name().to_string_lossy().into_owned(); let path = Path::new(&name); let extension = path .extension() .and_then(|e| e.to_str()) .unwrap_or_default() .to_ascii_lowercase(); // Only our own issues: a shared library may hold other people's books. if extension != "epub" || date_from_filename(&name).is_none() { continue; } let meta = match entry.metadata().await { Ok(meta) if meta.is_file() => meta, Ok(_) => continue, Err(e) => { tracing::warn!(error = %e, name, "skipping unreadable EPUB"); continue; } }; let modified = meta .modified() .ok() .and_then(|m| Timestamp::try_from(m).ok()) .unwrap_or_else(Timestamp::now); let stem = path .file_stem() .and_then(|s| s.to_str()) .unwrap_or_default() .to_string(); files.push(EpubFile { date: date_from_filename(&name), edition: Edition::from_file_stem(&stem), name, modified, bytes: meta.len(), }); } // Newest issue first, and within an issue the standard edition leads. files.sort_by(|a, b| { b.date .cmp(&a.date) .then(a.edition.cmp(&b.edition)) .then(b.modified.cmp(&a.modified)) .then(a.name.cmp(&b.name)) }); // Cap by issue day, not by file: an issue is two entries and truncating // mid-issue would list one edition without the other. let mut kept_dates: Vec> = Vec::new(); files.retain(|file| { if !kept_dates.contains(&file.date) { kept_dates.push(file.date); } kept_dates.iter().position(|d| *d == file.date) < Some(OPDS_FEED_ISSUES) }); Ok(files) } /// Issue numbers for the dated files, best-effort (the feed is still valid /// without them). Uses the `db` escape hatch — no bespoke helper in `db.rs`. async fn issue_numbers(db: &Db, files: &[EpubFile]) -> BTreeMap { let mut numbers = BTreeMap::new(); for date in files.iter().filter_map(|f| f.date) { if numbers.contains_key(&date) { continue; } let row = sqlx::query("SELECT issue_number FROM issues WHERE date = ?") .bind(date.to_string()) .fetch_optional(db.pool()) .await; match row { Ok(Some(row)) => { numbers.insert(date, row.get::("issue_number")); } Ok(None) => {} Err(e) => tracing::warn!(error = %e, %date, "issue number lookup failed"), } } numbers } /// Render the Atom/OPDS document (§3.11). fn render_opds( files: &[EpubFile], numbers: &BTreeMap, public_url: &str, now: Timestamp, ) -> String { let base = public_url.trim_end_matches('/'); let self_href = format!("{base}{OPDS_PATH}"); let updated = files.first().map(|f| f.modified).unwrap_or(now); let mut out = String::with_capacity(1024 + files.len() * 512); out.push_str("\n"); out.push_str( "\n", ); out.push_str(" urn:daily-epub:issues\n"); out.push_str(" The Daily EPUB\n"); out.push_str(&format!(" {}\n", rfc3339(updated))); out.push_str(" The Daily EPUB\n"); out.push_str(&format!( " \n", xml_escape(&self_href), crate::server::OPDS_CONTENT_TYPE )); out.push_str(&format!( " \n", xml_escape(&self_href), crate::server::OPDS_CONTENT_TYPE )); for file in files { // Matches the EPUB's own `dc:title`, so the two editions of one issue // are told apart in the list rather than showing as the same book. let title = match file.date { Some(date) => format!("The Daily EPUB — {date}{}", file.edition.file_suffix()), None => file.name.clone(), }; let edition_note = match file.edition { Edition::Standard => "Standard", Edition::X4 => "Xteink X4", }; let summary = match file.date.and_then(|d| numbers.get(&d)) { Some(n) => format!("Issue #{n} · {edition_note} · {}", human_bytes(file.bytes)), None => format!("{edition_note} · {}", human_bytes(file.bytes)), }; let href = format!("{base}/files/epub/{}", percent_encode(&file.name)); out.push_str(" \n"); out.push_str(&format!(" {}\n", xml_escape(&title))); out.push_str(&format!( " urn:daily-epub:issue:{}\n", xml_escape(&percent_encode(&file.name)) )); out.push_str(&format!( " {}\n", rfc3339(file.modified) )); if let Some(date) = file.date { out.push_str(&format!(" {date}\n")); } out.push_str(" The Daily EPUB\n"); out.push_str(&format!( " {}\n", xml_escape(&summary) )); // The type must be exactly `application/epub+zip`: CrossPoint's OPDS // parser compares it with `strcmp` and silently drops entries whose // acquisition link is anything else, reporting "No entries found". out.push_str(&format!( " \n", xml_escape(&href), file.bytes )); out.push_str(" \n"); } out.push_str("\n"); out } /// Atom wants `1996-12-19T16:39:57-08:00`; jiff's `Timestamp` prints `…Z`. fn rfc3339(ts: Timestamp) -> String { ts.to_string() } fn human_bytes(bytes: u64) -> String { if bytes >= 1_048_576 { format!("{:.1} MB", bytes as f64 / 1_048_576.0) } else { format!("{:.0} KB", (bytes as f64 / 1024.0).ceil()) } } fn xml_escape(s: &str) -> String { let mut out = String::with_capacity(s.len()); for c in s.chars() { match c { '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), '\'' => out.push_str("'"), _ => out.push(c), } } out } /// Percent-encode one URL path segment (filenames contain spaces and parens). fn percent_encode(s: &str) -> String { let mut out = String::with_capacity(s.len()); for byte in s.as_bytes() { match byte { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { out.push(*byte as char) } _ => out.push_str(&format!("%{byte:02X}")), } } out } // --------------------------------------------------------------------------- // Retention (§3.11) // --------------------------------------------------------------------------- /// Retention sweep over both publish dirs. SQLite history is kept forever — /// it's the training data (§3.11). /// /// The two directories are swept on different rules: EPUBs age out after /// `retention_days`, while XTC is capped at `xtc_retention_count` issues because /// each one is ~80–100 MB of pre-rendered page bitmaps and the binding /// constraint is disk rather than age. /// /// Only files named `The Daily EPUB - YYYY-MM-DD*.{epub,xtc,xtch}` are ever /// considered; anything else in those directories (other people's books) is left /// strictly alone. pub async fn prune(cfg: &Config, today: Date) -> Result { let cutoff = today .checked_sub(jiff::Span::new().days(i64::from(cfg.retention_days))) .unwrap_or(today); let mut removed = prune_dir(&cfg.publish.epub_dir, cutoff).await?; if removed > 0 { tracing::info!(removed, %cutoff, "retention sweep removed expired EPUBs"); } let xtc_removed = prune_xtc_dir(&cfg.publish.xtc_dir, cfg.xtc_retention_count as usize).await?; if xtc_removed > 0 { tracing::info!( removed = xtc_removed, keep = cfg.xtc_retention_count, "retention sweep trimmed the XTC directory" ); } removed += xtc_removed; Ok(removed) } /// Keep only the newest `keep` XTC issues, deleting the rest (§3.11). /// /// Counted rather than dated so the directory has a hard size ceiling no matter /// how often `generate` runs. async fn prune_xtc_dir(dir: &Path, keep: usize) -> Result { if !dir.exists() { return Ok(0); } let mut entries = tokio::fs::read_dir(dir) .await .map_err(PublishError::at(dir))?; let mut ours: Vec<(Date, PathBuf)> = Vec::new(); while let Some(entry) = entries.next_entry().await.map_err(PublishError::at(dir))? { let name = entry.file_name().to_string_lossy().into_owned(); let extension = Path::new(&name) .extension() .and_then(|e| e.to_str()) .unwrap_or_default() .to_ascii_lowercase(); if !XTC_EXTENSIONS.contains(&extension.as_str()) { continue; } let Some(date) = date_from_filename(&name) else { continue; }; if !entry.metadata().await.map(|m| m.is_file()).unwrap_or(false) { continue; } ours.push((date, entry.path())); } // Newest first, then drop everything past the cap. ours.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1))); let mut removed = 0; for (date, path) in ours.into_iter().skip(keep) { match tokio::fs::remove_file(&path).await { Ok(()) => { tracing::info!(path = %path.display(), %date, "pruned an XTC issue past the cap"); removed += 1; } Err(e) => tracing::warn!(error = %e, path = %path.display(), "could not prune file"), } } Ok(removed) } async fn prune_dir(dir: &Path, cutoff: Date) -> Result { if !dir.exists() { return Ok(0); } let mut entries = tokio::fs::read_dir(dir) .await .map_err(PublishError::at(dir))?; let mut removed = 0; while let Some(entry) = entries.next_entry().await.map_err(PublishError::at(dir))? { let name = entry.file_name().to_string_lossy().into_owned(); let Some(date) = date_from_filename(&name) else { continue; }; if date >= cutoff { continue; } if !entry.metadata().await.map(|m| m.is_file()).unwrap_or(false) { continue; } let path = entry.path(); match tokio::fs::remove_file(&path).await { Ok(()) => { tracing::info!(path = %path.display(), %date, "pruned expired issue file"); removed += 1; } Err(e) => tracing::warn!(error = %e, path = %path.display(), "could not prune file"), } } Ok(removed) } /// Today in the configured timezone — the reference point for [`prune`]. pub fn today_in_tz(cfg: &Config) -> Date { match cfg.tz() { Ok(tz) => Zoned::now().with_time_zone(tz).date(), Err(e) => { tracing::warn!(error = %e, "falling back to UTC for the retention cutoff"); Timestamp::now().to_zoned(jiff::tz::TimeZone::UTC).date() } } } #[cfg(test)] mod tests { use super::*; use std::collections::BTreeMap; fn cfg(dir: &Path) -> Config { let mut cfg = Config::default(); cfg.publish.epub_dir = dir.join("bookorbit"); cfg.publish.xtc_dir = dir.join("xtc"); cfg.server.public_url = "https://daily.hallada.net".into(); cfg } fn date(s: &str) -> Date { s.parse().unwrap() } fn ts(s: &str) -> Timestamp { s.parse().unwrap() } #[test] fn filenames_match_the_spec() { assert_eq!( issue_filename(date("2026-08-15"), Edition::Standard, "epub"), "The Daily EPUB - 2026-08-15.epub" ); assert_eq!( issue_filename(date("2026-08-15"), Edition::X4, "epub"), "The Daily EPUB - 2026-08-15 (X4).epub" ); assert_eq!( issue_filename(date("2026-08-15"), Edition::X4, "xtch"), "The Daily EPUB - 2026-08-15 (X4).xtch" ); } #[test] fn only_our_filenames_are_recognized() { assert_eq!( date_from_filename("The Daily EPUB - 2026-08-15.epub"), Some(date("2026-08-15")) ); assert_eq!( date_from_filename("The Daily EPUB - 2026-08-15 (X4).xtch"), Some(date("2026-08-15")) ); for foreign in [ "The Daily EPUB - 2026-08-15.xml", "Moby Dick.epub", "The Daily EPUB - notadate.epub", "The Daily EPUB - 2026-08-15.txt", "the daily epub - 2026-08-15.epub", "metadata.db", ] { assert_eq!(date_from_filename(foreign), None, "{foreign}"); } } #[tokio::test] async fn atomic_copy_creates_the_final_name_and_leaves_no_temp_files() { let dir = tempfile::tempdir().unwrap(); let src = dir.path().join("build.epub"); tokio::fs::write(&src, b"EPUB BYTES").await.unwrap(); let dest = dir .path() .join("out") .join("The Daily EPUB - 2026-08-15.epub"); atomic_copy(&src, &dest).await.unwrap(); assert_eq!(tokio::fs::read(&dest).await.unwrap(), b"EPUB BYTES"); // Overwriting an existing issue works and stays atomic. tokio::fs::write(&src, b"REGENERATED").await.unwrap(); atomic_copy(&src, &dest).await.unwrap(); assert_eq!(tokio::fs::read(&dest).await.unwrap(), b"REGENERATED"); let leftovers: Vec = std::fs::read_dir(dir.path().join("out")) .unwrap() .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) .filter(|n| n.ends_with(".tmp")) .collect(); assert!( leftovers.is_empty(), "temp files left behind: {leftovers:?}" ); assert!( atomic_copy(Path::new("/nonexistent/x.epub"), &dest) .await .is_err() ); } #[tokio::test] async fn publish_epubs_uses_canonical_names() { let dir = tempfile::tempdir().unwrap(); let cfg = cfg(dir.path()); let std_src = dir.path().join("a.epub"); let x4_src = dir.path().join("b.epub"); tokio::fs::write(&std_src, b"standard").await.unwrap(); tokio::fs::write(&x4_src, b"x4").await.unwrap(); let artifacts = vec![ Artifact { edition: Edition::Standard, path: std_src, bytes: 8, }, Artifact { edition: Edition::X4, path: x4_src, bytes: 2, }, ]; let issue = fake_issue(date("2026-08-15")); let paths = publish_epubs(&artifacts, &issue, &cfg).await.unwrap(); assert_eq!( paths, vec![ cfg.publish .epub_dir .join("The Daily EPUB - 2026-08-15.epub"), cfg.publish .epub_dir .join("The Daily EPUB - 2026-08-15 (X4).epub"), ] ); assert_eq!(tokio::fs::read(&paths[0]).await.unwrap(), b"standard"); let xtc_src = dir.path().join("c.xtch"); tokio::fs::write(&xtc_src, b"xtch").await.unwrap(); let published = publish_xtc(&xtc_src, &cfg).await.unwrap(); assert_eq!(published, cfg.publish.xtc_dir.join("c.xtch")); } fn fake_issue(date: Date) -> Issue { use crate::types::{Colophon, Editorial, IssueMeta, Lineup}; Issue { meta: IssueMeta { date, issue_number: 12, generated_at: ts("2026-08-15T05:30:00Z"), display_date: "Saturday, August 15, 2026".into(), article_count: 3, section_count: 1, total_words: 900, reading_minutes: 5, }, lineup: Lineup { date, picks: vec![], section_order: vec![], }, editorial: Editorial::default(), world_briefing: None, colophon: Colophon::default(), behind: Default::default(), } } fn epub_file(name: &str, edition: Edition, modified: &str, bytes: u64) -> EpubFile { EpubFile { date: date_from_filename(name), edition, name: name.into(), modified: ts(modified), bytes, } } #[test] fn opds_feed_is_newest_first_with_acquisition_links() { let files = vec![ epub_file( "The Daily EPUB - 2026-08-15.epub", Edition::Standard, "2026-08-15T05:40:00Z", 6_500_000, ), epub_file( "The Daily EPUB - 2026-08-15 (X4).epub", Edition::X4, "2026-08-15T05:40:00Z", 1_700_000, ), epub_file( "The Daily EPUB - 2026-08-14.epub", Edition::Standard, "2026-08-14T05:40:00Z", 4096, ), ]; let mut numbers = BTreeMap::new(); numbers.insert(date("2026-08-15"), 12); let feed = render_opds( &files, &numbers, "https://daily.hallada.net/", ts("2026-08-15T06:00:00Z"), ); assert!(feed.starts_with("")); assert!(feed.contains("urn:daily-epub:issues")); assert!(feed.contains("2026-08-15T05:40:00Z")); assert_eq!(feed.matches("").count(), 3); assert_eq!(feed.matches("").count(), 3); // Newest issue first; the two editions of one issue are distinguishable // by title, matching each EPUB's own `dc:title`. let i15 = feed .find("The Daily EPUB — 2026-08-15") .unwrap(); let i15_x4 = feed .find("The Daily EPUB — 2026-08-15 (X4)") .unwrap(); let i14 = feed .find("The Daily EPUB — 2026-08-14") .unwrap(); assert!(i15 < i15_x4 && i15_x4 < i14); // CrossPoint compares the acquisition type with `strcmp` against // `application/epub+zip` and drops the entry on any mismatch (§3.11). assert_eq!( feed.matches("type=\"application/epub+zip\"").count(), 3, "{feed}" ); assert!(!feed.contains("application/octet-stream")); assert!(feed.contains( "" )); assert!(feed.contains("Issue #12 · Standard · 6.2 MB")); assert!(feed.contains("Issue #12 · Xteink X4 · 1.6 MB")); assert!( feed.contains("")); assert!(!feed.contains("&<"), "unescaped markup leaked in"); } #[tokio::test] async fn the_feed_lists_both_editions_of_the_last_fourteen_issues() { let dir = tempfile::tempdir().unwrap(); let cfg = cfg(dir.path()); std::fs::create_dir_all(&cfg.publish.epub_dir).unwrap(); std::fs::create_dir_all(&cfg.publish.xtc_dir).unwrap(); for day in 1..=20 { for edition in Edition::ALL { let name = issue_filename(date(&format!("2026-08-{day:02}")), edition, "epub"); std::fs::write(cfg.publish.epub_dir.join(name), b"x").unwrap(); } } // Other people's books and our own XTC artifacts must be ignored. std::fs::write(cfg.publish.epub_dir.join("Moby Dick.epub"), b"x").unwrap(); std::fs::write(cfg.publish.epub_dir.join("metadata.db"), b"x").unwrap(); std::fs::write( cfg.publish .xtc_dir .join("The Daily EPUB - 2026-08-20 (X4).xtch"), b"x", ) .unwrap(); let db = Db::open_and_migrate(&dir.path().join("db.sqlite")) .await .unwrap(); db.upsert_issue( date("2026-08-20"), 20, ts("2026-08-20T05:30:00Z"), None, None, None, None, None, None, ) .await .unwrap(); let feed = build_opds(&db, &cfg).await.unwrap(); // Capped by issue day, so both editions of each of the last 14 survive. assert_eq!(feed.matches("").count(), OPDS_FEED_ISSUES * 2); assert!(feed.contains("2026-08-20")); assert!(!feed.contains("2026-08-06"), "older than the last 14"); assert!(!feed.contains("Moby Dick")); assert!(!feed.contains("metadata.db")); assert!(feed.contains("Issue #20")); // XTC is never offered: CrossPoint cannot acquire it (§3.11). assert!(!feed.contains(".xtch"), "{feed}"); assert!(!feed.contains("/files/xtc/")); } #[tokio::test] async fn prune_ages_out_epubs_but_counts_xtc() { let dir = tempfile::tempdir().unwrap(); let mut conf = cfg(dir.path()); conf.retention_days = 21; conf.xtc_retention_count = 5; std::fs::create_dir_all(&conf.publish.epub_dir).unwrap(); std::fs::create_dir_all(&conf.publish.xtc_dir).unwrap(); let keep_epub = conf .publish .epub_dir .join("The Daily EPUB - 2026-08-14.epub"); let old_epub = conf .publish .epub_dir .join("The Daily EPUB - 2026-07-01.epub"); let old_x4 = conf .publish .epub_dir .join("The Daily EPUB - 2026-07-01 (X4).epub"); let foreign = conf.publish.epub_dir.join("Moby Dick.epub"); for path in [&keep_epub, &old_epub, &old_x4, &foreign] { std::fs::write(path, b"x").unwrap(); } // Eight consecutive XTC issues, all recent: age would keep every one, // the count cap keeps the newest five. let xtc: Vec = (8..=15) .map(|day| { let path = conf .publish .xtc_dir .join(format!("The Daily EPUB - 2026-08-{day:02} (X4).xtch")); std::fs::write(&path, b"x").unwrap(); path }) .collect(); let foreign_xtc = conf.publish.xtc_dir.join("Someone Else.xtch"); std::fs::write(&foreign_xtc, b"x").unwrap(); // 2 expired EPUBs + 3 XTC issues past the cap of 5. let removed = prune(&conf, date("2026-08-15")).await.unwrap(); assert_eq!(removed, 5); assert!(keep_epub.exists()); assert!(foreign.exists(), "never touch other people's books"); assert!(foreign_xtc.exists(), "nor their XTC files"); assert!(!old_epub.exists()); assert!(!old_x4.exists()); // The five newest XTC issues (11th–15th) survive; 8th–10th are gone. for path in &xtc[..3] { assert!(!path.exists(), "{} should be pruned", path.display()); } for path in &xtc[3..] { assert!(path.exists(), "{} should be kept", path.display()); } // Idempotent, and tolerant of missing directories. assert_eq!(prune(&conf, date("2026-08-15")).await.unwrap(), 0); let missing_dirs = cfg(&dir.path().join("nowhere")); assert_eq!(prune(&missing_dirs, date("2026-08-15")).await.unwrap(), 0); } #[tokio::test] async fn publish_issue_does_the_whole_dance() { let dir = tempfile::tempdir().unwrap(); let cfg = cfg(dir.path()); let db = Db::open_and_migrate(&dir.path().join("db.sqlite")) .await .unwrap(); let src = dir.path().join("std.epub"); let x4_src = dir.path().join("x4.epub"); let xtc_src = dir.path().join("out.xtch"); for (path, body) in [ (&src, "standard"), (&x4_src, "x4"), (&xtc_src, "xtch-bytes"), ] { std::fs::write(path, body).unwrap(); } let artifacts = vec![ Artifact { edition: Edition::Standard, path: src, bytes: 8, }, Artifact { edition: Edition::X4, path: x4_src, bytes: 2, }, ]; let issue = fake_issue(date("2026-08-15")); let published = publish_issue(&cfg, &issue, &artifacts, Some(&xtc_src)) .await .unwrap(); assert_eq!(published.epubs.len(), 2); assert!(published.epubs.iter().all(|a| a.path.exists())); assert_eq!(published.epubs[1].edition, Edition::X4); assert!(published.xtc.as_ref().is_some_and(|p| p.exists())); assert_eq!(published.pruned, 0); // The feed is derived from the publish directory, so both editions show // up without publish having written anything. let feed = build_opds(&db, &cfg).await.unwrap(); assert!( feed.contains("The Daily EPUB — 2026-08-15"), "{feed}" ); assert!( feed.contains("The Daily EPUB — 2026-08-15 (X4)"), "{feed}" ); assert!(!feed.contains("out.xtch")); // No XTC artifact is fine — the EPUBs are what the feed lists anyway. let published = publish_issue(&cfg, &issue, &artifacts, None).await.unwrap(); assert!(published.xtc.is_none()); } #[test] fn helpers_escape_and_encode() { assert_eq!(xml_escape("a & b < c"), "a & b < c"); assert_eq!(percent_encode("a b(c).xtch"), "a%20b%28c%29.xtch"); assert_eq!(human_bytes(2_500_000), "2.4 MB"); assert_eq!(human_bytes(4096), "4 KB"); } }