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
9pub 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
19pub fn atomic_write(dest: &Path, content: &[u8]) -> Result<(), MarsError> {
24 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
37pub fn atomic_install_dir(src: &Path, dest: &Path) -> Result<(), MarsError> {
45 atomic_install_dir_impl(src, dest, &[])
46}
47
48pub 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 let old_path = parent.join(format!(
72 ".{}.old",
73 dest.file_name().unwrap_or_default().to_string_lossy()
74 ));
75 if old_path.exists() {
77 fs::remove_dir_all(&old_path)?;
78 }
79 fs::rename(dest, &old_path)?;
81 if let Err(e) = fs::rename(&tmp_path, dest) {
83 let _ = fs::rename(&old_path, dest);
85 let _ = fs::remove_dir_all(&tmp_path);
86 return Err(e.into());
87 }
88 let _ = fs::remove_dir_all(&old_path);
90 } else {
91 fs::rename(&tmp_path, dest)?;
92 }
93
94 Ok(())
95}
96
97fn 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
134pub 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
143pub struct FileLock {
149 _fd: fs::File,
150}
151
152impl FileLock {
153 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 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 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 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 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 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 fs::create_dir_all(&dest).unwrap();
267 fs::write(dest.join("old.txt"), "old").unwrap();
268
269 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 fs::create_dir_all(&dest).unwrap();
287 fs::write(dest.join("old.txt"), "old").unwrap();
288
289 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 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 fs::create_dir_all(&dest).unwrap();
313 fs::write(dest.join("v1.txt"), "v1").unwrap();
314
315 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 }
391 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}