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 = root.join(&target.dest_path);
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 = root.join(&target.dest_path);
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 = root.join(&target.dest_path);
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 = root.join(&locked.dest_path);
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 cache_path = cache_bases_dir.join(installed_checksum.as_ref());
381
382 if cache_path.exists() {
384 return Ok(());
385 }
386
387 match kind {
388 ItemKind::Agent => {
389 let content = std::fs::read(dest)?;
390 fs_ops::atomic_write_file(&cache_path, &content)?;
391 }
392 ItemKind::Skill => {
393 let skill_md = dest.join("SKILL.md");
395 if skill_md.exists() {
396 let content = std::fs::read(&skill_md)?;
397 fs_ops::atomic_write_file(&cache_path, &content)?;
398 }
399 }
400 }
401
402 Ok(())
403}
404
405fn extract_name_from_dest(dest_path: &DestPath, kind: ItemKind) -> String {
407 let path = dest_path.as_path();
408 match kind {
409 ItemKind::Agent => path
410 .file_stem()
411 .map(|s| s.to_string_lossy().to_string())
412 .unwrap_or_default(),
413 ItemKind::Skill => path
414 .file_name()
415 .map(|s| s.to_string_lossy().to_string())
416 .unwrap_or_default(),
417 }
418}
419
420pub fn prune_orphans(
425 root: &Path,
426 lock: &crate::lock::LockFile,
427 target: &crate::sync::target::TargetState,
428) -> Result<Vec<ActionOutcome>, MarsError> {
429 let mut outcomes = Vec::new();
430
431 for (dest_path_str, locked_item) in &lock.items {
432 if !target.items.contains_key(dest_path_str) {
433 let dest = root.join(dest_path_str);
434 if dest.exists() {
435 fs_ops::safe_remove(&dest)?;
436 }
437 outcomes.push(ActionOutcome {
438 item_id: ItemId {
439 kind: locked_item.kind,
440 name: ItemName::from(extract_name_from_dest(dest_path_str, locked_item.kind)),
441 },
442 action: ActionTaken::Removed,
443 dest_path: dest_path_str.clone(),
444 source_name: locked_item.source.clone(),
445 source_checksum: None,
446 installed_checksum: None,
447 });
448 }
449 }
450
451 Ok(outcomes)
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457 use crate::hash;
458 use crate::lock::{ItemId, ItemKind, LockedItem};
459 use crate::sync::plan::{PlannedAction, SyncPlan};
460 use crate::sync::target::TargetItem;
461 use std::fs;
462 use std::path::PathBuf;
463 use tempfile::TempDir;
464
465 fn make_agent_target(name: &str, source_path: PathBuf, content: &[u8]) -> TargetItem {
466 TargetItem {
467 id: ItemId {
468 kind: ItemKind::Agent,
469 name: name.into(),
470 },
471 source_name: "test-source".into(),
472 origin: crate::types::SourceOrigin::Dependency("test-source".into()),
473 source_id: crate::types::SourceId::Path {
474 canonical: source_path.clone(),
475 subpath: None,
476 },
477 source_path,
478 dest_path: format!("agents/{name}.md").into(),
479 source_hash: hash::hash_bytes(content).into(),
480 is_flat_skill: false,
481 rewritten_content: None,
482 }
483 }
484
485 fn setup_source_agent(dir: &Path, name: &str, content: &[u8]) -> PathBuf {
486 let agents_dir = dir.join("source").join("agents");
487 fs::create_dir_all(&agents_dir).unwrap();
488 let path = agents_dir.join(format!("{name}.md"));
489 fs::write(&path, content).unwrap();
490 path
491 }
492
493 #[test]
496 fn install_creates_new_file() {
497 let root = TempDir::new().unwrap();
498 let source_dir = TempDir::new().unwrap();
499 let cache_dir = TempDir::new().unwrap();
500 let bases_dir = cache_dir.path().join("bases");
501
502 let content = b"# new agent content";
503 let source_path = setup_source_agent(source_dir.path(), "coder", content);
504 let target = make_agent_target("coder", source_path, content);
505
506 let plan = SyncPlan {
507 actions: vec![PlannedAction::Install {
508 target: target.clone(),
509 }],
510 };
511
512 let options = SyncOptions {
513 force: false,
514 dry_run: false,
515 frozen: false,
516 no_refresh_models: false,
517 };
518
519 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
520 assert_eq!(result.outcomes.len(), 1);
521
522 let outcome = &result.outcomes[0];
523 assert!(matches!(outcome.action, ActionTaken::Installed));
524
525 let installed_path = root.path().join("agents/coder.md");
527 assert!(installed_path.exists());
528 assert_eq!(fs::read(&installed_path).unwrap(), content);
529
530 assert_eq!(
532 outcome.source_checksum.as_deref(),
533 Some(hash::hash_bytes(content).as_str())
534 );
535 assert!(outcome.installed_checksum.is_some());
536 }
537
538 #[test]
539 fn install_caches_base_content() {
540 let root = TempDir::new().unwrap();
541 let source_dir = TempDir::new().unwrap();
542 let cache_dir = TempDir::new().unwrap();
543 let bases_dir = cache_dir.path().join("bases");
544
545 let content = b"# cached content";
546 let source_path = setup_source_agent(source_dir.path(), "coder", content);
547 let target = make_agent_target("coder", source_path, content);
548
549 let plan = SyncPlan {
550 actions: vec![PlannedAction::Install { target }],
551 };
552
553 let options = SyncOptions {
554 force: false,
555 dry_run: false,
556 frozen: false,
557 no_refresh_models: false,
558 };
559
560 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
561 let installed_checksum = result.outcomes[0].installed_checksum.as_ref().unwrap();
562
563 let cached = bases_dir.join(installed_checksum.as_ref());
565 assert!(cached.exists(), "base content should be cached");
566 assert_eq!(fs::read(&cached).unwrap(), content);
567 }
568
569 #[test]
572 fn overwrite_replaces_existing_file() {
573 let root = TempDir::new().unwrap();
574 let source_dir = TempDir::new().unwrap();
575 let cache_dir = TempDir::new().unwrap();
576 let bases_dir = cache_dir.path().join("bases");
577
578 let agents_dir = root.path().join("agents");
580 fs::create_dir_all(&agents_dir).unwrap();
581 fs::write(agents_dir.join("coder.md"), b"# old content").unwrap();
582
583 let new_content = b"# new content";
584 let source_path = setup_source_agent(source_dir.path(), "coder", new_content);
585 let target = make_agent_target("coder", source_path, new_content);
586
587 let plan = SyncPlan {
588 actions: vec![PlannedAction::Overwrite { target }],
589 };
590
591 let options = SyncOptions {
592 force: false,
593 dry_run: false,
594 frozen: false,
595 no_refresh_models: false,
596 };
597
598 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
599 assert!(matches!(result.outcomes[0].action, ActionTaken::Updated));
600
601 let installed = fs::read(root.path().join("agents/coder.md")).unwrap();
602 assert_eq!(installed, new_content);
603 }
604
605 #[test]
608 fn remove_deletes_file() {
609 let root = 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("orphan.md"), b"# orphan").unwrap();
617
618 let locked = LockedItem {
619 source: "old-source".into(),
620 kind: ItemKind::Agent,
621 version: None,
622 source_checksum: "sha256:aaa".into(),
623 installed_checksum: "sha256:bbb".into(),
624 dest_path: "agents/orphan.md".into(),
625 };
626
627 let plan = SyncPlan {
628 actions: vec![PlannedAction::Remove { locked }],
629 };
630
631 let options = SyncOptions {
632 force: false,
633 dry_run: false,
634 frozen: false,
635 no_refresh_models: false,
636 };
637
638 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
639 assert!(matches!(result.outcomes[0].action, ActionTaken::Removed));
640 assert!(!root.path().join("agents/orphan.md").exists());
641 }
642
643 #[test]
644 fn remove_skill_directory() {
645 let root = TempDir::new().unwrap();
646 let cache_dir = TempDir::new().unwrap();
647 let bases_dir = cache_dir.path().join("bases");
648
649 let skill_dir = root.path().join("skills/old-skill");
651 fs::create_dir_all(&skill_dir).unwrap();
652 fs::write(skill_dir.join("SKILL.md"), b"# old skill").unwrap();
653
654 let locked = LockedItem {
655 source: "old-source".into(),
656 kind: ItemKind::Skill,
657 version: None,
658 source_checksum: "sha256:aaa".into(),
659 installed_checksum: "sha256:bbb".into(),
660 dest_path: "skills/old-skill".into(),
661 };
662
663 let plan = SyncPlan {
664 actions: vec![PlannedAction::Remove { locked }],
665 };
666
667 let options = SyncOptions {
668 force: false,
669 dry_run: false,
670 frozen: false,
671 no_refresh_models: false,
672 };
673
674 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
675 assert!(matches!(result.outcomes[0].action, ActionTaken::Removed));
676 assert!(!root.path().join("skills/old-skill").exists());
677 }
678
679 #[test]
682 fn dry_run_does_not_modify_files() {
683 let root = TempDir::new().unwrap();
684 let source_dir = TempDir::new().unwrap();
685 let cache_dir = TempDir::new().unwrap();
686 let bases_dir = cache_dir.path().join("bases");
687
688 let content = b"# new agent";
689 let source_path = setup_source_agent(source_dir.path(), "coder", content);
690 let target = make_agent_target("coder", source_path, content);
691
692 let plan = SyncPlan {
693 actions: vec![PlannedAction::Install { target }],
694 };
695
696 let options = SyncOptions {
697 force: false,
698 dry_run: true,
699 frozen: false,
700 no_refresh_models: false,
701 };
702
703 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
704 assert_eq!(result.outcomes.len(), 1);
705 assert!(matches!(result.outcomes[0].action, ActionTaken::Installed));
706
707 assert!(!root.path().join("agents/coder.md").exists());
709 }
710
711 #[test]
714 fn skip_produces_skipped_outcome() {
715 let root = TempDir::new().unwrap();
716 let cache_dir = TempDir::new().unwrap();
717 let bases_dir = cache_dir.path().join("bases");
718
719 let plan = SyncPlan {
720 actions: vec![PlannedAction::Skip {
721 item_id: ItemId {
722 kind: ItemKind::Agent,
723 name: "stable".into(),
724 },
725 dest_path: "agents/stable.md".into(),
726 source_name: "base".into(),
727 installed_checksum: Some("sha256:stable".into()),
728 reason: "unchanged",
729 }],
730 };
731
732 let options = SyncOptions {
733 force: false,
734 dry_run: false,
735 frozen: false,
736 no_refresh_models: false,
737 };
738
739 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
740 assert!(matches!(result.outcomes[0].action, ActionTaken::Skipped));
741 assert_eq!(
742 result.outcomes[0].dest_path,
743 crate::types::DestPath::from("agents/stable.md")
744 );
745 assert_eq!(result.outcomes[0].source_name, "base");
746 assert_eq!(
747 result.outcomes[0].installed_checksum.as_deref(),
748 Some("sha256:stable")
749 );
750 }
751
752 #[test]
753 fn keep_local_produces_kept_outcome() {
754 let root = TempDir::new().unwrap();
755 let cache_dir = TempDir::new().unwrap();
756 let bases_dir = cache_dir.path().join("bases");
757
758 let plan = SyncPlan {
759 actions: vec![PlannedAction::KeepLocal {
760 item_id: ItemId {
761 kind: ItemKind::Agent,
762 name: "modified".into(),
763 },
764 dest_path: "agents/modified.md".into(),
765 source_name: "base".into(),
766 }],
767 };
768
769 let options = SyncOptions {
770 force: false,
771 dry_run: false,
772 frozen: false,
773 no_refresh_models: false,
774 };
775
776 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
777 assert!(matches!(result.outcomes[0].action, ActionTaken::Kept));
778 assert_eq!(
779 result.outcomes[0].dest_path,
780 crate::types::DestPath::from("agents/modified.md")
781 );
782 assert_eq!(result.outcomes[0].source_name, "base");
783 }
784
785 #[test]
788 fn install_skill_directory() {
789 let root = TempDir::new().unwrap();
790 let source_dir = TempDir::new().unwrap();
791 let cache_dir = TempDir::new().unwrap();
792 let bases_dir = cache_dir.path().join("bases");
793
794 let source_skill = source_dir.path().join("skills/planning");
796 fs::create_dir_all(&source_skill).unwrap();
797 fs::write(source_skill.join("SKILL.md"), b"# Planning skill").unwrap();
798 fs::write(source_skill.join("helper.md"), b"# Helper").unwrap();
799
800 let skill_hash = hash::compute_hash(&source_skill, ItemKind::Skill).unwrap();
801
802 let target = TargetItem {
803 id: ItemId {
804 kind: ItemKind::Skill,
805 name: "planning".into(),
806 },
807 source_name: "test".into(),
808 origin: crate::types::SourceOrigin::Dependency("test".into()),
809 source_id: crate::types::SourceId::Path {
810 canonical: source_skill.clone(),
811 subpath: None,
812 },
813 source_path: source_skill,
814 dest_path: "skills/planning".into(),
815 source_hash: skill_hash.into(),
816 is_flat_skill: false,
817 rewritten_content: None,
818 };
819
820 let plan = SyncPlan {
821 actions: vec![PlannedAction::Install { target }],
822 };
823
824 let options = SyncOptions {
825 force: false,
826 dry_run: false,
827 frozen: false,
828 no_refresh_models: false,
829 };
830
831 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
832 assert!(matches!(result.outcomes[0].action, ActionTaken::Installed));
833
834 let installed_dir = root.path().join("skills/planning");
835 assert!(installed_dir.exists());
836 assert!(installed_dir.join("SKILL.md").exists());
837 assert!(installed_dir.join("helper.md").exists());
838 assert_eq!(
839 fs::read_to_string(installed_dir.join("SKILL.md")).unwrap(),
840 "# Planning skill"
841 );
842 }
843
844 #[test]
845 fn install_flat_skill_excludes_repo_metadata() {
846 let root = TempDir::new().unwrap();
847 let source_dir = TempDir::new().unwrap();
848 let cache_dir = TempDir::new().unwrap();
849 let bases_dir = cache_dir.path().join("bases");
850
851 let flat_source = source_dir.path().join("flat-skill");
852 fs::create_dir_all(flat_source.join(".git")).unwrap();
853 fs::create_dir_all(flat_source.join("resources")).unwrap();
854 fs::write(flat_source.join("SKILL.md"), b"# Flat skill").unwrap();
855 fs::write(flat_source.join("resources/guide.md"), b"# Guide").unwrap();
856 fs::write(flat_source.join("mars.toml"), b"[sources]").unwrap();
857 fs::write(flat_source.join(".gitignore"), b"target/").unwrap();
858 fs::write(flat_source.join(".git/config"), b"[core]").unwrap();
859
860 let source_hash = hash::compute_skill_hash_filtered(
861 &flat_source,
862 crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL,
863 )
864 .unwrap();
865
866 let target = TargetItem {
867 id: ItemId {
868 kind: ItemKind::Skill,
869 name: "flat-skill".into(),
870 },
871 source_name: "test".into(),
872 origin: crate::types::SourceOrigin::Dependency("test".into()),
873 source_id: crate::types::SourceId::Path {
874 canonical: flat_source.clone(),
875 subpath: None,
876 },
877 source_path: flat_source,
878 dest_path: "skills/flat-skill".into(),
879 source_hash: source_hash.into(),
880 is_flat_skill: true,
881 rewritten_content: None,
882 };
883
884 let plan = SyncPlan {
885 actions: vec![PlannedAction::Install { target }],
886 };
887
888 let options = SyncOptions {
889 force: false,
890 dry_run: false,
891 frozen: false,
892 no_refresh_models: false,
893 };
894
895 execute(root.path(), &plan, &options, &bases_dir).unwrap();
896
897 let installed = root.path().join("skills/flat-skill");
898 assert!(installed.join("SKILL.md").exists());
899 assert!(installed.join("resources/guide.md").exists());
900 assert!(!installed.join(".git").exists());
901 assert!(!installed.join("mars.toml").exists());
902 assert!(!installed.join(".gitignore").exists());
903 }
904
905 #[test]
908 fn prune_removes_orphaned_items() {
909 let root = TempDir::new().unwrap();
910
911 let agents_dir = root.path().join("agents");
913 fs::create_dir_all(&agents_dir).unwrap();
914 fs::write(agents_dir.join("old.md"), b"# orphan").unwrap();
915
916 let mut lock_items = indexmap::IndexMap::new();
917 lock_items.insert(
918 "agents/old.md".into(),
919 LockedItem {
920 source: "old-source".into(),
921 kind: ItemKind::Agent,
922 version: None,
923 source_checksum: "sha256:aaa".into(),
924 installed_checksum: "sha256:bbb".into(),
925 dest_path: "agents/old.md".into(),
926 },
927 );
928 let lock = crate::lock::LockFile {
929 version: 1,
930 dependencies: indexmap::IndexMap::new(),
931 items: lock_items,
932 };
933
934 let target = crate::sync::target::TargetState {
936 items: indexmap::IndexMap::new(),
937 };
938
939 let outcomes = prune_orphans(root.path(), &lock, &target).unwrap();
940 assert_eq!(outcomes.len(), 1);
941 assert!(matches!(outcomes[0].action, ActionTaken::Removed));
942 assert!(!root.path().join("agents/old.md").exists());
943 }
944
945 #[test]
948 fn extract_agent_name() {
949 assert_eq!(
950 extract_name_from_dest(
951 &crate::types::DestPath::from("agents/coder.md"),
952 ItemKind::Agent
953 ),
954 "coder"
955 );
956 }
957
958 #[test]
959 fn extract_skill_name() {
960 assert_eq!(
961 extract_name_from_dest(
962 &crate::types::DestPath::from("skills/planning"),
963 ItemKind::Skill
964 ),
965 "planning"
966 );
967 }
968}