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}