use std::fs; use std::io; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; fn feed_path(state_dir: &Path, name: &str) -> PathBuf { state_dir.join(format!("{name}.xml")) } fn timestamp_path(state_dir: &Path, name: &str) -> PathBuf { state_dir.join(format!("{name}.timestamp")) } fn validators_path(state_dir: &Path, name: &str) -> PathBuf { state_dir.join(format!("{name}.validators")) } /// The conditional-request validators an origin server sent with a site's /// last successful fetch, so the next fetch can ask "has this changed?" /// instead of downloading the page again regardless. #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct Validators { pub etag: Option, pub last_modified: Option, } /// Writes `path` by writing to a `.tmp` sibling and renaming over the /// destination, so a reader never observes a partially-written file. fn atomic_write(path: &Path, contents: &[u8]) -> io::Result<()> { let mut tmp = path.as_os_str().to_owned(); tmp.push(".tmp"); let tmp_path = PathBuf::from(tmp); fs::write(&tmp_path, contents)?; fs::rename(&tmp_path, path) } pub fn write_feed(state_dir: &Path, name: &str, xml: &str) -> io::Result<()> { fs::create_dir_all(state_dir)?; atomic_write(&feed_path(state_dir, name), xml.as_bytes())?; let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); atomic_write(×tamp_path(state_dir, name), now.to_string().as_bytes()) } pub fn read_feed(state_dir: &Path, name: &str) -> io::Result { fs::read_to_string(feed_path(state_dir, name)) } pub fn read_timestamp(state_dir: &Path, name: &str) -> Option { fs::read_to_string(timestamp_path(state_dir, name)) .ok()? .trim() .parse() .ok() } /// Persists the validators from a site's latest response so the next fetch /// can send them back as `If-None-Match`/`If-Modified-Since`. Called /// unconditionally after every successful (non-304) fetch, including when a /// server stops sending a validator it previously sent -- the stored value /// should track what the server currently supports. pub fn write_validators(state_dir: &Path, name: &str, validators: &Validators) -> io::Result<()> { fs::create_dir_all(state_dir)?; let contents = format!( "{}\n{}\n", validators.etag.as_deref().unwrap_or(""), validators.last_modified.as_deref().unwrap_or(""), ); atomic_write(&validators_path(state_dir, name), contents.as_bytes()) } pub fn read_validators(state_dir: &Path, name: &str) -> Validators { let contents = fs::read_to_string(validators_path(state_dir, name)).unwrap_or_default(); let mut lines = contents.lines(); let non_empty = |s: &str| (!s.is_empty()).then(|| s.to_string()); Validators { etag: lines.next().and_then(non_empty), last_modified: lines.next().and_then(non_empty), } } #[cfg(test)] mod tests { use super::*; fn scratch_dir(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("luafeed-cache-test-{name}")); let _ = fs::remove_dir_all(&dir); dir } #[test] fn missing_validators_file_reads_as_all_none() { let dir = scratch_dir("missing-validators"); assert_eq!(read_validators(&dir, "site"), Validators::default()); } #[test] fn validators_round_trip_through_write_and_read() { let dir = scratch_dir("validators-round-trip"); let validators = Validators { etag: Some("\"abc123\"".to_string()), last_modified: Some("Sun, 06 Sep 2026 00:00:00 GMT".to_string()), }; write_validators(&dir, "site", &validators).unwrap(); assert_eq!(read_validators(&dir, "site"), validators); } #[test] fn validators_round_trip_with_only_one_field_present() { let dir = scratch_dir("validators-partial"); let validators = Validators { etag: Some("\"abc123\"".to_string()), last_modified: None, }; write_validators(&dir, "site", &validators).unwrap(); assert_eq!(read_validators(&dir, "site"), validators); } }