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 {
17            files: files
18                .into_iter()
19                .map(|(path, bytes)| (normalize(&path), bytes))
20                .collect(),
21        }
22    }
23}
24
25impl std::fmt::Debug for InMemoryFiles {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        let filenames: Vec<_> = self.files.keys().collect();
28        f.debug_struct("InMemoryFiles").field("files", &filenames).finish()
29    }
30}
31
32#[async_trait]
33impl crate::fs::FileSystem for InMemoryFiles {
34    async fn read(&self, path: &TypedPath, source_range: SourceRange) -> Result<Vec<u8>, KclError> {
35        let path_str = normalize(&path.to_string());
36        let bytes = self.files.get(&path_str).ok_or_else(|| {
37            KclError::new_io(KclErrorDetails::new(
38                format!("No such file {} found", path.display()),
39                vec![source_range],
40            ))
41        })?;
42        Ok(bytes.to_owned())
43    }
44
45    async fn read_to_string(&self, path: &TypedPath, source_range: SourceRange) -> Result<String, KclError> {
46        let bytes = self.read(path, source_range).await?;
47        String::from_utf8(bytes).map_err(|err| {
48            KclError::new_io(KclErrorDetails::new(
49                format!("File {} had invalid UTF-8 bytes: {err}", path.display()),
50                vec![source_range],
51            ))
52        })
53    }
54
55    async fn exists(&self, path: &TypedPath, _source_range: SourceRange) -> Result<bool, crate::errors::KclError> {
56        Ok(self.files.contains_key(&normalize(&path.to_string())))
57    }
58
59    async fn get_all_files(
60        &self,
61        base_path: &TypedPath,
62        _source_range: SourceRange,
63    ) -> Result<Vec<TypedPath>, crate::errors::KclError> {
64        let base_path = TypedPath::new(&normalize(&base_path.to_string()));
65        let all_files = self
66            .files
67            .keys()
68            .map(|filename| TypedPath::new(filename))
69            .filter(|file| file.starts_with(&base_path))
70            .collect();
71        Ok(all_files)
72    }
73}
74
75fn normalize(path: &str) -> String {
76    let path = typed_path::TypedPath::derive(path);
77    let mut output_components: Vec<typed_path::TypedComponent<'_>> = Vec::new();
78    for component in path.components() {
79        if component.is_current() {
80            continue;
81        }
82        if component.is_parent() {
83            if output_components.last().is_some_and(|last| last.is_normal()) {
84                output_components.pop();
85                continue;
86            }
87            if path.is_absolute() {
88                continue;
89            }
90        }
91        output_components.push(component);
92    }
93    let mut normalized = path.to_path_buf();
94    normalized.clear();
95    for component in output_components {
96        normalized.push(component.as_bytes());
97    }
98    normalized.to_string_lossy().into_owned()
99}
100
101#[cfg(test)]
102mod tests {
103    use ahash::HashMap;
104    use kcl_error::SourceRange;
105    use kittycad_modeling_cmds::shared::safe_filepath::SafeFilepath;
106
107    use super::InMemoryFiles;
108    use super::normalize;
109    use crate::TypedPath;
110    use crate::fs::FileSystem;
111
112    fn files(entries: &[(&str, &[u8])]) -> InMemoryFiles {
113        InMemoryFiles::new(
114            entries
115                .iter()
116                .map(|(path, bytes)| ((*path).to_owned(), (*bytes).to_vec()))
117                .collect::<HashMap<_, _>>(),
118        )
119    }
120
121    #[test]
122    fn normalize_resolves_parent_relative_asset_from_safe_uploaded_paths() {
123        // sub/main.kcl imports "../cube.step", while both uploaded filenames
124        // satisfy the API's restriction against parent directory components.
125        let entrypoint = SafeFilepath::validate("sub/main.kcl").unwrap();
126        let asset = SafeFilepath::validate("cube.step").unwrap();
127        let import_path = typed_path::UnixPath::new(&entrypoint.to_string())
128            .parent()
129            .unwrap()
130            .join("../cube.step")
131            .to_string_lossy()
132            .into_owned();
133
134        assert_eq!(import_path, "sub/../cube.step");
135        SafeFilepath::validate(&import_path).unwrap_err();
136        assert_eq!(normalize(&import_path), asset.to_string());
137    }
138
139    #[tokio::test]
140    async fn reads_existing_file_as_bytes_and_string() {
141        let fs = files(&[("/project/main.kcl", b"part = 1")]);
142        let path = TypedPath::new("/project/main.kcl");
143
144        assert_eq!(
145            fs.read(&path, SourceRange::default()).await.unwrap(),
146            b"part = 1".to_vec()
147        );
148        assert_eq!(
149            fs.read_to_string(&path, SourceRange::default()).await.unwrap(),
150            "part = 1"
151        );
152    }
153
154    #[tokio::test]
155    async fn reports_file_existence() {
156        let fs = files(&[("/project/main.kcl", b"part = 1")]);
157
158        assert!(
159            fs.exists(&TypedPath::new("/project/main.kcl"), SourceRange::default())
160                .await
161                .unwrap()
162        );
163        assert!(
164            !fs.exists(&TypedPath::new("/project/missing.kcl"), SourceRange::default())
165                .await
166                .unwrap()
167        );
168    }
169
170    #[tokio::test]
171    async fn resolves_relative_import_components() {
172        let fs = files(&[("part.kcl", b"part = 1"), ("./sub/shape.kcl", b"shape = 2")]);
173        let range = SourceRange::synthetic();
174        assert_eq!(
175            fs.read(&TypedPath::new("sub/../part.kcl"), range).await.unwrap(),
176            b"part = 1"
177        );
178        assert!(fs.exists(&TypedPath::new("./sub/shape.kcl"), range).await.unwrap());
179        assert!(!fs.exists(&TypedPath::new("../part.kcl"), range).await.unwrap());
180    }
181
182    #[tokio::test]
183    async fn read_errors_when_file_is_missing() {
184        let fs = files(&[]);
185
186        let err = fs
187            .read(&TypedPath::new("/project/missing.kcl"), SourceRange::default())
188            .await
189            .unwrap_err();
190
191        assert!(err.to_string().contains("No such file /project/missing.kcl found"));
192    }
193
194    #[tokio::test]
195    async fn read_to_string_errors_when_file_is_not_utf8() {
196        let fs = files(&[("/project/binary.kcl", &[0xff, 0xfe])]);
197
198        let err = fs
199            .read_to_string(&TypedPath::new("/project/binary.kcl"), SourceRange::default())
200            .await
201            .unwrap_err();
202
203        assert!(
204            err.to_string()
205                .contains("File /project/binary.kcl had invalid UTF-8 bytes")
206        );
207    }
208
209    #[tokio::test]
210    async fn get_all_files_returns_files_under_base_path() {
211        let fs = files(&[
212            ("/project/main.kcl", b"main"),
213            ("/project/sub/part.kcl", b"part"),
214            ("/projectile/not-project.kcl", b"outside"),
215            ("/other/file.kcl", b"other"),
216        ]);
217
218        let mut files = fs
219            .get_all_files(&TypedPath::new("/project"), SourceRange::default())
220            .await
221            .unwrap()
222            .into_iter()
223            .map(|path| path.to_string())
224            .collect::<Vec<_>>();
225        files.sort();
226
227        assert_eq!(files, vec!["/project/main.kcl", "/project/sub/part.kcl"]);
228    }
229}