cranpose_services/content/
file.rs1use std::{
2 cell::RefCell,
3 fs::File,
4 io::{Read, Write},
5 path::{Path, PathBuf},
6 rc::Rc,
7 time::UNIX_EPOCH,
8};
9
10use super::{
11 Content, ContentEntry, ContentError, ContentFolder, ContentFolderRef, ContentFuture,
12 ContentHandle, ContentMetadata, ContentReader, ContentReaderRef, ContentSink, ContentSinkRef,
13 DEFAULT_CHUNK_LEN,
14};
15
16fn io_error(path: &Path, error: std::io::Error) -> ContentError {
17 match error.kind() {
18 std::io::ErrorKind::NotFound => ContentError::NotFound(path.display().to_string()),
19 std::io::ErrorKind::PermissionDenied => {
20 ContentError::PermissionDenied(path.display().to_string())
21 }
22 _ => ContentError::Io(format!("{}: {error}", path.display())),
23 }
24}
25
26fn metadata_for(path: &Path) -> ContentMetadata {
27 let name = path
28 .file_name()
29 .map(|name| name.to_string_lossy().into_owned())
30 .unwrap_or_else(|| path.display().to_string());
31 let mut metadata = ContentMetadata {
32 name,
33 mime_type: None,
34 len: None,
35 modified_millis: None,
36 identifier: path.display().to_string(),
37 };
38 if let Ok(stat) = std::fs::metadata(path) {
39 if stat.is_file() {
40 metadata.len = Some(stat.len());
41 }
42 metadata.modified_millis = stat
43 .modified()
44 .ok()
45 .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
46 .map(|since| since.as_millis() as u64);
47 }
48 metadata
49}
50
51pub struct FileContent {
53 path: PathBuf,
54}
55
56impl FileContent {
57 pub fn new(path: impl Into<PathBuf>) -> Self {
59 Self { path: path.into() }
60 }
61
62 pub fn path(&self) -> &Path {
64 &self.path
65 }
66}
67
68impl Content for FileContent {
69 fn metadata(&self) -> ContentMetadata {
70 metadata_for(&self.path)
71 }
72
73 fn open(&self) -> ContentFuture<'_, Result<ContentReaderRef, ContentError>> {
74 let path = self.path.clone();
75 Box::pin(async move {
76 let file = File::open(&path).map_err(|error| io_error(&path, error))?;
77 Ok(Rc::new(FileReader {
78 path,
79 file: RefCell::new(Some(file)),
80 }) as ContentReaderRef)
81 })
82 }
83
84 fn read_all(&self) -> ContentFuture<'_, Result<Vec<u8>, ContentError>> {
85 let path = self.path.clone();
86 Box::pin(async move { std::fs::read(&path).map_err(|error| io_error(&path, error)) })
87 }
88}
89
90pub fn file_content(path: impl Into<PathBuf>) -> ContentHandle {
92 Rc::new(FileContent::new(path))
93}
94
95struct FileReader {
96 path: PathBuf,
97 file: RefCell<Option<File>>,
98}
99
100impl ContentReader for FileReader {
101 fn read_chunk(&self) -> ContentFuture<'_, Result<Option<Vec<u8>>, ContentError>> {
102 Box::pin(async move {
103 let mut slot = self.file.borrow_mut();
104 let Some(file) = slot.as_mut() else {
105 return Ok(None);
106 };
107 let mut buffer = vec![0u8; DEFAULT_CHUNK_LEN];
108 let mut filled = 0;
109 while filled < buffer.len() {
110 match file.read(&mut buffer[filled..]) {
111 Ok(0) => break,
112 Ok(read) => filled += read,
113 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
114 Err(error) => return Err(io_error(&self.path, error)),
115 }
116 }
117 if filled == 0 {
118 *slot = None;
119 return Ok(None);
120 }
121 buffer.truncate(filled);
122 Ok(Some(buffer))
123 })
124 }
125}
126
127pub struct FileFolder {
129 path: PathBuf,
130}
131
132impl FileFolder {
133 pub fn new(path: impl Into<PathBuf>) -> Self {
135 Self { path: path.into() }
136 }
137
138 pub fn path(&self) -> &Path {
140 &self.path
141 }
142}
143
144impl ContentFolder for FileFolder {
145 fn metadata(&self) -> ContentMetadata {
146 metadata_for(&self.path)
147 }
148
149 fn entries(&self) -> ContentFuture<'_, Result<Vec<ContentEntry>, ContentError>> {
150 let path = self.path.clone();
151 Box::pin(async move {
152 let listing = std::fs::read_dir(&path).map_err(|error| io_error(&path, error))?;
153 let mut entries = Vec::new();
154 for child in listing {
155 let child = child.map_err(|error| io_error(&path, error))?;
156 let child_path = child.path();
157 let is_dir = child
158 .file_type()
159 .map(|kind| kind.is_dir())
160 .unwrap_or_else(|_| child_path.is_dir());
161 if is_dir {
162 entries.push(ContentEntry::Folder(
163 Rc::new(FileFolder::new(child_path)) as ContentFolderRef
164 ));
165 } else {
166 entries.push(ContentEntry::File(
167 Rc::new(FileContent::new(child_path)) as ContentHandle
168 ));
169 }
170 }
171 entries.sort_by_key(|entry| entry.metadata().name);
172 Ok(entries)
173 })
174 }
175}
176
177pub fn file_folder(path: impl Into<PathBuf>) -> ContentFolderRef {
179 Rc::new(FileFolder::new(path))
180}
181
182pub struct FileSink {
185 destination: PathBuf,
186 staging: PathBuf,
187 file: RefCell<Option<File>>,
188}
189
190impl FileSink {
191 pub fn create(destination: impl Into<PathBuf>) -> Result<Self, ContentError> {
193 let destination = destination.into();
194 if let Some(parent) = destination.parent() {
195 std::fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?;
196 }
197 let staging = staging_path(&destination);
198 let file = File::create(&staging).map_err(|error| io_error(&staging, error))?;
199 Ok(Self {
200 destination,
201 staging,
202 file: RefCell::new(Some(file)),
203 })
204 }
205
206 pub fn handle(self) -> ContentSinkRef {
208 Rc::new(self)
209 }
210
211 pub fn destination(&self) -> &Path {
213 &self.destination
214 }
215}
216
217fn staging_path(destination: &Path) -> PathBuf {
218 let mut name = destination
219 .file_name()
220 .map(|name| name.to_string_lossy().into_owned())
221 .unwrap_or_else(|| "content".to_string());
222 name.push_str(".partial");
223 destination.with_file_name(name)
224}
225
226impl ContentSink for FileSink {
227 fn write_chunk(&self, bytes: Vec<u8>) -> ContentFuture<'_, Result<(), ContentError>> {
228 Box::pin(async move {
229 let mut slot = self.file.borrow_mut();
230 let file = slot
231 .as_mut()
232 .ok_or_else(|| ContentError::Io("sink is already finished".into()))?;
233 file.write_all(&bytes)
234 .map_err(|error| io_error(&self.staging, error))
235 })
236 }
237
238 fn finish(&self) -> ContentFuture<'_, Result<(), ContentError>> {
239 Box::pin(async move {
240 let Some(mut file) = self.file.borrow_mut().take() else {
241 return Ok(());
242 };
243 file.flush()
244 .map_err(|error| io_error(&self.staging, error))?;
245 file.sync_all()
246 .map_err(|error| io_error(&self.staging, error))?;
247 drop(file);
248 std::fs::rename(&self.staging, &self.destination)
249 .map_err(|error| io_error(&self.destination, error))
250 })
251 }
252}
253
254impl Drop for FileSink {
255 fn drop(&mut self) {
256 if self.file.borrow().is_some() {
257 let _ = std::fs::remove_file(&self.staging);
258 }
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265 use crate::content::{DEFAULT_CHUNK_LEN, collect_stream, folder_files, write_all};
266
267 fn temp_dir(name: &str) -> PathBuf {
268 crate::test_scratch_dir(&format!("content-{name}"))
269 }
270
271 #[test]
272 fn a_file_streams_in_chunks_and_reports_its_length() {
273 let root = temp_dir("chunks");
274 let path = root.join("payload.bin");
275 let payload = vec![3u8; DEFAULT_CHUNK_LEN + 5];
276 std::fs::write(&path, &payload).unwrap();
277
278 let content = file_content(&path);
279 assert_eq!(content.metadata().name, "payload.bin");
280 assert_eq!(content.metadata().len, Some(payload.len() as u64));
281
282 let sizes = pollster::block_on(async {
283 let reader = content.open().await.unwrap();
284 let mut sizes = Vec::new();
285 while let Some(chunk) = reader.read_chunk().await.unwrap() {
286 sizes.push(chunk.len());
287 }
288 sizes
289 });
290 assert_eq!(sizes, vec![DEFAULT_CHUNK_LEN, 5]);
291 assert_eq!(pollster::block_on(content.read_all()).unwrap(), payload);
292 std::fs::remove_dir_all(&root).unwrap();
293 }
294
295 #[test]
296 fn a_missing_file_reports_not_found() {
297 let root = temp_dir("missing");
298 let content = file_content(root.join("absent.bin"));
299 assert!(matches!(
300 pollster::block_on(content.read_all()),
301 Err(ContentError::NotFound(_))
302 ));
303 std::fs::remove_dir_all(&root).unwrap();
304 }
305
306 #[test]
307 fn a_folder_streams_its_whole_tree() {
308 let root = temp_dir("tree");
309 std::fs::create_dir_all(root.join("nested")).unwrap();
310 std::fs::write(root.join("a.txt"), b"a").unwrap();
311 std::fs::write(root.join("nested/b.txt"), b"b").unwrap();
312
313 let stream = folder_files(file_folder(&root));
314 let mut names: Vec<String> = pollster::block_on(collect_stream(&stream))
315 .unwrap()
316 .iter()
317 .map(|file| file.metadata().name)
318 .collect();
319 names.sort();
320 assert_eq!(names, vec!["a.txt", "b.txt"]);
321 std::fs::remove_dir_all(&root).unwrap();
322 }
323
324 #[test]
325 fn a_sink_commits_atomically_and_discards_unfinished_writes() {
326 let root = temp_dir("sink");
327 let destination = root.join("out/report.bin");
328
329 let sink = FileSink::create(&destination).unwrap().handle();
330 pollster::block_on(write_all(&sink, b"committed".to_vec())).unwrap();
331 assert_eq!(std::fs::read(&destination).unwrap(), b"committed");
332
333 let abandoned = root.join("out/abandoned.bin");
334 {
335 let sink = FileSink::create(&abandoned).unwrap();
336 pollster::block_on(sink.write_chunk(b"partial".to_vec())).unwrap();
337 }
338 assert!(!abandoned.exists());
339 assert!(!staging_path(&abandoned).exists());
340 std::fs::remove_dir_all(&root).unwrap();
341 }
342}