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