1use std::fs;
6use std::path::Path;
7
8use crate::error::MarsError;
9use crate::fs::atomic_write;
10
11#[cfg(windows)]
12use crate::fs::clear_readonly;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum CachePublishResult {
17 Published,
19 AlreadyPresent,
21}
22
23pub fn replace_generated_dir(src: &Path, dest: &Path) -> Result<(), MarsError> {
25 let parent = dest.parent().unwrap_or(Path::new("."));
26 fs::create_dir_all(parent).map_err(|e| io_context("create generated parent", parent, e))?;
27
28 let old_path = parent.join(format!(
29 ".{}.old",
30 dest.file_name().unwrap_or_default().to_string_lossy()
31 ));
32
33 if old_path.symlink_metadata().is_ok() {
35 safe_remove(&old_path)?;
36 }
37
38 if dest.exists() {
39 #[cfg(windows)]
40 clear_readonly_recursive(dest)?;
41
42 fs::rename(dest, &old_path)
43 .map_err(|e| io_context("rename destination to backup", dest, e))?;
44
45 if let Err(e) = fs::rename(src, dest) {
46 let _ = fs::rename(&old_path, dest);
47 let _ = safe_remove(src);
48 return Err(io_context("rename source to destination", src, e));
49 }
50
51 let _ = safe_remove(&old_path);
52 } else {
53 fs::rename(src, dest).map_err(|e| io_context("rename source to destination", src, e))?;
54 }
55
56 Ok(())
57}
58
59pub fn publish_cache_dir_if_absent(
61 src: &Path,
62 dest: &Path,
63) -> Result<CachePublishResult, MarsError> {
64 if dest.exists() {
65 safe_remove(src)?;
66 return Ok(CachePublishResult::AlreadyPresent);
67 }
68
69 if let Some(parent) = dest.parent() {
70 fs::create_dir_all(parent).map_err(|e| io_context("create cache parent", parent, e))?;
71 }
72
73 match fs::rename(src, dest) {
74 Ok(()) => Ok(CachePublishResult::Published),
75 Err(_err) if dest.exists() => {
76 let _ = safe_remove(src);
77 Ok(CachePublishResult::AlreadyPresent)
78 }
79 Err(e) => Err(io_context("publish cache directory", src, e)),
80 }
81}
82
83pub fn safe_remove(path: &Path) -> Result<(), MarsError> {
85 let metadata = match path.symlink_metadata() {
86 Ok(metadata) => metadata,
87 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
88 Err(e) => return Err(io_context("read metadata for removal", path, e)),
89 };
90
91 #[cfg(windows)]
92 if metadata.is_dir() {
93 clear_readonly_recursive(path)?;
94 } else {
95 clear_readonly(path).map_err(|e| io_context("clear readonly bit", path, e))?;
96 }
97
98 if metadata.is_dir() {
99 fs::remove_dir_all(path).map_err(|e| io_context("remove directory", path, e))?;
100 } else {
101 fs::remove_file(path).map_err(|e| io_context("remove file", path, e))?;
102 }
103
104 Ok(())
105}
106
107#[cfg(windows)]
108fn clear_readonly_recursive(path: &Path) -> Result<(), MarsError> {
109 for entry in walkdir::WalkDir::new(path)
110 .into_iter()
111 .filter_map(|entry| entry.ok())
112 {
113 clear_readonly(entry.path())
114 .map_err(|e| io_context("clear readonly bit", entry.path(), e))?;
115 }
116 Ok(())
117}
118
119fn io_context(operation: &str, path: &Path, source: std::io::Error) -> MarsError {
120 MarsError::Io {
121 operation: operation.to_string(),
122 path: path.to_path_buf(),
123 source,
124 }
125}
126
127pub fn atomic_copy_file(source: &Path, dest: &Path) -> Result<(), MarsError> {
129 let content = fs::read(source)?;
130 #[cfg(windows)]
131 if dest.exists() {
132 crate::fs::clear_readonly(dest)?;
133 }
134 atomic_write(dest, &content)
135}
136
137pub fn atomic_copy_dir(source: &Path, dest: &Path) -> Result<(), MarsError> {
139 let parent = dest.parent().unwrap_or(Path::new("."));
140 fs::create_dir_all(parent)?;
141
142 let tmp_dir = tempfile::TempDir::new_in(parent)?;
143 copy_dir_following_symlinks(source, tmp_dir.path())?;
144 let tmp_path = tmp_dir.keep();
145
146 replace_generated_dir(&tmp_path, dest)
147}
148
149pub fn file_content_equal(left: &Path, right: &Path) -> Result<bool, MarsError> {
154 let left_meta = match fs::symlink_metadata(left) {
155 Ok(m) => m,
156 Err(_) => return Ok(false),
157 };
158 let right_meta = match fs::symlink_metadata(right) {
159 Ok(m) => m,
160 Err(_) => return Ok(false),
161 };
162 if left_meta.file_type().is_symlink() || right_meta.file_type().is_symlink() {
163 return Ok(false);
164 }
165 if !left_meta.is_file() || !right_meta.is_file() {
166 return Ok(false);
167 }
168 Ok(fs::read(left)? == fs::read(right)?)
169}
170
171pub fn directory_trees_content_equal(left: &Path, right: &Path) -> Result<bool, MarsError> {
177 let Some(left) = strict_directory_tree_snapshot(left)? else {
178 return Ok(false);
179 };
180 let Some(right) = strict_directory_tree_snapshot(right)? else {
181 return Ok(false);
182 };
183 Ok(left == right)
184}
185
186#[derive(Debug, Clone, PartialEq, Eq)]
192pub(crate) struct DirectoryTreeSnapshot {
193 entries: std::collections::BTreeMap<String, TreeEntryKind>,
194}
195
196impl DirectoryTreeSnapshot {
197 pub(crate) fn new() -> Self {
198 Self {
199 entries: std::collections::BTreeMap::new(),
200 }
201 }
202
203 pub(crate) fn insert_directory(&mut self, rel_path: String) {
204 self.entries.insert(rel_path, TreeEntryKind::Directory);
205 }
206
207 pub(crate) fn insert_file_hash(&mut self, rel_path: String, hash: String) {
208 let mut parent = Path::new(&rel_path).parent();
209 while let Some(path) = parent.filter(|path| !path.as_os_str().is_empty()) {
210 let normalized = path
211 .components()
212 .map(|component| component.as_os_str().to_string_lossy())
213 .collect::<Vec<_>>()
214 .join("/");
215 self.entries
216 .entry(normalized)
217 .or_insert(TreeEntryKind::Directory);
218 parent = path.parent();
219 }
220 self.entries.insert(rel_path, TreeEntryKind::File(hash));
221 }
222
223 pub(crate) fn file_hash_manifest_entries(&self) -> Vec<(String, String)> {
224 self.entries
225 .iter()
226 .filter_map(|(path, kind)| match kind {
227 TreeEntryKind::Directory => None,
228 TreeEntryKind::File(hash) => Some((path.clone(), hash.clone())),
229 })
230 .collect()
231 }
232}
233
234#[derive(Debug, Clone, PartialEq, Eq)]
235enum TreeEntryKind {
236 Directory,
237 File(String),
238}
239
240pub(crate) fn directory_tree_matches_snapshot(
241 path: &Path,
242 expected: &DirectoryTreeSnapshot,
243) -> Result<bool, MarsError> {
244 Ok(strict_directory_tree_snapshot(path)?.as_ref() == Some(expected))
245}
246
247fn strict_directory_tree_snapshot(root: &Path) -> Result<Option<DirectoryTreeSnapshot>, MarsError> {
248 let metadata = match fs::symlink_metadata(root) {
249 Ok(metadata) => metadata,
250 Err(_) => return Ok(None),
251 };
252 if metadata.file_type().is_symlink() || !metadata.is_dir() {
253 return Ok(None);
254 }
255 let mut snapshot = DirectoryTreeSnapshot::new();
256 if collect_relative_tree_entries(root, root, &mut snapshot)? {
257 Ok(Some(snapshot))
258 } else {
259 Ok(None)
260 }
261}
262
263fn collect_relative_tree_entries(
264 root: &Path,
265 current: &Path,
266 snapshot: &mut DirectoryTreeSnapshot,
267) -> Result<bool, MarsError> {
268 for entry in fs::read_dir(current)? {
269 let entry = entry?;
270 let path = entry.path();
271 let file_type = entry.file_type()?;
272 let rel = path.strip_prefix(root).expect("path is always under root");
273 let rel_path: String = rel
274 .components()
275 .map(|c| c.as_os_str().to_string_lossy())
276 .collect::<Vec<_>>()
277 .join("/");
278
279 if file_type.is_symlink() {
280 return Ok(false);
281 }
282 if file_type.is_dir() {
283 snapshot.insert_directory(rel_path);
284 if !collect_relative_tree_entries(root, &path, snapshot)? {
285 return Ok(false);
286 }
287 } else if file_type.is_file() {
288 snapshot.insert_file_hash(rel_path, crate::hash::hash_bytes(&fs::read(path)?));
289 } else {
290 return Ok(false);
291 }
292 }
293 Ok(true)
294}
295
296fn copy_dir_following_symlinks(source: &Path, dest: &Path) -> Result<(), MarsError> {
303 fs::create_dir_all(dest)?;
304
305 for entry in fs::read_dir(source)? {
306 let entry = entry?;
307 let source_path = entry.path();
308 let dest_path = dest.join(entry.file_name());
309
310 let metadata = match fs::metadata(&source_path) {
312 Ok(m) => m,
313 Err(e) => {
314 if entry.file_type()?.is_symlink() {
316 return Err(std::io::Error::new(
317 std::io::ErrorKind::NotFound,
318 format!("broken symlink in source tree: {}", source_path.display()),
319 )
320 .into());
321 }
322 return Err(e.into());
323 }
324 };
325
326 if metadata.is_dir() {
327 copy_dir_following_symlinks(&source_path, &dest_path)?;
328 } else if metadata.is_file() {
329 let content = fs::read(&source_path)?;
330 fs::write(&dest_path, &content)?;
331 #[cfg(unix)]
332 {
333 use std::os::unix::fs::PermissionsExt;
334 fs::set_permissions(&dest_path, fs::Permissions::from_mode(0o644))?;
335 }
336 } else {
337 return Err(std::io::Error::new(
338 std::io::ErrorKind::InvalidData,
339 format!("unsupported filesystem entry: {}", source_path.display()),
340 )
341 .into());
342 }
343 }
344
345 Ok(())
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351 use tempfile::TempDir;
352
353 #[test]
354 fn replace_generated_dir_basic() {
355 let tmp = TempDir::new().unwrap();
356 let src = tmp.path().join("src");
357 let dest = tmp.path().join("dest");
358
359 fs::create_dir(&src).unwrap();
360 fs::write(src.join("file.txt"), "content").unwrap();
361
362 replace_generated_dir(&src, &dest).unwrap();
363
364 assert!(!src.exists());
365 assert!(dest.join("file.txt").exists());
366 }
367
368 #[test]
369 fn replace_generated_dir_replaces_existing() {
370 let tmp = TempDir::new().unwrap();
371 let src = tmp.path().join("src");
372 let dest = tmp.path().join("dest");
373
374 fs::create_dir(&dest).unwrap();
375 fs::write(dest.join("old.txt"), "old").unwrap();
376
377 fs::create_dir(&src).unwrap();
378 fs::write(src.join("new.txt"), "new").unwrap();
379
380 replace_generated_dir(&src, &dest).unwrap();
381
382 assert!(!dest.join("old.txt").exists());
383 assert!(dest.join("new.txt").exists());
384 }
385
386 #[test]
387 fn publish_cache_dir_if_absent_publishes() {
388 let tmp = TempDir::new().unwrap();
389 let src = tmp.path().join("src");
390 let dest = tmp.path().join("dest");
391
392 fs::create_dir(&src).unwrap();
393 fs::write(src.join("file.txt"), "content").unwrap();
394
395 let result = publish_cache_dir_if_absent(&src, &dest).unwrap();
396
397 assert_eq!(result, CachePublishResult::Published);
398 assert!(!src.exists());
399 assert!(dest.join("file.txt").exists());
400 }
401
402 #[test]
403 fn publish_cache_dir_if_absent_accepts_existing() {
404 let tmp = TempDir::new().unwrap();
405 let src = tmp.path().join("src");
406 let dest = tmp.path().join("dest");
407
408 fs::create_dir(&dest).unwrap();
409 fs::write(dest.join("existing.txt"), "existing").unwrap();
410
411 fs::create_dir(&src).unwrap();
412 fs::write(src.join("new.txt"), "new").unwrap();
413
414 let result = publish_cache_dir_if_absent(&src, &dest).unwrap();
415
416 assert_eq!(result, CachePublishResult::AlreadyPresent);
417 assert!(!src.exists());
418 assert!(dest.join("existing.txt").exists());
419 assert!(!dest.join("new.txt").exists());
420 }
421
422 #[test]
423 fn safe_remove_handles_nonexistent() {
424 let tmp = TempDir::new().unwrap();
425 let path = tmp.path().join("nonexistent");
426
427 safe_remove(&path).unwrap();
428 }
429
430 #[test]
431 fn safe_remove_removes_file_and_directory_tree() {
432 let tmp = TempDir::new().unwrap();
433 let file = tmp.path().join("file.txt");
434 fs::write(&file, "content").unwrap();
435
436 safe_remove(&file).unwrap();
437 assert!(!file.exists());
438
439 let dir = tmp.path().join("dir");
440 fs::create_dir_all(dir.join("nested")).unwrap();
441 fs::write(dir.join("nested").join("file.txt"), "content").unwrap();
442
443 safe_remove(&dir).unwrap();
444 assert!(!dir.exists());
445 }
446
447 #[test]
448 fn replace_generated_dir_cleans_stale_backup_before_replace() {
449 let tmp = TempDir::new().unwrap();
450 let src = tmp.path().join("src");
451 let dest = tmp.path().join("dest");
452 let old = tmp.path().join(".dest.old");
453
454 fs::create_dir(&dest).unwrap();
455 fs::write(dest.join("old.txt"), "old").unwrap();
456 fs::create_dir(&old).unwrap();
457 fs::write(old.join("stale.txt"), "stale").unwrap();
458 fs::create_dir(&src).unwrap();
459 fs::write(src.join("new.txt"), "new").unwrap();
460
461 replace_generated_dir(&src, &dest).unwrap();
462
463 assert!(!old.exists());
464 assert!(!dest.join("old.txt").exists());
465 assert_eq!(fs::read_to_string(dest.join("new.txt")).unwrap(), "new");
466 }
467}
468
469#[cfg(test)]
470mod content_tests {
471 use super::*;
472 use tempfile::TempDir;
473
474 #[test]
475 fn directory_trees_content_equal_detects_identical_and_different_trees() {
476 let dir = TempDir::new().expect("temp dir");
477 let left = dir.path().join("left");
478 let right = dir.path().join("right");
479 let other = dir.path().join("other");
480 fs::create_dir_all(left.join("nested")).expect("create left");
481 fs::create_dir_all(right.join("nested")).expect("create right");
482 fs::create_dir_all(other.join("nested")).expect("create other");
483 fs::write(left.join("root.txt"), "root").expect("write left root");
484 fs::write(left.join("nested/child.txt"), "child").expect("write left child");
485 fs::write(right.join("root.txt"), "root").expect("write right root");
486 fs::write(right.join("nested/child.txt"), "child").expect("write right child");
487 fs::write(other.join("root.txt"), "different").expect("write other root");
488 fs::write(other.join("nested/child.txt"), "child").expect("write other child");
489
490 assert!(directory_trees_content_equal(&left, &right).expect("compare equal"));
491 assert!(!directory_trees_content_equal(&left, &other).expect("compare different"));
492 }
493
494 #[test]
495 fn file_content_equal_compares_regular_files() {
496 let dir = TempDir::new().expect("temp dir");
497 let left = dir.path().join("left.txt");
498 let right = dir.path().join("right.txt");
499 let other = dir.path().join("other.txt");
500 fs::write(&left, "same").expect("write left");
501 fs::write(&right, "same").expect("write right");
502 fs::write(&other, "different").expect("write other");
503
504 assert!(file_content_equal(&left, &right).expect("compare equal"));
505 assert!(!file_content_equal(&left, &other).expect("compare different"));
506 }
507
508 #[cfg(unix)]
509 #[test]
510 fn file_content_equal_rejects_symlink_dest_even_when_bytes_match() {
511 let dir = TempDir::new().expect("temp dir");
512 let target = dir.path().join("target.txt");
513 fs::write(&target, "same bytes").expect("write target");
514
515 let regular = dir.path().join("regular.txt");
516 fs::write(®ular, "same bytes").expect("write regular");
517
518 let symlink = dir.path().join("link.txt");
519 std::os::unix::fs::symlink(&target, &symlink).expect("create symlink");
520
521 assert!(
522 !file_content_equal(®ular, &symlink).expect("compare symlink dest"),
523 "symlink dest must force rewrite even when target bytes match"
524 );
525 }
526
527 #[test]
528 fn directory_trees_content_equal_detects_empty_directory_delta() {
529 let dir = TempDir::new().expect("temp dir");
530 let left = dir.path().join("left");
531 let right = dir.path().join("right");
532 fs::create_dir_all(left.join("empty-only")).expect("create left empty dir");
533 fs::create_dir_all(&right).expect("create right root");
534 fs::write(left.join("root.txt"), "root").expect("write left root");
535 fs::write(right.join("root.txt"), "root").expect("write right root");
536
537 assert!(
538 !directory_trees_content_equal(&left, &right).expect("compare empty-dir delta"),
539 "empty-directory-only structural delta must not compare equal"
540 );
541 }
542
543 #[cfg(unix)]
544 #[test]
545 fn directory_trees_content_equal_rejects_symlink_entry() {
546 let dir = TempDir::new().expect("temp dir");
547 let left = dir.path().join("left");
548 let right = dir.path().join("right");
549 let shared = dir.path().join("shared.txt");
550 fs::write(&shared, "shared").expect("write shared");
551
552 fs::create_dir_all(&left).expect("create left");
553 fs::create_dir_all(&right).expect("create right");
554 fs::write(left.join("root.txt"), "root").expect("write left root");
555 fs::write(right.join("root.txt"), "root").expect("write right root");
556 std::os::unix::fs::symlink(&shared, right.join("link.txt")).expect("create symlink");
557
558 assert!(
559 !directory_trees_content_equal(&left, &right).expect("compare symlink entry"),
560 "symlink entry in tree must force rewrite"
561 );
562 }
563
564 #[test]
565 fn directory_trees_content_equal_identical_regular_file_trees_still_equal() {
566 let dir = TempDir::new().expect("temp dir");
567 let left = dir.path().join("left");
568 let right = dir.path().join("right");
569 fs::create_dir_all(left.join("nested")).expect("create left nested");
570 fs::create_dir_all(right.join("nested")).expect("create right nested");
571 fs::write(left.join("root.txt"), "root").expect("write left root");
572 fs::write(left.join("nested/child.txt"), "child").expect("write left child");
573 fs::write(right.join("root.txt"), "root").expect("write right root");
574 fs::write(right.join("nested/child.txt"), "child").expect("write right child");
575
576 assert!(
577 directory_trees_content_equal(&left, &right).expect("compare identical trees"),
578 "identical all-regular-file trees must still compare equal"
579 );
580 }
581
582 #[test]
583 fn atomic_copy_file_copies_regular_file() {
584 let dir = TempDir::new().expect("temp dir");
585 let source = dir.path().join("source.txt");
586 let dest = dir.path().join("dest").join("copied.txt");
587 fs::write(&source, "hello").expect("write source");
588
589 atomic_copy_file(&source, &dest).expect("copy file");
590
591 assert_eq!(fs::read_to_string(dest).expect("read dest"), "hello");
592 }
593
594 #[cfg(unix)]
595 #[test]
596 fn atomic_copy_file_follows_source_symlink() {
597 let dir = TempDir::new().expect("temp dir");
598 let real = dir.path().join("real.txt");
599 fs::write(&real, "from-real").expect("write real");
600
601 let source_link = dir.path().join("source-link.txt");
602 std::os::unix::fs::symlink(&real, &source_link).expect("create symlink");
603
604 let dest = dir.path().join("dest").join("copied.txt");
605 atomic_copy_file(&source_link, &dest).expect("copy through symlink");
606
607 let dest_meta = fs::symlink_metadata(&dest).expect("dest metadata");
608 assert!(
609 !dest_meta.file_type().is_symlink(),
610 "dest should be a regular file"
611 );
612 assert_eq!(fs::read_to_string(dest).expect("read dest"), "from-real");
613 }
614
615 #[test]
616 fn atomic_copy_dir_copies_tree() {
617 let dir = TempDir::new().expect("temp dir");
618 let source = dir.path().join("source");
619 fs::create_dir_all(source.join("nested")).expect("create source tree");
620 fs::write(source.join("root.txt"), "root").expect("write root");
621 fs::write(source.join("nested").join("child.txt"), "child").expect("write child");
622
623 let dest = dir.path().join("dest");
624 atomic_copy_dir(&source, &dest).expect("copy dir");
625
626 assert_eq!(
627 fs::read_to_string(dest.join("root.txt")).expect("read root"),
628 "root"
629 );
630 assert_eq!(
631 fs::read_to_string(dest.join("nested").join("child.txt")).expect("read child"),
632 "child"
633 );
634 }
635
636 #[cfg(unix)]
637 #[test]
638 fn atomic_copy_dir_follows_symlinks() {
639 let dir = TempDir::new().expect("temp dir");
640 let shared = dir.path().join("shared");
641 fs::create_dir_all(shared.join("docs")).expect("create shared tree");
642 fs::write(shared.join("docs").join("guide.md"), "guide").expect("write guide");
643 fs::write(shared.join("main.txt"), "main").expect("write main");
644
645 let source = dir.path().join("source");
646 fs::create_dir_all(&source).expect("create source");
647 std::os::unix::fs::symlink(shared.join("main.txt"), source.join("main-link.txt"))
648 .expect("file symlink");
649 std::os::unix::fs::symlink(shared.join("docs"), source.join("docs-link"))
650 .expect("dir symlink");
651
652 let dest = dir.path().join("dest");
653 atomic_copy_dir(&source, &dest).expect("copy dir through symlinks");
654
655 let main_meta = fs::symlink_metadata(dest.join("main-link.txt")).expect("main metadata");
656 assert!(
657 !main_meta.file_type().is_symlink(),
658 "copied file entry should be regular"
659 );
660 assert_eq!(
661 fs::read_to_string(dest.join("main-link.txt")).expect("read copied main"),
662 "main"
663 );
664
665 let docs_meta = fs::symlink_metadata(dest.join("docs-link")).expect("docs metadata");
666 assert!(
667 !docs_meta.file_type().is_symlink(),
668 "copied dir entry should be regular directory"
669 );
670 assert_eq!(
671 fs::read_to_string(dest.join("docs-link").join("guide.md")).expect("read guide"),
672 "guide"
673 );
674 }
675}