linkleaf_core/fs.rs
1use crate::linkleaf_proto::Feed;
2use anyhow::{Context, Result};
3use prost::Message;
4use std::path::Path;
5use std::{fs, io::Write};
6
7/// Read a protobuf feed from disk.
8///
9/// ## Behavior
10/// - Reads the entire file at `path` into memory.
11/// - Decodes the bytes into a [`Feed`] using `prost`’s `Message::decode`.
12///
13/// ## Arguments
14/// - `path`: Path to the `.pb` file to read.
15///
16/// ## Returns
17/// The decoded [`Feed`] on success.
18///
19/// ## Errors
20/// - I/O errors from [`fs::read`], wrapped with context
21/// `"failed to read {path}"`.
22/// - Protobuf decode errors from `Feed::decode`, wrapped with context
23/// `"failed to decode protobuf: {path}"`.
24/// - The error type is [`anyhow::Error`] via your crate-wide `Result`.
25///
26/// ## Example
27/// ```no_run
28/// use std::path::PathBuf;
29/// use linkleaf_core::fs::read_feed;
30/// use anyhow::Result;
31///
32/// fn main() -> Result<()> {
33/// let path = PathBuf::from("mylinks.pb");
34/// let feed = read_feed(&path)?;
35/// println!("title: {}, links: {}", feed.title, feed.links.len());
36/// Ok::<(), anyhow::Error>(())
37/// }
38/// ```
39pub fn read_feed<P: AsRef<Path>>(path: P) -> Result<Feed> {
40 let path = path.as_ref();
41 let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
42 Feed::decode(bytes.as_slice())
43 .with_context(|| format!("failed to decode protobuf: {}", path.display()))
44}
45
46/// Write a protobuf feed to disk **atomically** (best-effort).
47///
48/// ## Behavior
49/// - Ensures the parent directory of `path` exists (creates it if needed).
50/// - Encodes `feed` to a temporary file with extension `".pb.tmp"`.
51/// - Flushes and then renames the temp file over `path`.
52/// - On Unix/POSIX, the rename is atomic when source and destination are on
53/// the same filesystem.
54/// - On Windows, `rename` may fail if the destination exists; this function
55/// forwards that error as-is.
56///
57/// The input `feed` is consumed and returned unchanged on success to make
58/// call sites ergonomic.
59///
60/// ## Arguments
61/// - `path`: Destination path of the `.pb` file.
62/// - `feed`: The feed to persist (consumed).
63///
64/// ## Returns
65/// The same [`Feed`] value that was written (handy for chaining).
66///
67/// ## Errors
68/// - Directory creation errors from [`fs::create_dir_all`], with context
69/// `"failed to create directory {dir}"`.
70/// - File creation/write/flush errors for the temporary file, with context
71/// `"failed to write {tmp}"`.
72/// - Rename errors when moving the temp file into place, with context
73/// `"failed to move temp file into place: {path}"`.
74/// - Protobuf encode errors from `feed.encode(&mut buf)`.
75/// - The error type is [`anyhow::Error`] via your crate-wide `Result`.
76///
77/// ## Example
78/// ```no_run
79/// use std::path::PathBuf;
80/// use linkleaf_core::fs::{read_feed, write_feed};
81/// use anyhow::Result;
82///
83/// fn main() -> Result<()> {
84/// let path = PathBuf::from("mylinks.pb");
85/// let mut feed = read_feed(&path)?; // or Feed { .. } if creating anew
86/// feed.title = "My Links".into();
87/// let written = write_feed(&path, feed)?; // atomic write
88/// assert_eq!(written.title, "My Links");
89/// Ok(())
90/// }
91/// ```
92///
93/// ## Notes
94/// - Atomicity requires the temporary file and the destination to be on the
95/// **same filesystem**.
96/// - If multiple processes may write concurrently, consider adding a file lock
97/// around the write section.
98pub fn write_feed<P: AsRef<Path>>(path: P, feed: Feed) -> Result<Feed> {
99 let path = path.as_ref();
100 // Ensure parent directory exists (if any)
101 if let Some(dir) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
102 fs::create_dir_all(dir)
103 .with_context(|| format!("failed to create directory {}", dir.display()))?;
104 }
105
106 let mut buf = Vec::with_capacity(1024);
107 feed.encode(&mut buf)
108 .context("failed to encode protobuf Feed")?;
109
110 let tmp = path.with_extension("pb.tmp");
111 {
112 let mut f =
113 fs::File::create(&tmp).with_context(|| format!("failed to write {}", tmp.display()))?;
114 f.write_all(&buf)?;
115 // Ensure bytes are on disk, not just in the OS page cache
116 f.sync_all()?;
117 }
118 fs::rename(&tmp, &path)
119 .with_context(|| format!("failed to move temp file into place: {}", path.display()))?;
120 Ok(feed)
121}
122
123#[cfg(test)]
124mod tests {
125 use super::{read_feed, write_feed};
126 use crate::linkleaf_proto::Feed;
127 use anyhow::Result;
128 use std::{fs, path::PathBuf};
129 use tempfile::tempdir;
130
131 // Small helper to build a Feed with just the fields we care about.
132 // Prost-generated types usually derive Default + Clone + PartialEq.
133 fn mk_feed(title: &str) -> Feed {
134 Feed {
135 title: title.to_string(),
136 ..Default::default()
137 }
138 }
139
140 #[test]
141 fn write_then_read_roundtrip() -> Result<()> {
142 let dir = tempdir()?;
143 let path = dir.path().join("feed.pb");
144
145 let original = mk_feed("Roundtrip");
146 let written = write_feed(&path, original.clone())?;
147 // write_feed returns the same Feed it was given
148 assert_eq!(written, original);
149
150 let read = read_feed(&path)?;
151 assert_eq!(read, original);
152
153 Ok(())
154 }
155
156 #[test]
157 fn write_feed_creates_parent_dirs() -> Result<()> {
158 let dir = tempdir()?;
159 // nested dirs that don't exist yet
160 let path: PathBuf = dir.path().join("nested/dir/structure/feed.pb");
161
162 let feed = mk_feed("Nested OK");
163 write_feed(&path, feed)?;
164
165 assert!(path.exists(), "destination file should exist");
166 Ok(())
167 }
168
169 #[test]
170 fn write_feed_overwrites_existing_and_no_tmp_left() -> Result<()> {
171 let dir = tempdir()?;
172 let path = dir.path().join("feed.pb");
173 let tmp = path.with_extension("pb.tmp");
174
175 let first = mk_feed("v1");
176 write_feed(&path, first)?;
177
178 let second = mk_feed("v2");
179 write_feed(&path, second.clone())?;
180
181 let read_back = read_feed(&path)?;
182 assert_eq!(read_back.title, "v2");
183 assert!(
184 !tmp.exists(),
185 "temporary file should not remain after successful rename"
186 );
187 Ok(())
188 }
189
190 #[test]
191 fn read_feed_nonexistent_file_errors_with_context() {
192 let dir = tempdir().unwrap();
193 let path = dir.path().join("does_not_exist.pb");
194
195 let err = read_feed(&path).unwrap_err();
196 let msg = err.to_string();
197 assert!(
198 msg.contains("failed to read"),
199 "error should contain read context, got: {msg}"
200 );
201 }
202
203 #[test]
204 fn read_feed_invalid_protobuf_errors_with_context() -> Result<()> {
205 let dir = tempdir()?;
206 let path = dir.path().join("invalid.pb");
207
208 // Write junk bytes so prost::Message::decode fails
209 fs::write(&path, b"this is not a protobuf")?;
210
211 let err = read_feed(&path).unwrap_err();
212 let msg = err.to_string();
213 assert!(
214 msg.contains("failed to decode protobuf:"),
215 "error should contain decode context, got: {msg}"
216 );
217 Ok(())
218 }
219}