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