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 no_refresh_models: false,
554 };
555
556 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
557 assert_eq!(result.outcomes.len(), 1);
558
559 let outcome = &result.outcomes[0];
560 assert!(matches!(outcome.action, ActionTaken::Installed));
561
562 let installed_path = root.path().join("agents/coder.md");
564 assert!(installed_path.exists());
565 assert_eq!(fs::read(&installed_path).unwrap(), content);
566
567 assert_eq!(
569 outcome.source_checksum.as_deref(),
570 Some(hash::hash_bytes(content).as_str())
571 );
572 assert!(outcome.installed_checksum.is_some());
573 }
574
575 #[test]
576 fn install_caches_base_content() {
577 let root = TempDir::new().unwrap();
578 let source_dir = TempDir::new().unwrap();
579 let cache_dir = TempDir::new().unwrap();
580 let bases_dir = cache_dir.path().join("bases");
581
582 let content = b"# cached content";
583 let source_path = setup_source_agent(source_dir.path(), "coder", content);
584 let target = make_agent_target("coder", source_path, content);
585
586 let plan = SyncPlan {
587 actions: vec![PlannedAction::Install { target }],
588 };
589
590 let options = SyncOptions {
591 force: false,
592 dry_run: false,
593 frozen: false,
594 no_refresh_models: false,
595 };
596
597 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
598 let installed_checksum = result.outcomes[0].installed_checksum.as_ref().unwrap();
599
600 let cached = bases_dir.join(installed_checksum.as_ref());
602 assert!(cached.exists(), "base content should be cached");
603 assert_eq!(fs::read(&cached).unwrap(), content);
604 }
605
606 #[test]
609 fn overwrite_replaces_existing_file() {
610 let root = TempDir::new().unwrap();
611 let source_dir = TempDir::new().unwrap();
612 let cache_dir = TempDir::new().unwrap();
613 let bases_dir = cache_dir.path().join("bases");
614
615 let agents_dir = root.path().join("agents");
617 fs::create_dir_all(&agents_dir).unwrap();
618 fs::write(agents_dir.join("coder.md"), b"# old content").unwrap();
619
620 let new_content = b"# new content";
621 let source_path = setup_source_agent(source_dir.path(), "coder", new_content);
622 let target = make_agent_target("coder", source_path, new_content);
623
624 let plan = SyncPlan {
625 actions: vec![PlannedAction::Overwrite { target }],
626 };
627
628 let options = SyncOptions {
629 force: false,
630 dry_run: false,
631 frozen: false,
632 no_refresh_models: false,
633 };
634
635 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
636 assert!(matches!(result.outcomes[0].action, ActionTaken::Updated));
637
638 let installed = fs::read(root.path().join("agents/coder.md")).unwrap();
639 assert_eq!(installed, new_content);
640 }
641
642 #[test]
645 fn remove_deletes_file() {
646 let root = TempDir::new().unwrap();
647 let cache_dir = TempDir::new().unwrap();
648 let bases_dir = cache_dir.path().join("bases");
649
650 let agents_dir = root.path().join("agents");
652 fs::create_dir_all(&agents_dir).unwrap();
653 fs::write(agents_dir.join("orphan.md"), b"# orphan").unwrap();
654
655 let locked = LockedItem {
656 source: "old-source".into(),
657 kind: ItemKind::Agent,
658 version: None,
659 source_checksum: "sha256:aaa".into(),
660 installed_checksum: "sha256:bbb".into(),
661 dest_path: "agents/orphan.md".into(),
662 };
663
664 let plan = SyncPlan {
665 actions: vec![PlannedAction::Remove { locked }],
666 };
667
668 let options = SyncOptions {
669 force: false,
670 dry_run: false,
671 frozen: false,
672 no_refresh_models: false,
673 };
674
675 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
676 assert!(matches!(result.outcomes[0].action, ActionTaken::Removed));
677 assert!(!root.path().join("agents/orphan.md").exists());
678 }
679
680 #[test]
681 fn remove_skill_directory() {
682 let root = TempDir::new().unwrap();
683 let cache_dir = TempDir::new().unwrap();
684 let bases_dir = cache_dir.path().join("bases");
685
686 let skill_dir = root.path().join("skills/old-skill");
688 fs::create_dir_all(&skill_dir).unwrap();
689 fs::write(skill_dir.join("SKILL.md"), b"# old skill").unwrap();
690
691 let locked = LockedItem {
692 source: "old-source".into(),
693 kind: ItemKind::Skill,
694 version: None,
695 source_checksum: "sha256:aaa".into(),
696 installed_checksum: "sha256:bbb".into(),
697 dest_path: "skills/old-skill".into(),
698 };
699
700 let plan = SyncPlan {
701 actions: vec![PlannedAction::Remove { locked }],
702 };
703
704 let options = SyncOptions {
705 force: false,
706 dry_run: false,
707 frozen: false,
708 no_refresh_models: false,
709 };
710
711 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
712 assert!(matches!(result.outcomes[0].action, ActionTaken::Removed));
713 assert!(!root.path().join("skills/old-skill").exists());
714 }
715
716 #[test]
719 fn dry_run_does_not_modify_files() {
720 let root = TempDir::new().unwrap();
721 let source_dir = TempDir::new().unwrap();
722 let cache_dir = TempDir::new().unwrap();
723 let bases_dir = cache_dir.path().join("bases");
724
725 let content = b"# new agent";
726 let source_path = setup_source_agent(source_dir.path(), "coder", content);
727 let target = make_agent_target("coder", source_path, content);
728
729 let plan = SyncPlan {
730 actions: vec![PlannedAction::Install { target }],
731 };
732
733 let options = SyncOptions {
734 force: false,
735 dry_run: true,
736 frozen: false,
737 no_refresh_models: false,
738 };
739
740 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
741 assert_eq!(result.outcomes.len(), 1);
742 assert!(matches!(result.outcomes[0].action, ActionTaken::Installed));
743
744 assert!(!root.path().join("agents/coder.md").exists());
746 }
747
748 #[test]
751 fn skip_produces_skipped_outcome() {
752 let root = TempDir::new().unwrap();
753 let cache_dir = TempDir::new().unwrap();
754 let bases_dir = cache_dir.path().join("bases");
755
756 let plan = SyncPlan {
757 actions: vec![PlannedAction::Skip {
758 item_id: ItemId {
759 kind: ItemKind::Agent,
760 name: "stable".into(),
761 },
762 dest_path: "agents/stable.md".into(),
763 source_name: "base".into(),
764 reason: "unchanged",
765 }],
766 };
767
768 let options = SyncOptions {
769 force: false,
770 dry_run: false,
771 frozen: false,
772 no_refresh_models: false,
773 };
774
775 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
776 assert!(matches!(result.outcomes[0].action, ActionTaken::Skipped));
777 assert_eq!(
778 result.outcomes[0].dest_path,
779 crate::types::DestPath::from("agents/stable.md")
780 );
781 assert_eq!(result.outcomes[0].source_name, "base");
782 }
783
784 #[test]
785 fn keep_local_produces_kept_outcome() {
786 let root = TempDir::new().unwrap();
787 let cache_dir = TempDir::new().unwrap();
788 let bases_dir = cache_dir.path().join("bases");
789
790 let plan = SyncPlan {
791 actions: vec![PlannedAction::KeepLocal {
792 item_id: ItemId {
793 kind: ItemKind::Agent,
794 name: "modified".into(),
795 },
796 dest_path: "agents/modified.md".into(),
797 source_name: "base".into(),
798 }],
799 };
800
801 let options = SyncOptions {
802 force: false,
803 dry_run: false,
804 frozen: false,
805 no_refresh_models: false,
806 };
807
808 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
809 assert!(matches!(result.outcomes[0].action, ActionTaken::Kept));
810 assert_eq!(
811 result.outcomes[0].dest_path,
812 crate::types::DestPath::from("agents/modified.md")
813 );
814 assert_eq!(result.outcomes[0].source_name, "base");
815 }
816
817 #[test]
820 fn install_skill_directory() {
821 let root = TempDir::new().unwrap();
822 let source_dir = TempDir::new().unwrap();
823 let cache_dir = TempDir::new().unwrap();
824 let bases_dir = cache_dir.path().join("bases");
825
826 let source_skill = source_dir.path().join("skills/planning");
828 fs::create_dir_all(&source_skill).unwrap();
829 fs::write(source_skill.join("SKILL.md"), b"# Planning skill").unwrap();
830 fs::write(source_skill.join("helper.md"), b"# Helper").unwrap();
831
832 let skill_hash = hash::compute_hash(&source_skill, ItemKind::Skill).unwrap();
833
834 let target = TargetItem {
835 id: ItemId {
836 kind: ItemKind::Skill,
837 name: "planning".into(),
838 },
839 source_name: "test".into(),
840 origin: crate::types::SourceOrigin::Dependency("test".into()),
841 materialization: crate::types::Materialization::Copy,
842 source_id: crate::types::SourceId::Path {
843 canonical: source_skill.clone(),
844 },
845 source_path: source_skill,
846 dest_path: "skills/planning".into(),
847 source_hash: skill_hash.into(),
848 is_flat_skill: false,
849 rewritten_content: None,
850 };
851
852 let plan = SyncPlan {
853 actions: vec![PlannedAction::Install { target }],
854 };
855
856 let options = SyncOptions {
857 force: false,
858 dry_run: false,
859 frozen: false,
860 no_refresh_models: false,
861 };
862
863 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
864 assert!(matches!(result.outcomes[0].action, ActionTaken::Installed));
865
866 let installed_dir = root.path().join("skills/planning");
867 assert!(installed_dir.exists());
868 assert!(installed_dir.join("SKILL.md").exists());
869 assert!(installed_dir.join("helper.md").exists());
870 assert_eq!(
871 fs::read_to_string(installed_dir.join("SKILL.md")).unwrap(),
872 "# Planning skill"
873 );
874 }
875
876 #[test]
877 fn install_flat_skill_excludes_repo_metadata() {
878 let root = TempDir::new().unwrap();
879 let source_dir = TempDir::new().unwrap();
880 let cache_dir = TempDir::new().unwrap();
881 let bases_dir = cache_dir.path().join("bases");
882
883 let flat_source = source_dir.path().join("flat-skill");
884 fs::create_dir_all(flat_source.join(".git")).unwrap();
885 fs::create_dir_all(flat_source.join("resources")).unwrap();
886 fs::write(flat_source.join("SKILL.md"), b"# Flat skill").unwrap();
887 fs::write(flat_source.join("resources/guide.md"), b"# Guide").unwrap();
888 fs::write(flat_source.join("mars.toml"), b"[sources]").unwrap();
889 fs::write(flat_source.join(".gitignore"), b"target/").unwrap();
890 fs::write(flat_source.join(".git/config"), b"[core]").unwrap();
891
892 let source_hash = hash::compute_skill_hash_filtered(
893 &flat_source,
894 crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL,
895 )
896 .unwrap();
897
898 let target = TargetItem {
899 id: ItemId {
900 kind: ItemKind::Skill,
901 name: "flat-skill".into(),
902 },
903 source_name: "test".into(),
904 origin: crate::types::SourceOrigin::Dependency("test".into()),
905 materialization: crate::types::Materialization::Copy,
906 source_id: crate::types::SourceId::Path {
907 canonical: flat_source.clone(),
908 },
909 source_path: flat_source,
910 dest_path: "skills/flat-skill".into(),
911 source_hash: source_hash.into(),
912 is_flat_skill: true,
913 rewritten_content: None,
914 };
915
916 let plan = SyncPlan {
917 actions: vec![PlannedAction::Install { target }],
918 };
919
920 let options = SyncOptions {
921 force: false,
922 dry_run: false,
923 frozen: false,
924 no_refresh_models: false,
925 };
926
927 execute(root.path(), &plan, &options, &bases_dir).unwrap();
928
929 let installed = root.path().join("skills/flat-skill");
930 assert!(installed.join("SKILL.md").exists());
931 assert!(installed.join("resources/guide.md").exists());
932 assert!(!installed.join(".git").exists());
933 assert!(!installed.join("mars.toml").exists());
934 assert!(!installed.join(".gitignore").exists());
935 }
936
937 #[test]
940 fn prune_removes_orphaned_items() {
941 let root = TempDir::new().unwrap();
942
943 let agents_dir = root.path().join("agents");
945 fs::create_dir_all(&agents_dir).unwrap();
946 fs::write(agents_dir.join("old.md"), b"# orphan").unwrap();
947
948 let mut lock_items = indexmap::IndexMap::new();
949 lock_items.insert(
950 "agents/old.md".into(),
951 LockedItem {
952 source: "old-source".into(),
953 kind: ItemKind::Agent,
954 version: None,
955 source_checksum: "sha256:aaa".into(),
956 installed_checksum: "sha256:bbb".into(),
957 dest_path: "agents/old.md".into(),
958 },
959 );
960 let lock = crate::lock::LockFile {
961 version: 1,
962 dependencies: indexmap::IndexMap::new(),
963 items: lock_items,
964 };
965
966 let target = crate::sync::target::TargetState {
968 items: indexmap::IndexMap::new(),
969 };
970
971 let outcomes = prune_orphans(root.path(), &lock, &target).unwrap();
972 assert_eq!(outcomes.len(), 1);
973 assert!(matches!(outcomes[0].action, ActionTaken::Removed));
974 assert!(!root.path().join("agents/old.md").exists());
975 }
976
977 #[test]
980 fn extract_agent_name() {
981 assert_eq!(
982 extract_name_from_dest(
983 &crate::types::DestPath::from("agents/coder.md"),
984 ItemKind::Agent
985 ),
986 "coder"
987 );
988 }
989
990 #[test]
991 fn extract_skill_name() {
992 assert_eq!(
993 extract_name_from_dest(
994 &crate::types::DestPath::from("skills/planning"),
995 ItemKind::Skill
996 ),
997 "planning"
998 );
999 }
1000}