Skip to main content

cranpose_services/content/
file.rs

1use std::{
2    fs::File,
3    io::Write,
4    path::{Path, PathBuf},
5    rc::Rc,
6    time::UNIX_EPOCH,
7};
8
9use super::{
10    Content, ContentEntry, ContentError, ContentFolder, ContentFolderRef, ContentFuture,
11    ContentHandle, ContentMetadata, ContentReaderRef, ContentSinkRef,
12    threaded::{BlockingSink, open_threaded, run_threaded, threaded_sink},
13};
14
15fn io_error(path: &Path, error: std::io::Error) -> ContentError {
16    match error.kind() {
17        std::io::ErrorKind::NotFound => ContentError::NotFound(path.display().to_string()),
18        std::io::ErrorKind::PermissionDenied => {
19            ContentError::PermissionDenied(path.display().to_string())
20        }
21        _ => ContentError::Io(format!("{}: {error}", path.display())),
22    }
23}
24
25fn metadata_for(path: &Path) -> ContentMetadata {
26    let name = path.file_name().map_or_else(
27        || path.display().to_string(),
28        |name| name.to_string_lossy().into_owned(),
29    );
30    let mut metadata = ContentMetadata {
31        name,
32        mime_type: None,
33        len: None,
34        modified_millis: None,
35        identifier: path.display().to_string(),
36    };
37    if let Ok(stat) = std::fs::metadata(path) {
38        if stat.is_file() {
39            metadata.len = Some(stat.len());
40        }
41        metadata.modified_millis = stat
42            .modified()
43            .ok()
44            .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
45            .map(|since| since.as_millis() as u64);
46    }
47    metadata
48}
49
50/// A file on the local filesystem.
51pub struct FileContent {
52    path: PathBuf,
53}
54
55impl FileContent {
56    /// Wraps `path` as readable content.
57    pub fn new(path: impl Into<PathBuf>) -> Self {
58        Self { path: path.into() }
59    }
60
61    /// The wrapped path.
62    pub fn path(&self) -> &Path {
63        &self.path
64    }
65}
66
67impl Content for FileContent {
68    fn metadata(&self) -> ContentMetadata {
69        metadata_for(&self.path)
70    }
71
72    fn open(&self) -> ContentFuture<'_, Result<ContentReaderRef, ContentError>> {
73        let opened = self.path.clone();
74        let path = self.path.clone();
75        open_threaded(
76            move || File::open(&opened).map_err(|error| io_error(&opened, error)),
77            move |error| io_error(&path, error),
78        )
79    }
80
81    fn read_all(&self) -> ContentFuture<'_, Result<Vec<u8>, ContentError>> {
82        let path = self.path.clone();
83        run_threaded(move || std::fs::read(&path).map_err(|error| io_error(&path, error)))
84    }
85}
86
87/// A shared handle to the file at `path`.
88pub fn file_content(path: impl Into<PathBuf>) -> ContentHandle {
89    Rc::new(FileContent::new(path))
90}
91
92/// A directory on the local filesystem.
93pub struct FileFolder {
94    path: PathBuf,
95}
96
97impl FileFolder {
98    /// Wraps `path` as an enumerable folder.
99    pub fn new(path: impl Into<PathBuf>) -> Self {
100        Self { path: path.into() }
101    }
102
103    /// The wrapped path.
104    pub fn path(&self) -> &Path {
105        &self.path
106    }
107}
108
109impl ContentFolder for FileFolder {
110    fn metadata(&self) -> ContentMetadata {
111        metadata_for(&self.path)
112    }
113
114    fn entries(&self) -> ContentFuture<'_, Result<Vec<ContentEntry>, ContentError>> {
115        let path = self.path.clone();
116        Box::pin(async move {
117            let listing = std::fs::read_dir(&path).map_err(|error| io_error(&path, error))?;
118            let mut entries = Vec::new();
119            for child in listing {
120                let child = child.map_err(|error| io_error(&path, error))?;
121                let child_path = child.path();
122                let is_dir = child
123                    .file_type()
124                    .map_or_else(|_| child_path.is_dir(), |kind| kind.is_dir());
125                if is_dir {
126                    entries.push(ContentEntry::Folder(
127                        Rc::new(FileFolder::new(child_path)) as ContentFolderRef
128                    ));
129                } else {
130                    entries.push(ContentEntry::File(
131                        Rc::new(FileContent::new(child_path)) as ContentHandle
132                    ));
133                }
134            }
135            entries.sort_by_key(|entry| entry.metadata().name);
136            Ok(entries)
137        })
138    }
139}
140
141/// A shared handle to the directory at `path`.
142pub fn file_folder(path: impl Into<PathBuf>) -> ContentFolderRef {
143    Rc::new(FileFolder::new(path))
144}
145
146/// A file being written through a temporary sibling, renamed on
147/// [`ContentSink::finish`](super::ContentSink::finish) so a partial write
148/// never replaces a good file. Its [`handle`](FileSink::handle) writes on a
149/// thread of its own.
150pub struct FileSink {
151    destination: PathBuf,
152    staging: PathBuf,
153    file: Option<File>,
154}
155
156impl FileSink {
157    /// Opens a staged writer for `destination`.
158    pub fn create(destination: impl Into<PathBuf>) -> Result<Self, ContentError> {
159        let destination = destination.into();
160        if let Some(parent) = destination.parent() {
161            std::fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?;
162        }
163        let staging = staging_path(&destination);
164        let file = File::create(&staging).map_err(|error| io_error(&staging, error))?;
165        Ok(Self {
166            destination,
167            staging,
168            file: Some(file),
169        })
170    }
171
172    /// A shared handle to this sink, which writes on a thread of its own.
173    pub fn handle(self) -> ContentSinkRef {
174        threaded_sink(self)
175    }
176
177    /// The path this sink commits to.
178    pub fn destination(&self) -> &Path {
179        &self.destination
180    }
181}
182
183fn staging_path(destination: &Path) -> PathBuf {
184    let mut name = destination.file_name().map_or_else(
185        || "content".to_string(),
186        |name| name.to_string_lossy().into_owned(),
187    );
188    name.push_str(".partial");
189    destination.with_file_name(name)
190}
191
192impl BlockingSink for FileSink {
193    fn write(&mut self, bytes: &[u8]) -> Result<(), ContentError> {
194        let file = self
195            .file
196            .as_mut()
197            .ok_or_else(|| ContentError::Io("sink is already finished".into()))?;
198        file.write_all(bytes)
199            .map_err(|error| io_error(&self.staging, error))
200    }
201
202    fn finish(&mut self) -> Result<(), ContentError> {
203        let Some(mut file) = self.file.take() else {
204            return Ok(());
205        };
206        file.flush()
207            .map_err(|error| io_error(&self.staging, error))?;
208        file.sync_all()
209            .map_err(|error| io_error(&self.staging, error))?;
210        drop(file);
211        std::fs::rename(&self.staging, &self.destination)
212            .map_err(|error| io_error(&self.destination, error))
213    }
214}
215
216impl Drop for FileSink {
217    fn drop(&mut self) {
218        if self.file.is_some() {
219            let _ = std::fs::remove_file(&self.staging);
220        }
221    }
222}
223
224#[cfg(test)]
225#[path = "tests/file_tests.rs"]
226mod tests;