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