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