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; agents/hooks/mcp/bootstrap are files).
140pub fn remove_item(path: &Path, kind: ItemKind) -> Result<(), MarsError> {
141    match kind {
142        ItemKind::Agent | ItemKind::Hook | ItemKind::McpServer | ItemKind::BootstrapDoc => {
143            fs::remove_file(path)?
144        }
145        ItemKind::Skill => fs::remove_dir_all(path)?,
146    }
147    Ok(())
148}
149
150#[cfg(windows)]
151#[allow(clippy::permissions_set_readonly_false)]
152pub fn clear_readonly(path: &Path) -> std::io::Result<()> {
153    if let Ok(metadata) = std::fs::metadata(path) {
154        let mut perms = metadata.permissions();
155        if perms.readonly() {
156            perms.set_readonly(false);
157            std::fs::set_permissions(path, perms)?;
158        }
159    }
160    Ok(())
161}
162
163/// Advisory file lock (flock) for concurrent access.
164///
165/// Prevents concurrent `mars sync` from corrupting state.
166/// The lock is held start-to-end — acquired before fetching and held through completion.
167/// Dropping the `FileLock` closes the fd, which releases the advisory lock.
168pub struct FileLock {
169    _fd: fs::File,
170}
171
172impl FileLock {
173    /// Acquire an advisory file lock, blocking until available.
174    pub fn acquire(lock_path: &Path) -> Result<Self, MarsError> {
175        let file = Self::open_lock_file(lock_path)?;
176        platform::lock_exclusive(&file)?;
177        Ok(FileLock { _fd: file })
178    }
179
180    /// Try to acquire the lock without blocking.
181    /// Returns `Ok(Some(lock))` if acquired, `Ok(None)` if already held by another process.
182    pub fn try_acquire(lock_path: &Path) -> Result<Option<Self>, MarsError> {
183        let file = Self::open_lock_file(lock_path)?;
184        match platform::try_lock_exclusive(&file) {
185            Ok(true) => Ok(Some(FileLock { _fd: file })),
186            Ok(false) => Ok(None),
187            Err(err) => Err(err.into()),
188        }
189    }
190
191    /// Open (or create) the lock file, creating parent dirs if needed.
192    fn open_lock_file(lock_path: &Path) -> Result<fs::File, MarsError> {
193        if let Some(parent) = lock_path.parent() {
194            fs::create_dir_all(parent)?;
195        }
196        let file = fs::OpenOptions::new()
197            .read(true)
198            .write(true)
199            .create(true)
200            .truncate(false)
201            .open(lock_path)?;
202        Ok(file)
203    }
204}
205
206#[cfg(unix)]
207mod platform {
208    use std::fs;
209    use std::os::unix::io::AsRawFd;
210
211    pub fn lock_exclusive(file: &fs::File) -> std::io::Result<()> {
212        // SAFETY: the file descriptor is valid while `file` is alive.
213        let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) };
214        if ret != 0 {
215            Err(std::io::Error::last_os_error())
216        } else {
217            Ok(())
218        }
219    }
220
221    pub fn try_lock_exclusive(file: &fs::File) -> std::io::Result<bool> {
222        // SAFETY: the file descriptor is valid while `file` is alive.
223        let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
224        if ret != 0 {
225            let err = std::io::Error::last_os_error();
226            if err.kind() == std::io::ErrorKind::WouldBlock {
227                Ok(false)
228            } else {
229                Err(err)
230            }
231        } else {
232            Ok(true)
233        }
234    }
235}
236
237#[cfg(windows)]
238mod platform {
239    use std::fs;
240    use std::os::windows::io::AsRawHandle;
241
242    use windows_sys::Win32::Foundation::HANDLE;
243    use windows_sys::Win32::Storage::FileSystem::{
244        LOCKFILE_EXCLUSIVE_LOCK, LOCKFILE_FAIL_IMMEDIATELY, LockFileEx,
245    };
246
247    const ERROR_LOCK_VIOLATION: i32 = 33;
248
249    pub fn lock_exclusive(file: &fs::File) -> std::io::Result<()> {
250        let handle = file.as_raw_handle() as HANDLE;
251        // SAFETY: zero-initialized OVERLAPPED is accepted by LockFileEx for
252        // whole-file locks at offset 0.
253        let mut overlapped = unsafe { std::mem::zeroed() };
254        // SAFETY: handle is valid while `file` is alive and `overlapped` outlives the call.
255        let ret =
256            unsafe { LockFileEx(handle, LOCKFILE_EXCLUSIVE_LOCK, 0, !0, !0, &mut overlapped) };
257        if ret == 0 {
258            Err(std::io::Error::last_os_error())
259        } else {
260            Ok(())
261        }
262    }
263
264    pub fn try_lock_exclusive(file: &fs::File) -> std::io::Result<bool> {
265        let handle = file.as_raw_handle() as HANDLE;
266        // SAFETY: zero-initialized OVERLAPPED is accepted by LockFileEx for
267        // whole-file locks at offset 0.
268        let mut overlapped = unsafe { std::mem::zeroed() };
269        // SAFETY: handle is valid while `file` is alive and `overlapped` outlives the call.
270        let ret = unsafe {
271            LockFileEx(
272                handle,
273                LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY,
274                0,
275                !0,
276                !0,
277                &mut overlapped,
278            )
279        };
280        if ret == 0 {
281            let err = std::io::Error::last_os_error();
282            if err.raw_os_error() == Some(ERROR_LOCK_VIOLATION) {
283                Ok(false)
284            } else {
285                Err(err)
286            }
287        } else {
288            Ok(true)
289        }
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use tempfile::TempDir;
297
298    #[test]
299    fn atomic_write_creates_file_with_correct_content() {
300        let dir = TempDir::new().unwrap();
301        let dest = dir.path().join("output.txt");
302        let content = b"hello world";
303
304        atomic_write(&dest, content).unwrap();
305
306        assert_eq!(fs::read(&dest).unwrap(), content);
307    }
308
309    #[test]
310    fn atomic_write_creates_parent_dirs() {
311        let dir = TempDir::new().unwrap();
312        let dest = dir.path().join("nested").join("dir").join("file.txt");
313        let content = b"nested content";
314
315        atomic_write(&dest, content).unwrap();
316
317        assert_eq!(fs::read(&dest).unwrap(), content);
318    }
319
320    #[test]
321    fn atomic_write_overwrites_existing_file() {
322        let dir = TempDir::new().unwrap();
323        let dest = dir.path().join("output.txt");
324
325        atomic_write(&dest, b"first").unwrap();
326        atomic_write(&dest, b"second").unwrap();
327
328        assert_eq!(fs::read(&dest).unwrap(), b"second");
329    }
330
331    #[test]
332    fn atomic_install_dir_copies_tree() {
333        let dir = TempDir::new().unwrap();
334        let src = dir.path().join("src_dir");
335        let dest = dir.path().join("dest_dir");
336
337        // Create source tree
338        fs::create_dir_all(src.join("sub")).unwrap();
339        fs::write(src.join("a.txt"), "file a").unwrap();
340        fs::write(src.join("sub").join("b.txt"), "file b").unwrap();
341
342        atomic_install_dir(&src, &dest).unwrap();
343
344        assert_eq!(fs::read_to_string(dest.join("a.txt")).unwrap(), "file a");
345        assert_eq!(
346            fs::read_to_string(dest.join("sub").join("b.txt")).unwrap(),
347            "file b"
348        );
349    }
350
351    #[test]
352    fn atomic_install_dir_replaces_existing() {
353        let dir = TempDir::new().unwrap();
354        let src = dir.path().join("src_dir");
355        let dest = dir.path().join("dest_dir");
356
357        // Create initial dest
358        fs::create_dir_all(&dest).unwrap();
359        fs::write(dest.join("old.txt"), "old").unwrap();
360
361        // Create source
362        fs::create_dir_all(&src).unwrap();
363        fs::write(src.join("new.txt"), "new").unwrap();
364
365        atomic_install_dir(&src, &dest).unwrap();
366
367        assert!(dest.join("new.txt").exists());
368        assert!(!dest.join("old.txt").exists());
369    }
370
371    #[test]
372    fn atomic_install_dir_cleans_stale_old() {
373        let dir = TempDir::new().unwrap();
374        let src = dir.path().join("src_dir");
375        let dest = dir.path().join("dest_dir");
376
377        // Create initial dest
378        fs::create_dir_all(&dest).unwrap();
379        fs::write(dest.join("old.txt"), "old").unwrap();
380
381        // Create stale .old from a prior crash
382        let old_path = dir.path().join(".dest_dir.old");
383        fs::create_dir_all(&old_path).unwrap();
384        fs::write(old_path.join("stale.txt"), "stale").unwrap();
385
386        // Create source
387        fs::create_dir_all(&src).unwrap();
388        fs::write(src.join("new.txt"), "new").unwrap();
389
390        atomic_install_dir(&src, &dest).unwrap();
391
392        assert!(dest.join("new.txt").exists());
393        assert!(!dest.join("old.txt").exists());
394        assert!(!old_path.exists(), "stale .old should be cleaned up");
395    }
396
397    #[test]
398    fn atomic_install_dir_dest_exists_throughout() {
399        let dir = TempDir::new().unwrap();
400        let src = dir.path().join("src_dir");
401        let dest = dir.path().join("dest_dir");
402
403        // Create initial dest
404        fs::create_dir_all(&dest).unwrap();
405        fs::write(dest.join("v1.txt"), "v1").unwrap();
406
407        // Create source
408        fs::create_dir_all(&src).unwrap();
409        fs::write(src.join("v2.txt"), "v2").unwrap();
410
411        assert!(dest.exists(), "dest should exist before install");
412        atomic_install_dir(&src, &dest).unwrap();
413        assert!(dest.exists(), "dest should exist after install");
414        assert!(dest.join("v2.txt").exists());
415    }
416
417    #[test]
418    fn atomic_install_dir_filtered_excludes_top_level_entries() {
419        let dir = TempDir::new().unwrap();
420        let src = dir.path().join("src_dir");
421        let dest = dir.path().join("dest_dir");
422
423        fs::create_dir_all(src.join(".git")).unwrap();
424        fs::create_dir_all(src.join("resources")).unwrap();
425        fs::write(src.join("SKILL.md"), "skill").unwrap();
426        fs::write(src.join("mars.toml"), "ignored").unwrap();
427        fs::write(src.join(".gitignore"), "ignored").unwrap();
428        fs::write(src.join(".git").join("config"), "ignored").unwrap();
429        fs::write(src.join("resources").join("guide.md"), "kept").unwrap();
430
431        atomic_install_dir_filtered(&src, &dest, FLAT_SKILL_EXCLUDED_TOP_LEVEL).unwrap();
432
433        assert!(dest.join("SKILL.md").exists());
434        assert!(dest.join("resources").join("guide.md").exists());
435        assert!(!dest.join(".git").exists());
436        assert!(!dest.join("mars.toml").exists());
437        assert!(!dest.join(".gitignore").exists());
438    }
439
440    #[test]
441    fn remove_item_removes_file() {
442        let dir = TempDir::new().unwrap();
443        let file = dir.path().join("agent.md");
444        fs::write(&file, "agent content").unwrap();
445
446        remove_item(&file, ItemKind::Agent).unwrap();
447
448        assert!(!file.exists());
449    }
450
451    #[test]
452    fn remove_item_removes_directory() {
453        let dir = TempDir::new().unwrap();
454        let skill_dir = dir.path().join("my-skill");
455        fs::create_dir_all(skill_dir.join("sub")).unwrap();
456        fs::write(skill_dir.join("main.md"), "skill").unwrap();
457        fs::write(skill_dir.join("sub").join("helper.md"), "helper").unwrap();
458
459        remove_item(&skill_dir, ItemKind::Skill).unwrap();
460
461        assert!(!skill_dir.exists());
462    }
463
464    #[test]
465    fn file_lock_acquire_returns_lock() {
466        let dir = TempDir::new().unwrap();
467        let lock_path = dir.path().join("test.lock");
468
469        let lock = FileLock::acquire(&lock_path).unwrap();
470        assert!(lock_path.exists());
471        drop(lock);
472    }
473
474    #[test]
475    fn file_lock_released_on_drop() {
476        let dir = TempDir::new().unwrap();
477        let lock_path = dir.path().join("test.lock");
478
479        {
480            let _lock = FileLock::acquire(&lock_path).unwrap();
481            // Lock held here
482        }
483        // Lock dropped — should be acquirable again
484        let lock2 = FileLock::try_acquire(&lock_path).unwrap();
485        assert!(lock2.is_some());
486    }
487
488    #[test]
489    fn file_lock_creates_parent_dirs() {
490        let dir = TempDir::new().unwrap();
491        let lock_path = dir.path().join("nested").join("dir").join("test.lock");
492
493        let lock = FileLock::acquire(&lock_path).unwrap();
494        assert!(lock_path.exists());
495        drop(lock);
496    }
497}