Skip to main content

mars_agents/fs/
mod.rs

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