Skip to main content

mars_agents/fs/
mod.rs

1use std::fs;
2use std::io::Write;
3use std::path::Path;
4
5use crate::error::MarsError;
6
7/// Top-level source entries excluded when installing flat skill repositories.
8pub const FLAT_SKILL_EXCLUDED_TOP_LEVEL: &[&str] = &[
9    ".git",
10    ".mars",
11    "mars.toml",
12    "mars.lock",
13    "mars.local.toml",
14    ".gitignore",
15];
16
17/// Atomic file write: write to temp file in same directory, then rename.
18///
19/// The rename is atomic on POSIX. Temp files are in the same directory
20/// as the destination to guarantee same-filesystem atomic rename.
21pub fn atomic_write(dest: &Path, content: &[u8]) -> Result<(), MarsError> {
22    // Ensure parent directory exists
23    if let Some(parent) = dest.parent() {
24        fs::create_dir_all(parent)?;
25    }
26
27    let parent = dest.parent().unwrap_or(Path::new("."));
28    let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
29    tmp.write_all(content)?;
30    tmp.as_file().sync_all()?;
31    #[cfg(unix)]
32    {
33        use std::os::unix::fs::PermissionsExt;
34        tmp.as_file()
35            .set_permissions(fs::Permissions::from_mode(0o644))?;
36    }
37    tmp.persist(dest).map_err(|e| e.error)?;
38    Ok(())
39}
40
41/// Atomically write a regular file unless its bytes are already identical.
42///
43/// Returns `true` when a write occurred. Symlinks and non-regular destinations
44/// are always replaced rather than treated as managed output.
45pub fn atomic_write_if_changed(dest: &Path, content: &[u8]) -> Result<bool, MarsError> {
46    let unchanged = dest
47        .symlink_metadata()
48        .ok()
49        .filter(|metadata| metadata.is_file() && !metadata.file_type().is_symlink())
50        .and_then(|_| fs::read(dest).ok())
51        .is_some_and(|existing| existing == content);
52    if unchanged {
53        return Ok(false);
54    }
55    atomic_write(dest, content)?;
56    Ok(true)
57}
58
59/// Atomic directory install: copy source tree to a temp dir in the same
60/// parent as `dest`, then rename into place.
61///
62/// Uses rename-old-then-rename-new to minimize the window where `dest`
63/// doesn't exist. If `dest` already exists, it's renamed to `.{name}.old`
64/// before the new content takes its place. Stale `.old` from prior crashes
65/// is cleaned up automatically.
66pub fn atomic_install_dir(src: &Path, dest: &Path) -> Result<(), MarsError> {
67    atomic_install_dir_impl(src, dest, &[])
68}
69
70/// Atomic directory install with optional top-level source entry exclusions.
71pub fn atomic_install_dir_filtered(
72    src: &Path,
73    dest: &Path,
74    excluded_top_level: &[&str],
75) -> Result<(), MarsError> {
76    atomic_install_dir_impl(src, dest, excluded_top_level)
77}
78
79fn atomic_install_dir_impl(
80    src: &Path,
81    dest: &Path,
82    excluded_top_level: &[&str],
83) -> Result<(), MarsError> {
84    let parent = dest.parent().unwrap_or(Path::new("."));
85    fs::create_dir_all(parent)?;
86
87    let tmp_dir = tempfile::TempDir::new_in(parent)?;
88    copy_dir_recursive(src, tmp_dir.path(), src, excluded_top_level)?;
89    let tmp_path = tmp_dir.keep();
90
91    if dest.exists() {
92        // Step 1: Rename old to .old (old content still accessible)
93        let old_path = parent.join(format!(
94            ".{}.old",
95            dest.file_name().unwrap_or_default().to_string_lossy()
96        ));
97        // Clean up stale .old from a prior crash
98        if old_path.exists() {
99            fs::remove_dir_all(&old_path)?;
100        }
101        // Atomic: old content moves to .old, dest slot is free
102        fs::rename(dest, &old_path)?;
103        // Atomic: new content takes dest slot
104        if let Err(e) = fs::rename(&tmp_path, dest) {
105            // Rollback: move old content back
106            let _ = fs::rename(&old_path, dest);
107            let _ = fs::remove_dir_all(&tmp_path);
108            return Err(e.into());
109        }
110        // Cleanup: remove old content (non-critical)
111        let _ = fs::remove_dir_all(&old_path);
112    } else {
113        fs::rename(&tmp_path, dest)?;
114    }
115
116    Ok(())
117}
118
119/// Recursively copy a directory tree.
120fn copy_dir_recursive(
121    src: &Path,
122    dest: &Path,
123    root: &Path,
124    excluded_top_level: &[&str],
125) -> Result<(), MarsError> {
126    for entry in fs::read_dir(src)? {
127        let entry = entry?;
128        let file_type = entry.file_type()?;
129        let src_path = entry.path();
130        let dest_path = dest.join(entry.file_name());
131
132        let rel_path = src_path
133            .strip_prefix(root)
134            .expect("copy traversal path should be under root");
135        if is_excluded_top_level(rel_path, excluded_top_level) {
136            continue;
137        }
138
139        if file_type.is_dir() {
140            fs::create_dir_all(&dest_path)?;
141            copy_dir_recursive(&src_path, &dest_path, root, excluded_top_level)?;
142        } else {
143            fs::copy(&src_path, &dest_path)?;
144        }
145    }
146    Ok(())
147}
148
149fn is_excluded_top_level(path: &Path, excluded_top_level: &[&str]) -> bool {
150    let Some(first) = path.components().next().map(|c| c.as_os_str()) else {
151        return false;
152    };
153    excluded_top_level.iter().any(|excluded| first == *excluded)
154}
155
156#[cfg(windows)]
157#[allow(clippy::permissions_set_readonly_false)]
158pub fn clear_readonly(path: &Path) -> std::io::Result<()> {
159    if let Ok(metadata) = std::fs::metadata(path) {
160        let mut perms = metadata.permissions();
161        if perms.readonly() {
162            perms.set_readonly(false);
163            std::fs::set_permissions(path, perms)?;
164        }
165    }
166    Ok(())
167}
168
169/// Advisory file lock (flock) for concurrent access.
170///
171/// Prevents concurrent `mars sync` from corrupting state.
172/// The lock is held start-to-end — acquired before fetching and held through completion.
173/// Dropping the `FileLock` closes the fd, which releases the advisory lock.
174pub struct FileLock {
175    _fd: fs::File,
176}
177
178impl FileLock {
179    /// Acquire an advisory file lock, blocking until available.
180    pub fn acquire(lock_path: &Path) -> Result<Self, MarsError> {
181        let file = Self::open_lock_file(lock_path)?;
182        platform::lock_exclusive(&file)?;
183        Ok(FileLock { _fd: file })
184    }
185
186    /// Open (or create) the lock file, creating parent dirs if needed.
187    fn open_lock_file(lock_path: &Path) -> Result<fs::File, MarsError> {
188        if let Some(parent) = lock_path.parent() {
189            fs::create_dir_all(parent)?;
190        }
191        let file = fs::OpenOptions::new()
192            .read(true)
193            .write(true)
194            .create(true)
195            .truncate(false)
196            .open(lock_path)?;
197        Ok(file)
198    }
199}
200
201#[cfg(unix)]
202mod platform {
203    use std::fs;
204    use std::os::unix::io::AsRawFd;
205
206    pub fn lock_exclusive(file: &fs::File) -> std::io::Result<()> {
207        // SAFETY: the file descriptor is valid while `file` is alive.
208        let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) };
209        if ret != 0 {
210            Err(std::io::Error::last_os_error())
211        } else {
212            Ok(())
213        }
214    }
215}
216
217#[cfg(windows)]
218mod platform {
219    use std::fs;
220    use std::os::windows::io::AsRawHandle;
221
222    use windows_sys::Win32::Foundation::HANDLE;
223    use windows_sys::Win32::Storage::FileSystem::{LOCKFILE_EXCLUSIVE_LOCK, LockFileEx};
224
225    pub fn lock_exclusive(file: &fs::File) -> std::io::Result<()> {
226        let handle = file.as_raw_handle() as HANDLE;
227        // SAFETY: zero-initialized OVERLAPPED is accepted by LockFileEx for
228        // whole-file locks at offset 0.
229        let mut overlapped = unsafe { std::mem::zeroed() };
230        // SAFETY: handle is valid while `file` is alive and `overlapped` outlives the call.
231        let ret =
232            unsafe { LockFileEx(handle, LOCKFILE_EXCLUSIVE_LOCK, 0, !0, !0, &mut overlapped) };
233        if ret == 0 {
234            Err(std::io::Error::last_os_error())
235        } else {
236            Ok(())
237        }
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use tempfile::TempDir;
245
246    #[test]
247    fn atomic_write_creates_file_with_correct_content() {
248        let dir = TempDir::new().unwrap();
249        let dest = dir.path().join("output.txt");
250        let content = b"hello world";
251
252        atomic_write(&dest, content).unwrap();
253
254        assert_eq!(fs::read(&dest).unwrap(), content);
255    }
256
257    #[test]
258    fn atomic_write_creates_parent_dirs() {
259        let dir = TempDir::new().unwrap();
260        let dest = dir.path().join("nested").join("dir").join("file.txt");
261        let content = b"nested content";
262
263        atomic_write(&dest, content).unwrap();
264
265        assert_eq!(fs::read(&dest).unwrap(), content);
266    }
267
268    #[test]
269    fn atomic_write_overwrites_existing_file() {
270        let dir = TempDir::new().unwrap();
271        let dest = dir.path().join("output.txt");
272
273        atomic_write(&dest, b"first").unwrap();
274        atomic_write(&dest, b"second").unwrap();
275
276        assert_eq!(fs::read(&dest).unwrap(), b"second");
277    }
278
279    #[test]
280    fn atomic_install_dir_copies_tree() {
281        let dir = TempDir::new().unwrap();
282        let src = dir.path().join("src_dir");
283        let dest = dir.path().join("dest_dir");
284
285        // Create source tree
286        fs::create_dir_all(src.join("sub")).unwrap();
287        fs::write(src.join("a.txt"), "file a").unwrap();
288        fs::write(src.join("sub").join("b.txt"), "file b").unwrap();
289
290        atomic_install_dir(&src, &dest).unwrap();
291
292        assert_eq!(fs::read_to_string(dest.join("a.txt")).unwrap(), "file a");
293        assert_eq!(
294            fs::read_to_string(dest.join("sub").join("b.txt")).unwrap(),
295            "file b"
296        );
297    }
298
299    #[test]
300    fn atomic_install_dir_replaces_existing() {
301        let dir = TempDir::new().unwrap();
302        let src = dir.path().join("src_dir");
303        let dest = dir.path().join("dest_dir");
304
305        // Create initial dest
306        fs::create_dir_all(&dest).unwrap();
307        fs::write(dest.join("old.txt"), "old").unwrap();
308
309        // Create source
310        fs::create_dir_all(&src).unwrap();
311        fs::write(src.join("new.txt"), "new").unwrap();
312
313        atomic_install_dir(&src, &dest).unwrap();
314
315        assert!(dest.join("new.txt").exists());
316        assert!(!dest.join("old.txt").exists());
317    }
318
319    #[test]
320    fn atomic_install_dir_cleans_stale_old() {
321        let dir = TempDir::new().unwrap();
322        let src = dir.path().join("src_dir");
323        let dest = dir.path().join("dest_dir");
324
325        // Create initial dest
326        fs::create_dir_all(&dest).unwrap();
327        fs::write(dest.join("old.txt"), "old").unwrap();
328
329        // Create stale .old from a prior crash
330        let old_path = dir.path().join(".dest_dir.old");
331        fs::create_dir_all(&old_path).unwrap();
332        fs::write(old_path.join("stale.txt"), "stale").unwrap();
333
334        // Create source
335        fs::create_dir_all(&src).unwrap();
336        fs::write(src.join("new.txt"), "new").unwrap();
337
338        atomic_install_dir(&src, &dest).unwrap();
339
340        assert!(dest.join("new.txt").exists());
341        assert!(!dest.join("old.txt").exists());
342        assert!(!old_path.exists(), "stale .old should be cleaned up");
343    }
344
345    #[test]
346    fn atomic_install_dir_dest_exists_throughout() {
347        let dir = TempDir::new().unwrap();
348        let src = dir.path().join("src_dir");
349        let dest = dir.path().join("dest_dir");
350
351        // Create initial dest
352        fs::create_dir_all(&dest).unwrap();
353        fs::write(dest.join("v1.txt"), "v1").unwrap();
354
355        // Create source
356        fs::create_dir_all(&src).unwrap();
357        fs::write(src.join("v2.txt"), "v2").unwrap();
358
359        assert!(dest.exists(), "dest should exist before install");
360        atomic_install_dir(&src, &dest).unwrap();
361        assert!(dest.exists(), "dest should exist after install");
362        assert!(dest.join("v2.txt").exists());
363    }
364
365    #[test]
366    fn atomic_install_dir_filtered_excludes_top_level_entries() {
367        let dir = TempDir::new().unwrap();
368        let src = dir.path().join("src_dir");
369        let dest = dir.path().join("dest_dir");
370
371        fs::create_dir_all(src.join(".git")).unwrap();
372        fs::create_dir_all(src.join("resources")).unwrap();
373        fs::write(src.join("SKILL.md"), "skill").unwrap();
374        fs::write(src.join("mars.toml"), "ignored").unwrap();
375        fs::write(src.join(".gitignore"), "ignored").unwrap();
376        fs::write(src.join(".git").join("config"), "ignored").unwrap();
377        fs::write(src.join("resources").join("guide.md"), "kept").unwrap();
378
379        atomic_install_dir_filtered(&src, &dest, FLAT_SKILL_EXCLUDED_TOP_LEVEL).unwrap();
380
381        assert!(dest.join("SKILL.md").exists());
382        assert!(dest.join("resources").join("guide.md").exists());
383        assert!(!dest.join(".git").exists());
384        assert!(!dest.join("mars.toml").exists());
385        assert!(!dest.join(".gitignore").exists());
386    }
387
388    #[test]
389    fn file_lock_acquire_returns_lock() {
390        let dir = TempDir::new().unwrap();
391        let lock_path = dir.path().join("test.lock");
392
393        let lock = FileLock::acquire(&lock_path).unwrap();
394        assert!(lock_path.exists());
395        drop(lock);
396    }
397
398    #[test]
399    fn file_lock_creates_parent_dirs() {
400        let dir = TempDir::new().unwrap();
401        let lock_path = dir.path().join("nested").join("dir").join("test.lock");
402
403        let lock = FileLock::acquire(&lock_path).unwrap();
404        assert!(lock_path.exists());
405        drop(lock);
406    }
407}