Skip to main content

kcl_lib/fs/
in_memory.rs

1use ahash::HashMap;
2use async_trait::async_trait;
3use kcl_error::KclError;
4use kcl_error::KclErrorDetails;
5use kcl_error::SourceRange;
6
7use crate::TypedPath;
8
9#[derive(Clone, Eq, PartialEq)]
10pub struct InMemoryFiles {
11    files: HashMap<String, Vec<u8>>,
12}
13
14impl InMemoryFiles {
15    pub fn new(files: HashMap<String, Vec<u8>>) -> Self {
16        Self { files }
17    }
18}
19
20impl std::fmt::Debug for InMemoryFiles {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        let filenames: Vec<_> = self.files.keys().collect();
23        f.debug_struct("InMemoryFiles").field("files", &filenames).finish()
24    }
25}
26
27#[async_trait]
28impl crate::fs::FileSystem for InMemoryFiles {
29    async fn read(&self, path: &TypedPath, source_range: SourceRange) -> Result<Vec<u8>, KclError> {
30        let path_str = path.to_string();
31        let bytes = self.files.get(&path_str).ok_or_else(|| {
32            KclError::new_io(KclErrorDetails::new(
33                format!("No such file {} found", path.display()),
34                vec![source_range],
35            ))
36        })?;
37        Ok(bytes.to_owned())
38    }
39
40    async fn read_to_string(&self, path: &TypedPath, source_range: SourceRange) -> Result<String, KclError> {
41        let bytes = self.read(path, source_range).await?;
42        String::from_utf8(bytes).map_err(|err| {
43            KclError::new_io(KclErrorDetails::new(
44                format!("File {} had invalid UTF-8 bytes: {err}", path.display()),
45                vec![source_range],
46            ))
47        })
48    }
49
50    async fn exists(&self, path: &TypedPath, _source_range: SourceRange) -> Result<bool, crate::errors::KclError> {
51        Ok(self.files.contains_key(&path.to_string()))
52    }
53
54    async fn get_all_files(
55        &self,
56        base_path: &TypedPath,
57        _source_range: SourceRange,
58    ) -> Result<Vec<TypedPath>, crate::errors::KclError> {
59        let all_files = self
60            .files
61            .keys()
62            .map(|filename| TypedPath::new(filename))
63            .filter(|file| file.starts_with(base_path))
64            .collect();
65        Ok(all_files)
66    }
67}
68
69#[cfg(test)]
70mod tests {
71    use ahash::HashMap;
72    use kcl_error::SourceRange;
73
74    use super::InMemoryFiles;
75    use crate::TypedPath;
76    use crate::fs::FileSystem;
77
78    fn files(entries: &[(&str, &[u8])]) -> InMemoryFiles {
79        InMemoryFiles::new(
80            entries
81                .iter()
82                .map(|(path, bytes)| ((*path).to_owned(), (*bytes).to_vec()))
83                .collect::<HashMap<_, _>>(),
84        )
85    }
86
87    #[tokio::test]
88    async fn reads_existing_file_as_bytes_and_string() {
89        let fs = files(&[("/project/main.kcl", b"part = 1")]);
90        let path = TypedPath::new("/project/main.kcl");
91
92        assert_eq!(
93            fs.read(&path, SourceRange::default()).await.unwrap(),
94            b"part = 1".to_vec()
95        );
96        assert_eq!(
97            fs.read_to_string(&path, SourceRange::default()).await.unwrap(),
98            "part = 1"
99        );
100    }
101
102    #[tokio::test]
103    async fn reports_file_existence() {
104        let fs = files(&[("/project/main.kcl", b"part = 1")]);
105
106        assert!(
107            fs.exists(&TypedPath::new("/project/main.kcl"), SourceRange::default())
108                .await
109                .unwrap()
110        );
111        assert!(
112            !fs.exists(&TypedPath::new("/project/missing.kcl"), SourceRange::default())
113                .await
114                .unwrap()
115        );
116    }
117
118    #[tokio::test]
119    async fn read_errors_when_file_is_missing() {
120        let fs = files(&[]);
121
122        let err = fs
123            .read(&TypedPath::new("/project/missing.kcl"), SourceRange::default())
124            .await
125            .unwrap_err();
126
127        assert!(err.to_string().contains("No such file /project/missing.kcl found"));
128    }
129
130    #[tokio::test]
131    async fn read_to_string_errors_when_file_is_not_utf8() {
132        let fs = files(&[("/project/binary.kcl", &[0xff, 0xfe])]);
133
134        let err = fs
135            .read_to_string(&TypedPath::new("/project/binary.kcl"), SourceRange::default())
136            .await
137            .unwrap_err();
138
139        assert!(
140            err.to_string()
141                .contains("File /project/binary.kcl had invalid UTF-8 bytes")
142        );
143    }
144
145    #[tokio::test]
146    async fn get_all_files_returns_files_under_base_path() {
147        let fs = files(&[
148            ("/project/main.kcl", b"main"),
149            ("/project/sub/part.kcl", b"part"),
150            ("/projectile/not-project.kcl", b"outside"),
151            ("/other/file.kcl", b"other"),
152        ]);
153
154        let mut files = fs
155            .get_all_files(&TypedPath::new("/project"), SourceRange::default())
156            .await
157            .unwrap()
158            .into_iter()
159            .map(|path| path.to_string())
160            .collect::<Vec<_>>();
161        files.sort();
162
163        assert_eq!(files, vec!["/project/main.kcl", "/project/sub/part.kcl"]);
164    }
165}