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 | 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
163pub struct FileLock {
169 _fd: fs::File,
170}
171
172impl FileLock {
173 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 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 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 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 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 let mut overlapped = unsafe { std::mem::zeroed() };
254 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 let mut overlapped = unsafe { std::mem::zeroed() };
269 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 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 fs::create_dir_all(&dest).unwrap();
359 fs::write(dest.join("old.txt"), "old").unwrap();
360
361 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 fs::create_dir_all(&dest).unwrap();
379 fs::write(dest.join("old.txt"), "old").unwrap();
380
381 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 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 fs::create_dir_all(&dest).unwrap();
405 fs::write(dest.join("v1.txt"), "v1").unwrap();
406
407 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 }
483 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}