1use std::fs;
2use std::io::Write;
3use std::path::Path;
4
5use crate::error::MarsError;
6
7pub const FLAT_SKILL_EXCLUDED_TOP_LEVEL: &[&str] = &[
9 ".git",
10 ".mars",
11 "mars.toml",
12 "mars.lock",
13 "mars.local.toml",
14 ".gitignore",
15];
16
17pub fn atomic_write(dest: &Path, content: &[u8]) -> Result<(), MarsError> {
22 if let Some(parent) = dest.parent() {
24 fs::create_dir_all(parent)?;
25 }
26
27 let parent = dest.parent().unwrap_or(Path::new("."));
28 let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
29 tmp.write_all(content)?;
30 tmp.as_file().sync_all()?;
31 #[cfg(unix)]
32 {
33 use std::os::unix::fs::PermissionsExt;
34 tmp.as_file()
35 .set_permissions(fs::Permissions::from_mode(0o644))?;
36 }
37 tmp.persist(dest).map_err(|e| e.error)?;
38 Ok(())
39}
40
41pub fn atomic_write_if_changed(dest: &Path, content: &[u8]) -> Result<bool, MarsError> {
46 let unchanged = dest
47 .symlink_metadata()
48 .ok()
49 .filter(|metadata| metadata.is_file() && !metadata.file_type().is_symlink())
50 .and_then(|_| fs::read(dest).ok())
51 .is_some_and(|existing| existing == content);
52 if unchanged {
53 return Ok(false);
54 }
55 atomic_write(dest, content)?;
56 Ok(true)
57}
58
59pub fn atomic_install_dir(src: &Path, dest: &Path) -> Result<(), MarsError> {
67 atomic_install_dir_impl(src, dest, &[])
68}
69
70pub fn atomic_install_dir_filtered(
72 src: &Path,
73 dest: &Path,
74 excluded_top_level: &[&str],
75) -> Result<(), MarsError> {
76 atomic_install_dir_impl(src, dest, excluded_top_level)
77}
78
79fn atomic_install_dir_impl(
80 src: &Path,
81 dest: &Path,
82 excluded_top_level: &[&str],
83) -> Result<(), MarsError> {
84 let parent = dest.parent().unwrap_or(Path::new("."));
85 fs::create_dir_all(parent)?;
86
87 let tmp_dir = tempfile::TempDir::new_in(parent)?;
88 copy_dir_recursive(src, tmp_dir.path(), src, excluded_top_level)?;
89 let tmp_path = tmp_dir.keep();
90
91 if dest.exists() {
92 let old_path = parent.join(format!(
94 ".{}.old",
95 dest.file_name().unwrap_or_default().to_string_lossy()
96 ));
97 if old_path.exists() {
99 fs::remove_dir_all(&old_path)?;
100 }
101 fs::rename(dest, &old_path)?;
103 if let Err(e) = fs::rename(&tmp_path, dest) {
105 let _ = fs::rename(&old_path, dest);
107 let _ = fs::remove_dir_all(&tmp_path);
108 return Err(e.into());
109 }
110 let _ = fs::remove_dir_all(&old_path);
112 } else {
113 fs::rename(&tmp_path, dest)?;
114 }
115
116 Ok(())
117}
118
119fn copy_dir_recursive(
121 src: &Path,
122 dest: &Path,
123 root: &Path,
124 excluded_top_level: &[&str],
125) -> Result<(), MarsError> {
126 for entry in fs::read_dir(src)? {
127 let entry = entry?;
128 let file_type = entry.file_type()?;
129 let src_path = entry.path();
130 let dest_path = dest.join(entry.file_name());
131
132 let rel_path = src_path
133 .strip_prefix(root)
134 .expect("copy traversal path should be under root");
135 if is_excluded_top_level(rel_path, excluded_top_level) {
136 continue;
137 }
138
139 if file_type.is_dir() {
140 fs::create_dir_all(&dest_path)?;
141 copy_dir_recursive(&src_path, &dest_path, root, excluded_top_level)?;
142 } else {
143 fs::copy(&src_path, &dest_path)?;
144 }
145 }
146 Ok(())
147}
148
149fn is_excluded_top_level(path: &Path, excluded_top_level: &[&str]) -> bool {
150 let Some(first) = path.components().next().map(|c| c.as_os_str()) else {
151 return false;
152 };
153 excluded_top_level.iter().any(|excluded| first == *excluded)
154}
155
156#[cfg(windows)]
157#[allow(clippy::permissions_set_readonly_false)]
158pub fn clear_readonly(path: &Path) -> std::io::Result<()> {
159 if let Ok(metadata) = std::fs::metadata(path) {
160 let mut perms = metadata.permissions();
161 if perms.readonly() {
162 perms.set_readonly(false);
163 std::fs::set_permissions(path, perms)?;
164 }
165 }
166 Ok(())
167}
168
169pub struct FileLock {
175 _fd: fs::File,
176}
177
178impl FileLock {
179 pub fn acquire(lock_path: &Path) -> Result<Self, MarsError> {
181 let file = Self::open_lock_file(lock_path)?;
182 platform::lock_exclusive(&file)?;
183 Ok(FileLock { _fd: file })
184 }
185
186 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(unix)]
202mod platform {
203 use std::fs;
204 use std::os::unix::io::AsRawFd;
205
206 pub fn lock_exclusive(file: &fs::File) -> std::io::Result<()> {
207 let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) };
209 if ret != 0 {
210 Err(std::io::Error::last_os_error())
211 } else {
212 Ok(())
213 }
214 }
215}
216
217#[cfg(windows)]
218mod platform {
219 use std::fs;
220 use std::os::windows::io::AsRawHandle;
221
222 use windows_sys::Win32::Foundation::HANDLE;
223 use windows_sys::Win32::Storage::FileSystem::{LOCKFILE_EXCLUSIVE_LOCK, LockFileEx};
224
225 pub fn lock_exclusive(file: &fs::File) -> std::io::Result<()> {
226 let handle = file.as_raw_handle() as HANDLE;
227 let mut overlapped = unsafe { std::mem::zeroed() };
230 let ret =
232 unsafe { LockFileEx(handle, LOCKFILE_EXCLUSIVE_LOCK, 0, !0, !0, &mut overlapped) };
233 if ret == 0 {
234 Err(std::io::Error::last_os_error())
235 } else {
236 Ok(())
237 }
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244 use tempfile::TempDir;
245
246 #[test]
247 fn atomic_write_creates_file_with_correct_content() {
248 let dir = TempDir::new().unwrap();
249 let dest = dir.path().join("output.txt");
250 let content = b"hello world";
251
252 atomic_write(&dest, content).unwrap();
253
254 assert_eq!(fs::read(&dest).unwrap(), content);
255 }
256
257 #[test]
258 fn atomic_write_creates_parent_dirs() {
259 let dir = TempDir::new().unwrap();
260 let dest = dir.path().join("nested").join("dir").join("file.txt");
261 let content = b"nested content";
262
263 atomic_write(&dest, content).unwrap();
264
265 assert_eq!(fs::read(&dest).unwrap(), content);
266 }
267
268 #[test]
269 fn atomic_write_overwrites_existing_file() {
270 let dir = TempDir::new().unwrap();
271 let dest = dir.path().join("output.txt");
272
273 atomic_write(&dest, b"first").unwrap();
274 atomic_write(&dest, b"second").unwrap();
275
276 assert_eq!(fs::read(&dest).unwrap(), b"second");
277 }
278
279 #[test]
280 fn atomic_install_dir_copies_tree() {
281 let dir = TempDir::new().unwrap();
282 let src = dir.path().join("src_dir");
283 let dest = dir.path().join("dest_dir");
284
285 fs::create_dir_all(src.join("sub")).unwrap();
287 fs::write(src.join("a.txt"), "file a").unwrap();
288 fs::write(src.join("sub").join("b.txt"), "file b").unwrap();
289
290 atomic_install_dir(&src, &dest).unwrap();
291
292 assert_eq!(fs::read_to_string(dest.join("a.txt")).unwrap(), "file a");
293 assert_eq!(
294 fs::read_to_string(dest.join("sub").join("b.txt")).unwrap(),
295 "file b"
296 );
297 }
298
299 #[test]
300 fn atomic_install_dir_replaces_existing() {
301 let dir = TempDir::new().unwrap();
302 let src = dir.path().join("src_dir");
303 let dest = dir.path().join("dest_dir");
304
305 fs::create_dir_all(&dest).unwrap();
307 fs::write(dest.join("old.txt"), "old").unwrap();
308
309 fs::create_dir_all(&src).unwrap();
311 fs::write(src.join("new.txt"), "new").unwrap();
312
313 atomic_install_dir(&src, &dest).unwrap();
314
315 assert!(dest.join("new.txt").exists());
316 assert!(!dest.join("old.txt").exists());
317 }
318
319 #[test]
320 fn atomic_install_dir_cleans_stale_old() {
321 let dir = TempDir::new().unwrap();
322 let src = dir.path().join("src_dir");
323 let dest = dir.path().join("dest_dir");
324
325 fs::create_dir_all(&dest).unwrap();
327 fs::write(dest.join("old.txt"), "old").unwrap();
328
329 let old_path = dir.path().join(".dest_dir.old");
331 fs::create_dir_all(&old_path).unwrap();
332 fs::write(old_path.join("stale.txt"), "stale").unwrap();
333
334 fs::create_dir_all(&src).unwrap();
336 fs::write(src.join("new.txt"), "new").unwrap();
337
338 atomic_install_dir(&src, &dest).unwrap();
339
340 assert!(dest.join("new.txt").exists());
341 assert!(!dest.join("old.txt").exists());
342 assert!(!old_path.exists(), "stale .old should be cleaned up");
343 }
344
345 #[test]
346 fn atomic_install_dir_dest_exists_throughout() {
347 let dir = TempDir::new().unwrap();
348 let src = dir.path().join("src_dir");
349 let dest = dir.path().join("dest_dir");
350
351 fs::create_dir_all(&dest).unwrap();
353 fs::write(dest.join("v1.txt"), "v1").unwrap();
354
355 fs::create_dir_all(&src).unwrap();
357 fs::write(src.join("v2.txt"), "v2").unwrap();
358
359 assert!(dest.exists(), "dest should exist before install");
360 atomic_install_dir(&src, &dest).unwrap();
361 assert!(dest.exists(), "dest should exist after install");
362 assert!(dest.join("v2.txt").exists());
363 }
364
365 #[test]
366 fn atomic_install_dir_filtered_excludes_top_level_entries() {
367 let dir = TempDir::new().unwrap();
368 let src = dir.path().join("src_dir");
369 let dest = dir.path().join("dest_dir");
370
371 fs::create_dir_all(src.join(".git")).unwrap();
372 fs::create_dir_all(src.join("resources")).unwrap();
373 fs::write(src.join("SKILL.md"), "skill").unwrap();
374 fs::write(src.join("mars.toml"), "ignored").unwrap();
375 fs::write(src.join(".gitignore"), "ignored").unwrap();
376 fs::write(src.join(".git").join("config"), "ignored").unwrap();
377 fs::write(src.join("resources").join("guide.md"), "kept").unwrap();
378
379 atomic_install_dir_filtered(&src, &dest, FLAT_SKILL_EXCLUDED_TOP_LEVEL).unwrap();
380
381 assert!(dest.join("SKILL.md").exists());
382 assert!(dest.join("resources").join("guide.md").exists());
383 assert!(!dest.join(".git").exists());
384 assert!(!dest.join("mars.toml").exists());
385 assert!(!dest.join(".gitignore").exists());
386 }
387
388 #[test]
389 fn file_lock_acquire_returns_lock() {
390 let dir = TempDir::new().unwrap();
391 let lock_path = dir.path().join("test.lock");
392
393 let lock = FileLock::acquire(&lock_path).unwrap();
394 assert!(lock_path.exists());
395 drop(lock);
396 }
397
398 #[test]
399 fn file_lock_creates_parent_dirs() {
400 let dir = TempDir::new().unwrap();
401 let lock_path = dir.path().join("nested").join("dir").join("test.lock");
402
403 let lock = FileLock::acquire(&lock_path).unwrap();
404 assert!(lock_path.exists());
405 drop(lock);
406 }
407}