1use std::path::Path;
2
3use crate::error::MarsError;
4use crate::lock::{ItemId, ItemKind};
5use crate::sync::plan::{PlannedAction, SyncPlan};
6use crate::sync::target::TargetItem;
7use crate::types::{ContentHash, DestPath, ItemName, SourceName};
8
9#[derive(Debug, Clone, Default)]
11pub struct SyncOptions {
12 pub force: bool,
14 pub dry_run: bool,
16 pub frozen: bool,
18}
19
20#[derive(Debug, Clone)]
22pub struct ApplyResult {
23 pub outcomes: Vec<ActionOutcome>,
24}
25
26#[derive(Debug, Clone)]
28pub struct ActionOutcome {
29 pub item_id: ItemId,
30 pub action: ActionTaken,
31 pub dest_path: DestPath,
32 pub source_name: SourceName,
34 pub source_checksum: Option<ContentHash>,
36 pub installed_checksum: Option<ContentHash>,
38}
39
40#[derive(Debug, Clone)]
42pub enum ActionTaken {
43 Installed,
44 Updated,
45 Merged,
46 Conflicted,
47 Removed,
48 Skipped,
49 Kept,
50}
51
52pub fn execute(
65 root: &Path,
66 plan: &SyncPlan,
67 options: &SyncOptions,
68 cache_bases_dir: &Path,
69) -> Result<ApplyResult, MarsError> {
70 let mut outcomes = Vec::new();
71
72 for action in &plan.actions {
73 let outcome = if options.dry_run {
74 dry_run_action(action)
76 } else {
77 execute_action(root, action, cache_bases_dir)?
78 };
79 outcomes.push(outcome);
80 }
81
82 Ok(ApplyResult { outcomes })
83}
84
85fn execute_action(
87 root: &Path,
88 action: &PlannedAction,
89 cache_bases_dir: &Path,
90) -> Result<ActionOutcome, MarsError> {
91 match action {
92 PlannedAction::Install { target } => {
93 let dest = root.join(&target.dest_path);
94
95 let installed_checksum = install_item(target, &dest)?;
97
98 cache_base_content(cache_bases_dir, &installed_checksum, &dest, target.id.kind)?;
100
101 Ok(ActionOutcome {
102 item_id: target.id.clone(),
103 action: ActionTaken::Installed,
104 dest_path: target.dest_path.clone(),
105 source_name: target.source_name.clone(),
106 source_checksum: Some(target.source_hash.clone()),
107 installed_checksum: Some(installed_checksum),
108 })
109 }
110
111 PlannedAction::Overwrite { target } => {
112 let dest = root.join(&target.dest_path);
113
114 let installed_checksum = install_item(target, &dest)?;
116
117 cache_base_content(cache_bases_dir, &installed_checksum, &dest, target.id.kind)?;
119
120 Ok(ActionOutcome {
121 item_id: target.id.clone(),
122 action: ActionTaken::Updated,
123 dest_path: target.dest_path.clone(),
124 source_name: target.source_name.clone(),
125 source_checksum: Some(target.source_hash.clone()),
126 installed_checksum: Some(installed_checksum),
127 })
128 }
129
130 PlannedAction::Merge {
131 target,
132 base_content,
133 local_path,
134 } => {
135 let dest = root.join(&target.dest_path);
136 let full_local_path = root.join(local_path);
137
138 let theirs_content = read_target_content_for_merge(target)?;
140
141 let local_content = read_item_content(&full_local_path, target.id.kind)?;
143
144 let labels = crate::merge::MergeLabels {
146 base: "base (last sync)".into(),
147 local: "local".into(),
148 theirs: format!("{}@{}", target.source_name, "upstream"),
149 };
150
151 let merge_result = crate::merge::merge_content(
152 base_content,
153 &local_content,
154 &theirs_content,
155 &labels,
156 )?;
157
158 crate::fs::atomic_write(&dest, &merge_result.content)?;
160
161 let installed_checksum =
162 ContentHash::from(crate::hash::hash_bytes(&merge_result.content));
163
164 cache_base_content(cache_bases_dir, &installed_checksum, &dest, target.id.kind)?;
166
167 let action_taken = if merge_result.has_conflicts {
168 ActionTaken::Conflicted
169 } else {
170 ActionTaken::Merged
171 };
172
173 Ok(ActionOutcome {
174 item_id: target.id.clone(),
175 action: action_taken,
176 dest_path: target.dest_path.clone(),
177 source_name: target.source_name.clone(),
178 source_checksum: Some(target.source_hash.clone()),
179 installed_checksum: Some(installed_checksum),
180 })
181 }
182
183 PlannedAction::Remove { locked } => {
184 let dest = root.join(&locked.dest_path);
185 if dest.exists() {
186 crate::fs::remove_item(&dest, locked.kind)?;
187 }
188
189 let item_id = ItemId {
190 kind: locked.kind,
191 name: ItemName::from(extract_name_from_dest(&locked.dest_path, locked.kind)),
192 };
193
194 Ok(ActionOutcome {
195 item_id,
196 action: ActionTaken::Removed,
197 dest_path: locked.dest_path.clone(),
198 source_name: locked.source.clone(),
199 source_checksum: None,
200 installed_checksum: None,
201 })
202 }
203
204 PlannedAction::Skip {
205 item_id,
206 dest_path,
207 source_name,
208 reason: _,
209 } => Ok(ActionOutcome {
210 item_id: item_id.clone(),
211 action: ActionTaken::Skipped,
212 dest_path: dest_path.clone(),
213 source_name: source_name.clone(),
214 source_checksum: None,
215 installed_checksum: None,
216 }),
217
218 PlannedAction::KeepLocal {
219 item_id,
220 dest_path,
221 source_name,
222 } => Ok(ActionOutcome {
223 item_id: item_id.clone(),
224 action: ActionTaken::Kept,
225 dest_path: dest_path.clone(),
226 source_name: source_name.clone(),
227 source_checksum: None,
228 installed_checksum: None,
229 }),
230 }
231}
232
233fn dry_run_action(action: &PlannedAction) -> ActionOutcome {
235 match action {
236 PlannedAction::Install { target } => ActionOutcome {
237 item_id: target.id.clone(),
238 action: ActionTaken::Installed,
239 dest_path: target.dest_path.clone(),
240 source_name: target.source_name.clone(),
241 source_checksum: Some(target.source_hash.clone()),
242 installed_checksum: None, },
244 PlannedAction::Overwrite { target } => ActionOutcome {
245 item_id: target.id.clone(),
246 action: ActionTaken::Updated,
247 dest_path: target.dest_path.clone(),
248 source_name: target.source_name.clone(),
249 source_checksum: Some(target.source_hash.clone()),
250 installed_checksum: None,
251 },
252 PlannedAction::Merge { target, .. } => ActionOutcome {
253 item_id: target.id.clone(),
254 action: ActionTaken::Merged,
255 dest_path: target.dest_path.clone(),
256 source_name: target.source_name.clone(),
257 source_checksum: Some(target.source_hash.clone()),
258 installed_checksum: None,
259 },
260 PlannedAction::Remove { locked } => {
261 let item_id = ItemId {
262 kind: locked.kind,
263 name: ItemName::from(extract_name_from_dest(&locked.dest_path, locked.kind)),
264 };
265 ActionOutcome {
266 item_id,
267 action: ActionTaken::Removed,
268 dest_path: locked.dest_path.clone(),
269 source_name: locked.source.clone(),
270 source_checksum: None,
271 installed_checksum: None,
272 }
273 }
274 PlannedAction::Skip {
275 item_id,
276 dest_path,
277 source_name,
278 ..
279 } => ActionOutcome {
280 item_id: item_id.clone(),
281 action: ActionTaken::Skipped,
282 dest_path: dest_path.clone(),
283 source_name: source_name.clone(),
284 source_checksum: None,
285 installed_checksum: None,
286 },
287 PlannedAction::KeepLocal {
288 item_id,
289 dest_path,
290 source_name,
291 } => ActionOutcome {
292 item_id: item_id.clone(),
293 action: ActionTaken::Kept,
294 dest_path: dest_path.clone(),
295 source_name: source_name.clone(),
296 source_checksum: None,
297 installed_checksum: None,
298 },
299 }
300}
301
302fn install_item(target: &TargetItem, dest: &Path) -> Result<ContentHash, MarsError> {
306 match target.id.kind {
307 ItemKind::Agent => {
308 let content = content_to_install(target)?;
309 crate::fs::atomic_write(dest, &content)?;
310 Ok(ContentHash::from(crate::hash::hash_bytes(&content)))
311 }
312 ItemKind::Skill => {
313 if target.is_flat_skill {
314 crate::fs::atomic_install_dir_filtered(
315 &target.source_path,
316 dest,
317 crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL,
318 )?;
319 } else {
320 crate::fs::atomic_install_dir(&target.source_path, dest)?;
321 }
322 crate::hash::compute_hash(dest, ItemKind::Skill).map(ContentHash::from)
323 }
324 }
325}
326
327fn content_to_install(target: &TargetItem) -> Result<Vec<u8>, MarsError> {
329 if let Some(content) = &target.rewritten_content {
330 Ok(content.as_bytes().to_vec())
331 } else {
332 Ok(std::fs::read(&target.source_path)?)
333 }
334}
335
336fn read_target_content_for_merge(target: &TargetItem) -> Result<Vec<u8>, MarsError> {
338 match target.id.kind {
339 ItemKind::Agent => content_to_install(target),
340 ItemKind::Skill => read_item_content(&target.source_path, target.id.kind),
341 }
342}
343
344fn read_item_content(path: &Path, kind: ItemKind) -> Result<Vec<u8>, MarsError> {
349 match kind {
350 ItemKind::Agent => Ok(std::fs::read(path)?),
351 ItemKind::Skill => {
352 let skill_md = path.join("SKILL.md");
354 if skill_md.exists() {
355 Ok(std::fs::read(&skill_md)?)
356 } else {
357 Ok(Vec::new())
358 }
359 }
360 }
361}
362
363fn cache_base_content(
368 cache_bases_dir: &Path,
369 installed_checksum: &ContentHash,
370 dest: &Path,
371 kind: ItemKind,
372) -> Result<(), MarsError> {
373 std::fs::create_dir_all(cache_bases_dir)?;
374 let cache_path = cache_bases_dir.join(installed_checksum.as_ref());
375
376 if cache_path.exists() {
378 return Ok(());
379 }
380
381 match kind {
382 ItemKind::Agent => {
383 let content = std::fs::read(dest)?;
384 crate::fs::atomic_write(&cache_path, &content)?;
385 }
386 ItemKind::Skill => {
387 let skill_md = dest.join("SKILL.md");
389 if skill_md.exists() {
390 let content = std::fs::read(&skill_md)?;
391 crate::fs::atomic_write(&cache_path, &content)?;
392 }
393 }
394 }
395
396 Ok(())
397}
398
399fn extract_name_from_dest(dest_path: &DestPath, kind: ItemKind) -> String {
401 let path = dest_path.as_path();
402 match kind {
403 ItemKind::Agent => path
404 .file_stem()
405 .map(|s| s.to_string_lossy().to_string())
406 .unwrap_or_default(),
407 ItemKind::Skill => path
408 .file_name()
409 .map(|s| s.to_string_lossy().to_string())
410 .unwrap_or_default(),
411 }
412}
413
414pub fn prune_orphans(
419 root: &Path,
420 lock: &crate::lock::LockFile,
421 target: &crate::sync::target::TargetState,
422) -> Result<Vec<ActionOutcome>, MarsError> {
423 let mut outcomes = Vec::new();
424
425 for (dest_path_str, locked_item) in &lock.items {
426 if !target.items.contains_key(dest_path_str) {
427 let dest = root.join(dest_path_str);
428 if dest.exists() {
429 crate::fs::remove_item(&dest, locked_item.kind)?;
430 }
431 outcomes.push(ActionOutcome {
432 item_id: ItemId {
433 kind: locked_item.kind,
434 name: ItemName::from(extract_name_from_dest(dest_path_str, locked_item.kind)),
435 },
436 action: ActionTaken::Removed,
437 dest_path: dest_path_str.clone(),
438 source_name: locked_item.source.clone(),
439 source_checksum: None,
440 installed_checksum: None,
441 });
442 }
443 }
444
445 Ok(outcomes)
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451 use crate::hash;
452 use crate::lock::{ItemId, ItemKind, LockedItem};
453 use crate::sync::plan::{PlannedAction, SyncPlan};
454 use crate::sync::target::TargetItem;
455 use std::fs;
456 use std::path::PathBuf;
457 use tempfile::TempDir;
458
459 fn make_agent_target(name: &str, source_path: PathBuf, content: &[u8]) -> TargetItem {
460 TargetItem {
461 id: ItemId {
462 kind: ItemKind::Agent,
463 name: name.into(),
464 },
465 source_name: "test-source".into(),
466 source_id: crate::types::SourceId::Path {
467 canonical: source_path.clone(),
468 },
469 source_path,
470 dest_path: format!("agents/{name}.md").into(),
471 source_hash: hash::hash_bytes(content).into(),
472 is_flat_skill: false,
473 rewritten_content: None,
474 }
475 }
476
477 fn setup_source_agent(dir: &Path, name: &str, content: &[u8]) -> PathBuf {
478 let agents_dir = dir.join("source").join("agents");
479 fs::create_dir_all(&agents_dir).unwrap();
480 let path = agents_dir.join(format!("{name}.md"));
481 fs::write(&path, content).unwrap();
482 path
483 }
484
485 #[test]
488 fn install_creates_new_file() {
489 let root = TempDir::new().unwrap();
490 let source_dir = TempDir::new().unwrap();
491 let cache_dir = TempDir::new().unwrap();
492 let bases_dir = cache_dir.path().join("bases");
493
494 let content = b"# new agent content";
495 let source_path = setup_source_agent(source_dir.path(), "coder", content);
496 let target = make_agent_target("coder", source_path, content);
497
498 let plan = SyncPlan {
499 actions: vec![PlannedAction::Install {
500 target: target.clone(),
501 }],
502 };
503
504 let options = SyncOptions {
505 force: false,
506 dry_run: false,
507 frozen: false,
508 };
509
510 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
511 assert_eq!(result.outcomes.len(), 1);
512
513 let outcome = &result.outcomes[0];
514 assert!(matches!(outcome.action, ActionTaken::Installed));
515
516 let installed_path = root.path().join("agents/coder.md");
518 assert!(installed_path.exists());
519 assert_eq!(fs::read(&installed_path).unwrap(), content);
520
521 assert_eq!(
523 outcome.source_checksum.as_deref(),
524 Some(hash::hash_bytes(content).as_str())
525 );
526 assert!(outcome.installed_checksum.is_some());
527 }
528
529 #[test]
530 fn install_caches_base_content() {
531 let root = TempDir::new().unwrap();
532 let source_dir = TempDir::new().unwrap();
533 let cache_dir = TempDir::new().unwrap();
534 let bases_dir = cache_dir.path().join("bases");
535
536 let content = b"# cached content";
537 let source_path = setup_source_agent(source_dir.path(), "coder", content);
538 let target = make_agent_target("coder", source_path, content);
539
540 let plan = SyncPlan {
541 actions: vec![PlannedAction::Install { target }],
542 };
543
544 let options = SyncOptions {
545 force: false,
546 dry_run: false,
547 frozen: false,
548 };
549
550 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
551 let installed_checksum = result.outcomes[0].installed_checksum.as_ref().unwrap();
552
553 let cached = bases_dir.join(installed_checksum.as_ref());
555 assert!(cached.exists(), "base content should be cached");
556 assert_eq!(fs::read(&cached).unwrap(), content);
557 }
558
559 #[test]
562 fn overwrite_replaces_existing_file() {
563 let root = TempDir::new().unwrap();
564 let source_dir = TempDir::new().unwrap();
565 let cache_dir = TempDir::new().unwrap();
566 let bases_dir = cache_dir.path().join("bases");
567
568 let agents_dir = root.path().join("agents");
570 fs::create_dir_all(&agents_dir).unwrap();
571 fs::write(agents_dir.join("coder.md"), b"# old content").unwrap();
572
573 let new_content = b"# new content";
574 let source_path = setup_source_agent(source_dir.path(), "coder", new_content);
575 let target = make_agent_target("coder", source_path, new_content);
576
577 let plan = SyncPlan {
578 actions: vec![PlannedAction::Overwrite { target }],
579 };
580
581 let options = SyncOptions {
582 force: false,
583 dry_run: false,
584 frozen: false,
585 };
586
587 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
588 assert!(matches!(result.outcomes[0].action, ActionTaken::Updated));
589
590 let installed = fs::read(root.path().join("agents/coder.md")).unwrap();
591 assert_eq!(installed, new_content);
592 }
593
594 #[test]
597 fn remove_deletes_file() {
598 let root = TempDir::new().unwrap();
599 let cache_dir = TempDir::new().unwrap();
600 let bases_dir = cache_dir.path().join("bases");
601
602 let agents_dir = root.path().join("agents");
604 fs::create_dir_all(&agents_dir).unwrap();
605 fs::write(agents_dir.join("orphan.md"), b"# orphan").unwrap();
606
607 let locked = LockedItem {
608 source: "old-source".into(),
609 kind: ItemKind::Agent,
610 version: None,
611 source_checksum: "sha256:aaa".into(),
612 installed_checksum: "sha256:bbb".into(),
613 dest_path: "agents/orphan.md".into(),
614 };
615
616 let plan = SyncPlan {
617 actions: vec![PlannedAction::Remove { locked }],
618 };
619
620 let options = SyncOptions {
621 force: false,
622 dry_run: false,
623 frozen: false,
624 };
625
626 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
627 assert!(matches!(result.outcomes[0].action, ActionTaken::Removed));
628 assert!(!root.path().join("agents/orphan.md").exists());
629 }
630
631 #[test]
632 fn remove_skill_directory() {
633 let root = TempDir::new().unwrap();
634 let cache_dir = TempDir::new().unwrap();
635 let bases_dir = cache_dir.path().join("bases");
636
637 let skill_dir = root.path().join("skills/old-skill");
639 fs::create_dir_all(&skill_dir).unwrap();
640 fs::write(skill_dir.join("SKILL.md"), b"# old skill").unwrap();
641
642 let locked = LockedItem {
643 source: "old-source".into(),
644 kind: ItemKind::Skill,
645 version: None,
646 source_checksum: "sha256:aaa".into(),
647 installed_checksum: "sha256:bbb".into(),
648 dest_path: "skills/old-skill".into(),
649 };
650
651 let plan = SyncPlan {
652 actions: vec![PlannedAction::Remove { locked }],
653 };
654
655 let options = SyncOptions {
656 force: false,
657 dry_run: false,
658 frozen: false,
659 };
660
661 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
662 assert!(matches!(result.outcomes[0].action, ActionTaken::Removed));
663 assert!(!root.path().join("skills/old-skill").exists());
664 }
665
666 #[test]
669 fn dry_run_does_not_modify_files() {
670 let root = TempDir::new().unwrap();
671 let source_dir = TempDir::new().unwrap();
672 let cache_dir = TempDir::new().unwrap();
673 let bases_dir = cache_dir.path().join("bases");
674
675 let content = b"# new agent";
676 let source_path = setup_source_agent(source_dir.path(), "coder", content);
677 let target = make_agent_target("coder", source_path, content);
678
679 let plan = SyncPlan {
680 actions: vec![PlannedAction::Install { target }],
681 };
682
683 let options = SyncOptions {
684 force: false,
685 dry_run: true,
686 frozen: false,
687 };
688
689 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
690 assert_eq!(result.outcomes.len(), 1);
691 assert!(matches!(result.outcomes[0].action, ActionTaken::Installed));
692
693 assert!(!root.path().join("agents/coder.md").exists());
695 }
696
697 #[test]
700 fn skip_produces_skipped_outcome() {
701 let root = TempDir::new().unwrap();
702 let cache_dir = TempDir::new().unwrap();
703 let bases_dir = cache_dir.path().join("bases");
704
705 let plan = SyncPlan {
706 actions: vec![PlannedAction::Skip {
707 item_id: ItemId {
708 kind: ItemKind::Agent,
709 name: "stable".into(),
710 },
711 dest_path: "agents/stable.md".into(),
712 source_name: "base".into(),
713 reason: "unchanged",
714 }],
715 };
716
717 let options = SyncOptions {
718 force: false,
719 dry_run: false,
720 frozen: false,
721 };
722
723 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
724 assert!(matches!(result.outcomes[0].action, ActionTaken::Skipped));
725 assert_eq!(
726 result.outcomes[0].dest_path,
727 crate::types::DestPath::from("agents/stable.md")
728 );
729 assert_eq!(result.outcomes[0].source_name, "base");
730 }
731
732 #[test]
733 fn keep_local_produces_kept_outcome() {
734 let root = TempDir::new().unwrap();
735 let cache_dir = TempDir::new().unwrap();
736 let bases_dir = cache_dir.path().join("bases");
737
738 let plan = SyncPlan {
739 actions: vec![PlannedAction::KeepLocal {
740 item_id: ItemId {
741 kind: ItemKind::Agent,
742 name: "modified".into(),
743 },
744 dest_path: "agents/modified.md".into(),
745 source_name: "base".into(),
746 }],
747 };
748
749 let options = SyncOptions {
750 force: false,
751 dry_run: false,
752 frozen: false,
753 };
754
755 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
756 assert!(matches!(result.outcomes[0].action, ActionTaken::Kept));
757 assert_eq!(
758 result.outcomes[0].dest_path,
759 crate::types::DestPath::from("agents/modified.md")
760 );
761 assert_eq!(result.outcomes[0].source_name, "base");
762 }
763
764 #[test]
767 fn install_skill_directory() {
768 let root = TempDir::new().unwrap();
769 let source_dir = TempDir::new().unwrap();
770 let cache_dir = TempDir::new().unwrap();
771 let bases_dir = cache_dir.path().join("bases");
772
773 let source_skill = source_dir.path().join("skills/planning");
775 fs::create_dir_all(&source_skill).unwrap();
776 fs::write(source_skill.join("SKILL.md"), b"# Planning skill").unwrap();
777 fs::write(source_skill.join("helper.md"), b"# Helper").unwrap();
778
779 let skill_hash = hash::compute_hash(&source_skill, ItemKind::Skill).unwrap();
780
781 let target = TargetItem {
782 id: ItemId {
783 kind: ItemKind::Skill,
784 name: "planning".into(),
785 },
786 source_name: "test".into(),
787 source_id: crate::types::SourceId::Path {
788 canonical: source_skill.clone(),
789 },
790 source_path: source_skill,
791 dest_path: "skills/planning".into(),
792 source_hash: skill_hash.into(),
793 is_flat_skill: false,
794 rewritten_content: None,
795 };
796
797 let plan = SyncPlan {
798 actions: vec![PlannedAction::Install { target }],
799 };
800
801 let options = SyncOptions {
802 force: false,
803 dry_run: false,
804 frozen: false,
805 };
806
807 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
808 assert!(matches!(result.outcomes[0].action, ActionTaken::Installed));
809
810 let installed_dir = root.path().join("skills/planning");
811 assert!(installed_dir.exists());
812 assert!(installed_dir.join("SKILL.md").exists());
813 assert!(installed_dir.join("helper.md").exists());
814 assert_eq!(
815 fs::read_to_string(installed_dir.join("SKILL.md")).unwrap(),
816 "# Planning skill"
817 );
818 }
819
820 #[test]
821 fn install_flat_skill_excludes_repo_metadata() {
822 let root = TempDir::new().unwrap();
823 let source_dir = TempDir::new().unwrap();
824 let cache_dir = TempDir::new().unwrap();
825 let bases_dir = cache_dir.path().join("bases");
826
827 let flat_source = source_dir.path().join("flat-skill");
828 fs::create_dir_all(flat_source.join(".git")).unwrap();
829 fs::create_dir_all(flat_source.join("resources")).unwrap();
830 fs::write(flat_source.join("SKILL.md"), b"# Flat skill").unwrap();
831 fs::write(flat_source.join("resources/guide.md"), b"# Guide").unwrap();
832 fs::write(flat_source.join("mars.toml"), b"[sources]").unwrap();
833 fs::write(flat_source.join(".gitignore"), b"target/").unwrap();
834 fs::write(flat_source.join(".git/config"), b"[core]").unwrap();
835
836 let source_hash = hash::compute_skill_hash_filtered(
837 &flat_source,
838 crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL,
839 )
840 .unwrap();
841
842 let target = TargetItem {
843 id: ItemId {
844 kind: ItemKind::Skill,
845 name: "flat-skill".into(),
846 },
847 source_name: "test".into(),
848 source_id: crate::types::SourceId::Path {
849 canonical: flat_source.clone(),
850 },
851 source_path: flat_source,
852 dest_path: "skills/flat-skill".into(),
853 source_hash: source_hash.into(),
854 is_flat_skill: true,
855 rewritten_content: None,
856 };
857
858 let plan = SyncPlan {
859 actions: vec![PlannedAction::Install { target }],
860 };
861
862 let options = SyncOptions {
863 force: false,
864 dry_run: false,
865 frozen: false,
866 };
867
868 execute(root.path(), &plan, &options, &bases_dir).unwrap();
869
870 let installed = root.path().join("skills/flat-skill");
871 assert!(installed.join("SKILL.md").exists());
872 assert!(installed.join("resources/guide.md").exists());
873 assert!(!installed.join(".git").exists());
874 assert!(!installed.join("mars.toml").exists());
875 assert!(!installed.join(".gitignore").exists());
876 }
877
878 #[test]
881 fn prune_removes_orphaned_items() {
882 let root = TempDir::new().unwrap();
883
884 let agents_dir = root.path().join("agents");
886 fs::create_dir_all(&agents_dir).unwrap();
887 fs::write(agents_dir.join("old.md"), b"# orphan").unwrap();
888
889 let mut lock_items = indexmap::IndexMap::new();
890 lock_items.insert(
891 "agents/old.md".into(),
892 LockedItem {
893 source: "old-source".into(),
894 kind: ItemKind::Agent,
895 version: None,
896 source_checksum: "sha256:aaa".into(),
897 installed_checksum: "sha256:bbb".into(),
898 dest_path: "agents/old.md".into(),
899 },
900 );
901 let lock = crate::lock::LockFile {
902 version: 1,
903 sources: indexmap::IndexMap::new(),
904 items: lock_items,
905 };
906
907 let target = crate::sync::target::TargetState {
909 items: indexmap::IndexMap::new(),
910 };
911
912 let outcomes = prune_orphans(root.path(), &lock, &target).unwrap();
913 assert_eq!(outcomes.len(), 1);
914 assert!(matches!(outcomes[0].action, ActionTaken::Removed));
915 assert!(!root.path().join("agents/old.md").exists());
916 }
917
918 #[test]
921 fn extract_agent_name() {
922 assert_eq!(
923 extract_name_from_dest(
924 &crate::types::DestPath::from("agents/coder.md"),
925 ItemKind::Agent
926 ),
927 "coder"
928 );
929 }
930
931 #[test]
932 fn extract_skill_name() {
933 assert_eq!(
934 extract_name_from_dest(
935 &crate::types::DestPath::from("skills/planning"),
936 ItemKind::Skill
937 ),
938 "planning"
939 );
940 }
941}