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 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}