Skip to main content

mars_agents/platform/
fs.rs

1//! Atomic filesystem operations for durable writes and directory replacement.
2//!
3//! All durable Mars writes should go through this module.
4
5use std::fs;
6use std::path::Path;
7
8use crate::error::MarsError;
9
10pub use crate::fs::{
11    FLAT_SKILL_EXCLUDED_TOP_LEVEL, atomic_install_dir, atomic_install_dir_filtered, atomic_write,
12    remove_item,
13};
14
15#[cfg(windows)]
16pub use crate::fs::clear_readonly;
17
18/// Result of cache directory publication.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum CachePublishResult {
21    /// The directory was published (renamed from temp to destination).
22    Published,
23    /// The destination already existed; temp was removed.
24    AlreadyPresent,
25}
26
27/// Replace a generated directory with rollback semantics.
28pub fn replace_generated_dir(src: &Path, dest: &Path) -> Result<(), MarsError> {
29    let parent = dest.parent().unwrap_or(Path::new("."));
30    fs::create_dir_all(parent).map_err(|e| io_context("create generated parent", parent, e))?;
31
32    let old_path = parent.join(format!(
33        ".{}.old",
34        dest.file_name().unwrap_or_default().to_string_lossy()
35    ));
36
37    // Clean stale rollback content from prior crashes.
38    if old_path.symlink_metadata().is_ok() {
39        safe_remove(&old_path)?;
40    }
41
42    if dest.exists() {
43        #[cfg(windows)]
44        clear_readonly_recursive(dest)?;
45
46        fs::rename(dest, &old_path)
47            .map_err(|e| io_context("rename destination to backup", dest, e))?;
48
49        if let Err(e) = fs::rename(src, dest) {
50            let _ = fs::rename(&old_path, dest);
51            let _ = safe_remove(src);
52            return Err(io_context("rename source to destination", src, e));
53        }
54
55        let _ = safe_remove(&old_path);
56    } else {
57        fs::rename(src, dest).map_err(|e| io_context("rename source to destination", src, e))?;
58    }
59
60    Ok(())
61}
62
63/// Publish a cache directory iff destination is absent.
64pub fn publish_cache_dir_if_absent(
65    src: &Path,
66    dest: &Path,
67) -> Result<CachePublishResult, MarsError> {
68    if dest.exists() {
69        safe_remove(src)?;
70        return Ok(CachePublishResult::AlreadyPresent);
71    }
72
73    if let Some(parent) = dest.parent() {
74        fs::create_dir_all(parent).map_err(|e| io_context("create cache parent", parent, e))?;
75    }
76
77    match fs::rename(src, dest) {
78        Ok(()) => Ok(CachePublishResult::Published),
79        Err(_err) if dest.exists() => {
80            let _ = safe_remove(src);
81            Ok(CachePublishResult::AlreadyPresent)
82        }
83        Err(e) => Err(io_context("publish cache directory", src, e)),
84    }
85}
86
87/// Remove a file or directory tree safely.
88pub fn safe_remove(path: &Path) -> Result<(), MarsError> {
89    let metadata = match path.symlink_metadata() {
90        Ok(metadata) => metadata,
91        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
92        Err(e) => return Err(io_context("read metadata for removal", path, e)),
93    };
94
95    #[cfg(windows)]
96    if metadata.is_dir() {
97        clear_readonly_recursive(path)?;
98    } else {
99        clear_readonly(path).map_err(|e| io_context("clear readonly bit", path, e))?;
100    }
101
102    if metadata.is_dir() {
103        fs::remove_dir_all(path).map_err(|e| io_context("remove directory", path, e))?;
104    } else {
105        fs::remove_file(path).map_err(|e| io_context("remove file", path, e))?;
106    }
107
108    Ok(())
109}
110
111#[cfg(windows)]
112fn clear_readonly_recursive(path: &Path) -> Result<(), MarsError> {
113    for entry in walkdir::WalkDir::new(path)
114        .into_iter()
115        .filter_map(|entry| entry.ok())
116    {
117        clear_readonly(entry.path())
118            .map_err(|e| io_context("clear readonly bit", entry.path(), e))?;
119    }
120    Ok(())
121}
122
123fn io_context(operation: &str, path: &Path, source: std::io::Error) -> MarsError {
124    MarsError::Io {
125        operation: operation.to_string(),
126        path: path.to_path_buf(),
127        source,
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use tempfile::TempDir;
135
136    #[test]
137    fn replace_generated_dir_basic() {
138        let tmp = TempDir::new().unwrap();
139        let src = tmp.path().join("src");
140        let dest = tmp.path().join("dest");
141
142        fs::create_dir(&src).unwrap();
143        fs::write(src.join("file.txt"), "content").unwrap();
144
145        replace_generated_dir(&src, &dest).unwrap();
146
147        assert!(!src.exists());
148        assert!(dest.join("file.txt").exists());
149    }
150
151    #[test]
152    fn replace_generated_dir_replaces_existing() {
153        let tmp = TempDir::new().unwrap();
154        let src = tmp.path().join("src");
155        let dest = tmp.path().join("dest");
156
157        fs::create_dir(&dest).unwrap();
158        fs::write(dest.join("old.txt"), "old").unwrap();
159
160        fs::create_dir(&src).unwrap();
161        fs::write(src.join("new.txt"), "new").unwrap();
162
163        replace_generated_dir(&src, &dest).unwrap();
164
165        assert!(!dest.join("old.txt").exists());
166        assert!(dest.join("new.txt").exists());
167    }
168
169    #[test]
170    fn publish_cache_dir_if_absent_publishes() {
171        let tmp = TempDir::new().unwrap();
172        let src = tmp.path().join("src");
173        let dest = tmp.path().join("dest");
174
175        fs::create_dir(&src).unwrap();
176        fs::write(src.join("file.txt"), "content").unwrap();
177
178        let result = publish_cache_dir_if_absent(&src, &dest).unwrap();
179
180        assert_eq!(result, CachePublishResult::Published);
181        assert!(!src.exists());
182        assert!(dest.join("file.txt").exists());
183    }
184
185    #[test]
186    fn publish_cache_dir_if_absent_accepts_existing() {
187        let tmp = TempDir::new().unwrap();
188        let src = tmp.path().join("src");
189        let dest = tmp.path().join("dest");
190
191        fs::create_dir(&dest).unwrap();
192        fs::write(dest.join("existing.txt"), "existing").unwrap();
193
194        fs::create_dir(&src).unwrap();
195        fs::write(src.join("new.txt"), "new").unwrap();
196
197        let result = publish_cache_dir_if_absent(&src, &dest).unwrap();
198
199        assert_eq!(result, CachePublishResult::AlreadyPresent);
200        assert!(!src.exists());
201        assert!(dest.join("existing.txt").exists());
202        assert!(!dest.join("new.txt").exists());
203    }
204
205    #[test]
206    fn safe_remove_handles_nonexistent() {
207        let tmp = TempDir::new().unwrap();
208        let path = tmp.path().join("nonexistent");
209
210        safe_remove(&path).unwrap();
211    }
212
213    #[test]
214    fn safe_remove_removes_file_and_directory_tree() {
215        let tmp = TempDir::new().unwrap();
216        let file = tmp.path().join("file.txt");
217        fs::write(&file, "content").unwrap();
218
219        safe_remove(&file).unwrap();
220        assert!(!file.exists());
221
222        let dir = tmp.path().join("dir");
223        fs::create_dir_all(dir.join("nested")).unwrap();
224        fs::write(dir.join("nested").join("file.txt"), "content").unwrap();
225
226        safe_remove(&dir).unwrap();
227        assert!(!dir.exists());
228    }
229
230    #[test]
231    fn replace_generated_dir_cleans_stale_backup_before_replace() {
232        let tmp = TempDir::new().unwrap();
233        let src = tmp.path().join("src");
234        let dest = tmp.path().join("dest");
235        let old = tmp.path().join(".dest.old");
236
237        fs::create_dir(&dest).unwrap();
238        fs::write(dest.join("old.txt"), "old").unwrap();
239        fs::create_dir(&old).unwrap();
240        fs::write(old.join("stale.txt"), "stale").unwrap();
241        fs::create_dir(&src).unwrap();
242        fs::write(src.join("new.txt"), "new").unwrap();
243
244        replace_generated_dir(&src, &dest).unwrap();
245
246        assert!(!old.exists());
247        assert!(!dest.join("old.txt").exists());
248        assert_eq!(fs::read_to_string(dest.join("new.txt")).unwrap(), "new");
249    }
250}