1use geop_core_math::geop_error::{GeopError, GeopResult};
7use geop_core_program::cache::{CACHE_EXTENSION, Cache, CacheStore};
8use std::{
9 collections::BTreeMap,
10 path::{Path, PathBuf},
11};
12
13pub const MOST_FILES: usize = 10_000;
17pub const MOST_BYTES: u64 = 512 << 20;
18
19pub struct Folder {
23 root: PathBuf,
25 extensions: Vec<String>,
27}
28
29impl Folder {
30 pub fn new(root: &Path, extensions: Vec<String>) -> GeopResult<Self> {
31 let root = std::fs::canonicalize(root)
32 .map_err(|e| GeopError::new(format!("opening {}: {e}", root.display())))?;
33 if !root.is_dir() {
34 return Err(GeopError::new(format!("{} is no folder", root.display())));
35 }
36 Ok(Self { root, extensions })
37 }
38
39 pub fn root(&self) -> &Path {
41 &self.root
42 }
43
44 pub fn names_file(&self, path: &str) -> bool {
48 let segments_ok = (path.split('/'))
49 .all(|s| !s.is_empty() && s != "." && s != ".." && !s.contains(['\\', ':', '\0']));
50 let lower = path.to_ascii_lowercase();
51 let extension_ok = (self.extensions.iter()).any(|e| lower.ends_with(&format!(".{e}")));
52 segments_ok && extension_ok
53 }
54
55 pub fn file(&self, path: &str) -> GeopResult<PathBuf> {
60 if !self.names_file(path) {
61 return Err(GeopError::new(format!(
62 "{path:?} names no file of the workspace"
63 )));
64 }
65 let file = path.split('/').fold(self.root.clone(), |p, s| p.join(s));
66 let existing = (file.ancestors())
67 .find(|p| p.symlink_metadata().is_ok())
68 .ok_or_else(|| GeopError::new(format!("{path:?}: nothing of it exists")))?;
69 let canonical = std::fs::canonicalize(existing)
70 .map_err(|e| GeopError::new(format!("{path:?}: {e}")))?;
71 if !canonical.starts_with(&self.root) {
72 return Err(GeopError::new(format!(
73 "{path:?} lies outside the workspace"
74 )));
75 }
76 Ok(file)
77 }
78
79 pub fn files(&self) -> GeopResult<BTreeMap<String, String>> {
84 let mut files = BTreeMap::new();
85 let mut bytes = 0;
86 let mut folders = vec![(self.root.clone(), String::new())];
87 while let Some((dir, prefix)) = folders.pop() {
88 let entries = std::fs::read_dir(&dir)
89 .map_err(|e| GeopError::new(format!("listing {}: {e}", dir.display())))?;
90 for entry in entries.flatten() {
91 let Some(name) = entry.file_name().to_str().map(String::from) else {
92 continue;
93 };
94 let path = format!("{prefix}{name}");
95 let Ok(kind) = entry.file_type() else {
96 continue;
97 };
98 if kind.is_dir() {
99 if !name.starts_with('.') && name != "node_modules" && name != "target" {
100 folders.push((entry.path(), format!("{path}/")));
101 }
102 continue;
103 }
104 let Ok(file) = self.file(&path) else { continue };
107 if !file.is_file() {
108 continue;
109 }
110 let text = std::fs::read(&file)
111 .map_err(|e| GeopError::new(format!("reading {}: {e}", file.display())))?;
112 bytes += text.len() as u64;
113 files.insert(path, geop_core_program::workspace::content(text));
114 if files.len() > MOST_FILES || bytes > MOST_BYTES {
115 return Err(GeopError::new(format!(
116 "{} holds more than {MOST_FILES} files or {} MB of programs and data: open a folder of one project",
117 self.root.display(),
118 MOST_BYTES >> 20
119 )));
120 }
121 }
122 }
123 Ok(files)
124 }
125
126 pub fn write(&self, path: &str, content: &str) -> GeopResult<()> {
131 let text = geop_core_program::workspace::bytes(content)?;
132 let file = self.file(path)?;
133 let io = |e: std::io::Error| GeopError::new(format!("writing {}: {e}", file.display()));
134 let dir = file.parent().expect("a file of the folder is in it");
135 std::fs::create_dir_all(dir).map_err(io)?;
136 let name = file
137 .file_name()
138 .expect("a file has a name")
139 .to_string_lossy();
140 let partial = dir.join(format!(".{name}.{}.partial", std::process::id()));
141 let written =
142 std::fs::write(&partial, &text).and_then(|()| std::fs::rename(&partial, &file));
143 if written.is_err() {
144 let _ = std::fs::remove_file(&partial);
145 }
146 written.map_err(io)
147 }
148
149 pub fn remove(&self, path: &str) -> GeopResult<()> {
151 let file = self.file(path)?;
152 match std::fs::remove_file(&file) {
153 Err(e) if e.kind() != std::io::ErrorKind::NotFound => {
154 Err(GeopError::new(format!("removing {}: {e}", file.display())))
155 }
156 _ => Ok(()),
157 }
158 }
159}
160
161pub fn workspace_of(path: &Path) -> GeopResult<(PathBuf, Option<String>)> {
164 if path.is_dir() {
165 return Ok((path.to_path_buf(), None));
166 }
167 let is_program = path.extension().is_some_and(|e| e == "geop");
168 let name = path.file_name().and_then(|n| n.to_str());
169 match (is_program && path.is_file(), name) {
170 (true, Some(name)) => {
171 let dir = path.parent().filter(|p| !p.as_os_str().is_empty());
172 Ok((
173 dir.unwrap_or(Path::new(".")).to_path_buf(),
174 Some(name.to_string()),
175 ))
176 }
177 _ => Err(GeopError::new(format!(
178 "{} is neither a folder nor a .geop program",
179 path.display()
180 ))),
181 }
182}
183
184pub struct DiskCache {
187 root: PathBuf,
188 build: String,
189}
190
191impl DiskCache {
192 pub fn of(root: PathBuf) -> Option<Self> {
197 let exe = std::fs::metadata(std::env::current_exe().ok()?).ok()?;
198 let modified = exe
199 .modified()
200 .ok()?
201 .duration_since(std::time::UNIX_EPOCH)
202 .ok()?;
203 let build = format!(
204 "{} {} {}",
205 env!("CARGO_PKG_VERSION"),
206 exe.len(),
207 modified.as_nanos()
208 );
209 Some(Self { root, build })
210 }
211
212 pub fn cache(this: Option<Self>) -> Cache {
215 match this {
216 Some(store) => Cache::stored(Box::new(store)),
217 None => Cache::new(),
218 }
219 }
220}
221
222impl CacheStore for DiskCache {
223 fn build(&self) -> &str {
224 &self.build
225 }
226
227 fn read(&self, path: &str) -> Option<String> {
228 std::fs::read_to_string(self.root.join(path)).ok()
229 }
230
231 fn write(&self, path: &str, text: &str) {
236 let target = self.root.join(path);
237 let partial = target.with_extension(format!("{CACHE_EXTENSION}.{}", std::process::id()));
238 let folder = target.parent().map_or(Ok(()), std::fs::create_dir_all);
240 let written = (folder.and_then(|()| std::fs::write(&partial, text)))
241 .and_then(|()| std::fs::rename(&partial, &target));
242 if let Err(e) = written {
243 let _ = std::fs::remove_file(&partial);
244 eprintln!("geop: could not keep {}: {e}", target.display());
245 }
246 }
247}
248
249pub fn extensions(registry: &geop_core_editor::registry::Registry) -> Vec<String> {
253 let mut extensions: Vec<String> = vec!["geop".into()];
254 for listed in registry.infos() {
255 extensions.extend(listed.info.reads.iter().map(|e| e.to_ascii_lowercase()));
256 }
257 extensions.sort();
258 extensions.dedup();
259 extensions
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 fn scratch(test: &str) -> PathBuf {
267 let dir = std::env::temp_dir().join(format!("geop-disk-{test}-{}", std::process::id()));
268 let _ = std::fs::remove_dir_all(&dir);
269 std::fs::create_dir_all(&dir).unwrap();
270 dir
271 }
272
273 fn folder(dir: &Path) -> Folder {
274 Folder::new(dir, vec!["geop".into(), "step".into(), "stp".into()]).unwrap()
275 }
276
277 #[test]
278 fn a_path_names_a_file_of_the_folder_only_relatively_and_by_a_shown_extension() {
279 let dir = scratch("names");
280 let f = folder(&dir);
281 for ok in ["part.geop", "parts/bolt.geop", "a b/c.STEP", "x.stp"] {
282 assert!(f.names_file(ok), "{ok}");
283 }
284 for bad in [
285 "",
286 "/etc/passwd.geop",
287 "../up.geop",
288 "a/../../up.geop",
289 "./part.geop",
290 "a//b.geop",
291 "a\\..\\b.geop",
292 "C:/x.geop",
293 "c:x.geop",
294 "part.geop:stream",
295 "notes.txt",
296 "part.geop/",
297 "part.geopc",
298 ] {
299 assert!(!f.names_file(bad), "{bad}");
300 assert!(f.file(bad).is_err(), "{bad}");
301 }
302 }
303
304 #[test]
305 fn bytes_that_are_no_text_travel_as_a_data_url() {
306 let dir = scratch("bytes");
307 let f = folder(&dir);
308 let content = geop_core_program::workspace::content(vec![0xff, 0x00, 0x80]);
309 f.write("mesh.step", &content).unwrap();
310 assert_eq!(
311 std::fs::read(dir.join("mesh.step")).unwrap(),
312 [0xff, 0x00, 0x80]
313 );
314 assert_eq!(f.files().unwrap()["mesh.step"], content);
315 }
316
317 #[test]
318 fn files_are_read_and_written_in_the_folder() {
319 let dir = scratch("rw");
320 let f = folder(&dir);
321 f.write("parts/bolt.geop", "{\"steps\": []}\n").unwrap();
322 f.write("part.geop", "{}").unwrap();
323 std::fs::write(dir.join("notes.txt"), "not shown").unwrap();
324 std::fs::create_dir_all(dir.join(".git")).unwrap();
325 std::fs::write(dir.join(".git/hidden.geop"), "{}").unwrap();
326 let files = f.files().unwrap();
327 assert_eq!(
328 files.keys().collect::<Vec<_>>(),
329 ["part.geop", "parts/bolt.geop"]
330 );
331 assert_eq!(files["parts/bolt.geop"], "{\"steps\": []}\n");
332 f.remove("part.geop").unwrap();
333 f.remove("part.geop").unwrap();
334 assert!(!dir.join("part.geop").exists());
335 let left: Vec<_> = std::fs::read_dir(dir.join("parts")).unwrap().collect();
337 assert_eq!(left.len(), 1);
338 }
339
340 #[cfg(unix)]
341 #[test]
342 fn a_link_out_of_the_folder_is_refused() {
343 let dir = scratch("links");
344 let outside = scratch("links-outside");
345 std::fs::write(outside.join("secret.geop"), "secret").unwrap();
346 let root = dir.join("root");
347 std::fs::create_dir_all(&root).unwrap();
348 std::fs::write(root.join("inside.geop"), "inside").unwrap();
349 std::os::unix::fs::symlink(&outside, root.join("out")).unwrap();
350 std::os::unix::fs::symlink(outside.join("secret.geop"), root.join("secret.geop")).unwrap();
351 std::os::unix::fs::symlink(root.join("inside.geop"), root.join("alias.geop")).unwrap();
352 let f = folder(&root);
353 assert!(f.file("out/secret.geop").is_err());
354 assert!(f.file("out/new/new.geop").is_err());
355 assert!(f.file("secret.geop").is_err());
356 assert!(f.write("out/written.geop", "x").is_err());
357 assert!(!outside.join("written.geop").exists());
358 assert!(f.write("secret.geop", "x").is_err());
359 assert_eq!(
360 std::fs::read_to_string(outside.join("secret.geop")).unwrap(),
361 "secret"
362 );
363 assert!(f.remove("secret.geop").is_err());
364 assert!(outside.join("secret.geop").exists());
365 let files = f.files().unwrap();
367 assert_eq!(
368 files.keys().collect::<Vec<_>>(),
369 ["alias.geop", "inside.geop"]
370 );
371 }
372
373 #[test]
374 fn a_program_named_is_opened_in_its_folder() {
375 let dir = scratch("workspace-of");
376 std::fs::write(dir.join("a.geop"), "{}").unwrap();
377 assert_eq!(workspace_of(&dir).unwrap(), (dir.clone(), None));
378 assert_eq!(
379 workspace_of(&dir.join("a.geop")).unwrap(),
380 (dir.clone(), Some("a.geop".into()))
381 );
382 assert!(workspace_of(&dir.join("missing.geop")).is_err());
383 assert!(workspace_of(&dir.join("missing")).is_err());
384 }
385}