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