1use std::fs;
2use std::io::Write;
3use std::path::Path;
4
5use crate::error::MarsError;
6use crate::types::ItemKind;
7
8pub 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
18pub fn atomic_write(dest: &Path, content: &[u8]) -> Result<(), MarsError> {
23 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
42pub fn atomic_install_dir(src: &Path, dest: &Path) -> Result<(), MarsError> {
50 atomic_install_dir_impl(src, dest, &[])
51}
52
53pub 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 let old_path = parent.join(format!(
77 ".{}.old",
78 dest.file_name().unwrap_or_default().to_string_lossy()
79 ));
80 if old_path.exists() {
82 fs::remove_dir_all(&old_path)?;
83 }
84 fs::rename(dest, &old_path)?;
86 if let Err(e) = fs::rename(&tmp_path, dest) {
88 let _ = fs::rename(&old_path, dest);
90 let _ = fs::remove_dir_all(&tmp_path);
91 return Err(e.into());
92 }
93 let _ = fs::remove_dir_all(&old_path);
95 } else {
96 fs::rename(&tmp_path, dest)?;
97 }
98
99 Ok(())
100}
101
102fn 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
139pub 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
160pub struct FileLock {
166 _fd: fs::File,
167}
168
169impl FileLock {
170 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 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 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 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 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 let mut overlapped = unsafe { std::mem::zeroed() };
251 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 let mut overlapped = unsafe { std::mem::zeroed() };
266 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 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 fs::create_dir_all(&dest).unwrap();
356 fs::write(dest.join("old.txt"), "old").unwrap();
357
358 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 fs::create_dir_all(&dest).unwrap();
376 fs::write(dest.join("old.txt"), "old").unwrap();
377
378 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 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 fs::create_dir_all(&dest).unwrap();
402 fs::write(dest.join("v1.txt"), "v1").unwrap();
403
404 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 }
480 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}