Skip to main content

turtle_build/infrastructure/file_system/
os.rs

1use super::Metadata;
2use crate::infrastructure::{FileError, FileSystem};
3use async_trait::async_trait;
4use std::{
5    fs::{exists, metadata, read_to_string},
6    io,
7    path::Path,
8};
9use tokio::{
10    fs::{create_dir_all, read, remove_file},
11    sync::Semaphore,
12};
13
14/// A file system backed by an operating system.
15#[derive(Debug)]
16pub struct OsFileSystem {
17    semaphore: Semaphore,
18}
19
20impl OsFileSystem {
21    /// Creates a file system.
22    pub fn new(open_file_limit: usize) -> Self {
23        Self {
24            semaphore: Semaphore::new(open_file_limit.min(Semaphore::MAX_PERMITS)),
25        }
26    }
27
28    fn error(error: io::Error, path: &Path) -> FileError {
29        FileError::new(format!("{}: {}", error, path.display()))
30    }
31}
32
33#[async_trait]
34impl FileSystem for OsFileSystem {
35    async fn read_file(&self, path: &Path) -> Result<Vec<u8>, FileError> {
36        let _permit = self.semaphore.acquire().await?;
37
38        read(path).await.map_err(|error| Self::error(error, path))
39    }
40
41    // Text files are read inline because they are small enough for a read to be
42    // cheaper than a round trip through the blocking thread pool.
43    async fn read_file_to_string(&self, path: &Path) -> Result<String, FileError> {
44        read_to_string(path).map_err(|error| Self::error(error, path))
45    }
46
47    // Existence checks run inline because a `stat` call is cheaper than a round
48    // trip through the blocking thread pool.
49    async fn exists(&self, path: &Path) -> Result<bool, FileError> {
50        exists(path).map_err(|error| Self::error(error, path))
51    }
52
53    // Metadata queries run inline for the same reason as existence checks.
54    async fn metadata(&self, path: &Path) -> Result<Option<Metadata>, FileError> {
55        match metadata(path) {
56            Ok(metadata) => Ok(Some(metadata.try_into()?)),
57            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
58            Err(error) => Err(Self::error(error, path)),
59        }
60    }
61
62    async fn create_directory(&self, path: &Path) -> Result<(), FileError> {
63        create_dir_all(path)
64            .await
65            .map_err(|error| Self::error(error, path))?;
66
67        Ok(())
68    }
69
70    async fn remove_file(&self, path: &Path) -> Result<(), FileError> {
71        remove_file(path)
72            .await
73            .map_err(|error| Self::error(error, path))?;
74
75        Ok(())
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use pretty_assertions::assert_eq;
83    use std::fs::{metadata, write};
84    use tempfile::tempdir;
85
86    #[test]
87    fn limit_open_files() {
88        assert_eq!(OsFileSystem::new(42).semaphore.available_permits(), 42);
89    }
90
91    #[test]
92    fn limit_open_files_to_maximum_permits() {
93        assert_eq!(
94            OsFileSystem::new(usize::MAX).semaphore.available_permits(),
95            Semaphore::MAX_PERMITS
96        );
97    }
98
99    #[tokio::test]
100    async fn read_file() {
101        let directory = tempdir().unwrap();
102        let path = directory.path().join("foo");
103
104        write(&path, "bar").unwrap();
105
106        assert_eq!(OsFileSystem::new(1).read_file(&path).await.unwrap(), b"bar");
107    }
108
109    #[tokio::test]
110    async fn fail_to_read_missing_file() {
111        let directory = tempdir().unwrap();
112        let path = directory.path().join("foo");
113
114        assert!(
115            OsFileSystem::new(1)
116                .read_file(&path)
117                .await
118                .unwrap_err()
119                .to_string()
120                .contains(&path.display().to_string())
121        );
122    }
123
124    #[tokio::test]
125    async fn read_file_to_string() {
126        let directory = tempdir().unwrap();
127        let path = directory.path().join("foo");
128
129        write(&path, "bar").unwrap();
130
131        assert_eq!(
132            OsFileSystem::new(1)
133                .read_file_to_string(&path)
134                .await
135                .unwrap(),
136            "bar"
137        );
138    }
139
140    #[tokio::test]
141    async fn fail_to_read_missing_file_to_string() {
142        let directory = tempdir().unwrap();
143        let path = directory.path().join("foo");
144
145        assert!(
146            OsFileSystem::new(1)
147                .read_file_to_string(&path)
148                .await
149                .unwrap_err()
150                .to_string()
151                .contains(&path.display().to_string())
152        );
153    }
154
155    #[tokio::test]
156    async fn check_file_existence() {
157        let directory = tempdir().unwrap();
158        let path = directory.path().join("foo");
159        let file_system = OsFileSystem::new(1);
160
161        assert!(!file_system.exists(&path).await.unwrap());
162
163        write(&path, "").unwrap();
164
165        assert!(file_system.exists(&path).await.unwrap());
166    }
167
168    #[tokio::test]
169    async fn get_metadata_of_file() {
170        let directory = tempdir().unwrap();
171        let path = directory.path().join("foo");
172
173        write(&path, "").unwrap();
174
175        assert_eq!(
176            OsFileSystem::new(1).metadata(&path).await.unwrap(),
177            Some(Metadata::new(
178                metadata(&path).unwrap().modified().unwrap(),
179                false
180            ))
181        );
182    }
183
184    #[tokio::test]
185    async fn get_metadata_of_directory() {
186        let directory = tempdir().unwrap();
187
188        assert_eq!(
189            OsFileSystem::new(1)
190                .metadata(directory.path())
191                .await
192                .unwrap(),
193            Some(Metadata::new(
194                metadata(directory.path()).unwrap().modified().unwrap(),
195                true
196            ))
197        );
198    }
199
200    #[tokio::test]
201    async fn get_metadata_of_missing_file() {
202        let directory = tempdir().unwrap();
203
204        assert_eq!(
205            OsFileSystem::new(1)
206                .metadata(&directory.path().join("foo"))
207                .await,
208            Ok(None)
209        );
210    }
211
212    // Windows reports a path under a file as a missing file.
213    #[cfg(unix)]
214    #[tokio::test]
215    async fn fail_to_get_metadata_under_file() {
216        let directory = tempdir().unwrap();
217        let path = directory.path().join("foo");
218
219        write(&path, "").unwrap();
220
221        assert!(
222            OsFileSystem::new(1)
223                .metadata(&path.join("bar"))
224                .await
225                .unwrap_err()
226                .to_string()
227                .contains(&path.join("bar").display().to_string())
228        );
229    }
230
231    #[tokio::test]
232    async fn create_directory() {
233        let directory = tempdir().unwrap();
234        let path = directory.path().join("foo").join("bar");
235
236        OsFileSystem::new(1).create_directory(&path).await.unwrap();
237
238        assert!(metadata(&path).unwrap().is_dir());
239    }
240
241    #[tokio::test]
242    async fn remove_file() {
243        let directory = tempdir().unwrap();
244        let path = directory.path().join("foo");
245
246        write(&path, "").unwrap();
247
248        OsFileSystem::new(1).remove_file(&path).await.unwrap();
249
250        assert!(!path.try_exists().unwrap());
251    }
252}