use crate::linkleaf_proto::Feed;
use anyhow::{Context, Result};
use prost::Message;
use std::path::Path;
use std::{fs, io::Write};
pub fn read_feed<P: AsRef<Path>>(path: P) -> Result<Feed> {
let path = path.as_ref();
let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
Feed::decode(bytes.as_slice())
.with_context(|| format!("failed to decode protobuf: {}", path.display()))
}
pub fn write_feed<P: AsRef<Path>>(path: P, feed: Feed) -> Result<Feed> {
let path = path.as_ref();
if let Some(dir) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
fs::create_dir_all(dir)
.with_context(|| format!("failed to create directory {}", dir.display()))?;
}
let mut buf = Vec::with_capacity(1024);
feed.encode(&mut buf)
.context("failed to encode protobuf Feed")?;
let tmp = path.with_extension("pb.tmp");
{
let mut f =
fs::File::create(&tmp).with_context(|| format!("failed to write {}", tmp.display()))?;
f.write_all(&buf)?;
f.sync_all()?;
}
fs::rename(&tmp, &path)
.with_context(|| format!("failed to move temp file into place: {}", path.display()))?;
Ok(feed)
}
#[cfg(test)]
mod tests {
use super::{read_feed, write_feed};
use crate::linkleaf_proto::Feed;
use anyhow::Result;
use std::{fs, path::PathBuf};
use tempfile::tempdir;
fn mk_feed(title: &str) -> Feed {
Feed {
title: title.to_string(),
..Default::default()
}
}
#[test]
fn write_then_read_roundtrip() -> Result<()> {
let dir = tempdir()?;
let path = dir.path().join("feed.pb");
let original = mk_feed("Roundtrip");
let written = write_feed(&path, original.clone())?;
assert_eq!(written, original);
let read = read_feed(&path)?;
assert_eq!(read, original);
Ok(())
}
#[test]
fn write_feed_creates_parent_dirs() -> Result<()> {
let dir = tempdir()?;
let path: PathBuf = dir.path().join("nested/dir/structure/feed.pb");
let feed = mk_feed("Nested OK");
write_feed(&path, feed)?;
assert!(path.exists(), "destination file should exist");
Ok(())
}
#[test]
fn write_feed_overwrites_existing_and_no_tmp_left() -> Result<()> {
let dir = tempdir()?;
let path = dir.path().join("feed.pb");
let tmp = path.with_extension("pb.tmp");
let first = mk_feed("v1");
write_feed(&path, first)?;
let second = mk_feed("v2");
write_feed(&path, second.clone())?;
let read_back = read_feed(&path)?;
assert_eq!(read_back.title, "v2");
assert!(
!tmp.exists(),
"temporary file should not remain after successful rename"
);
Ok(())
}
#[test]
fn read_feed_nonexistent_file_errors_with_context() {
let dir = tempdir().unwrap();
let path = dir.path().join("does_not_exist.pb");
let err = read_feed(&path).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("failed to read"),
"error should contain read context, got: {msg}"
);
}
#[test]
fn read_feed_invalid_protobuf_errors_with_context() -> Result<()> {
let dir = tempdir()?;
let path = dir.path().join("invalid.pb");
fs::write(&path, b"this is not a protobuf")?;
let err = read_feed(&path).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("failed to decode protobuf:"),
"error should contain decode context, got: {msg}"
);
Ok(())
}
}