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