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 {
123 target,
124 base_content,
125 local_path,
126 } => {
127 let dest = target.dest_path.resolve(root);
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 let installed_checksum = write_file_and_verify(&dest, &merge_result.content)?;
152
153 cache_base_content(cache_bases_dir, &installed_checksum, &dest, target.id.kind)?;
155
156 let action_taken = if merge_result.has_conflicts {
157 ActionTaken::Conflicted
158 } else {
159 ActionTaken::Merged
160 };
161
162 Ok(ActionOutcome {
163 item_id: target.id.clone(),
164 action: action_taken,
165 dest_path: target.dest_path.clone(),
166 source_name: target.source_name.clone(),
167 source_checksum: Some(target.source_hash.clone()),
168 installed_checksum: Some(installed_checksum),
169 })
170 }
171
172 PlannedAction::Remove { locked } => {
173 let dest = removal_path(root, &locked.dest_path, locked.kind);
174 if dest.exists() {
175 fs_ops::safe_remove(&dest)?;
176 }
177
178 let item_id = ItemId {
179 kind: locked.kind,
180 name: ItemName::from(locked.dest_path.item_name(locked.kind)),
181 };
182
183 Ok(ActionOutcome {
184 item_id,
185 action: ActionTaken::Removed,
186 dest_path: locked.dest_path.clone(),
187 source_name: locked.source.clone(),
188 source_checksum: None,
189 installed_checksum: None,
190 })
191 }
192
193 PlannedAction::Skip {
194 item_id,
195 dest_path,
196 source_name,
197 installed_checksum,
198 reason: _,
199 } => Ok(ActionOutcome {
200 item_id: item_id.clone(),
201 action: ActionTaken::Skipped,
202 dest_path: dest_path.clone(),
203 source_name: source_name.clone(),
204 source_checksum: None,
205 installed_checksum: installed_checksum.clone(),
206 }),
207
208 PlannedAction::KeepLocal {
209 item_id,
210 dest_path,
211 source_name,
212 } => Ok(ActionOutcome {
213 item_id: item_id.clone(),
214 action: ActionTaken::Kept,
215 dest_path: dest_path.clone(),
216 source_name: source_name.clone(),
217 source_checksum: None,
218 installed_checksum: None,
219 }),
220 }
221}
222
223fn dry_run_action(action: &PlannedAction) -> ActionOutcome {
225 match action {
226 PlannedAction::Install { target } => ActionOutcome {
227 item_id: target.id.clone(),
228 action: ActionTaken::Installed,
229 dest_path: target.dest_path.clone(),
230 source_name: target.source_name.clone(),
231 source_checksum: Some(target.source_hash.clone()),
232 installed_checksum: None, },
234 PlannedAction::Overwrite { target } => ActionOutcome {
235 item_id: target.id.clone(),
236 action: ActionTaken::Updated,
237 dest_path: target.dest_path.clone(),
238 source_name: target.source_name.clone(),
239 source_checksum: Some(target.source_hash.clone()),
240 installed_checksum: None,
241 },
242 PlannedAction::Merge { target, .. } => ActionOutcome {
243 item_id: target.id.clone(),
244 action: ActionTaken::Merged,
245 dest_path: target.dest_path.clone(),
246 source_name: target.source_name.clone(),
247 source_checksum: Some(target.source_hash.clone()),
248 installed_checksum: None,
249 },
250 PlannedAction::Remove { locked } => {
251 let item_id = ItemId {
252 kind: locked.kind,
253 name: ItemName::from(locked.dest_path.item_name(locked.kind)),
254 };
255 ActionOutcome {
256 item_id,
257 action: ActionTaken::Removed,
258 dest_path: locked.dest_path.clone(),
259 source_name: locked.source.clone(),
260 source_checksum: None,
261 installed_checksum: None,
262 }
263 }
264 PlannedAction::Skip {
265 item_id,
266 dest_path,
267 source_name,
268 installed_checksum,
269 ..
270 } => ActionOutcome {
271 item_id: item_id.clone(),
272 action: ActionTaken::Skipped,
273 dest_path: dest_path.clone(),
274 source_name: source_name.clone(),
275 source_checksum: None,
276 installed_checksum: installed_checksum.clone(),
277 },
278 PlannedAction::KeepLocal {
279 item_id,
280 dest_path,
281 source_name,
282 } => ActionOutcome {
283 item_id: item_id.clone(),
284 action: ActionTaken::Kept,
285 dest_path: dest_path.clone(),
286 source_name: source_name.clone(),
287 source_checksum: None,
288 installed_checksum: None,
289 },
290 }
291}
292
293fn install_item(target: &TargetItem, dest: &Path) -> Result<ContentHash, MarsError> {
297 match target.id.kind {
298 ItemKind::Agent | ItemKind::Hook | ItemKind::McpServer => {
299 let content = content_to_install(target)?;
300 write_file_and_verify(dest, &content)
301 }
302 ItemKind::BootstrapDoc => {
303 let doc_dest = dest.parent().ok_or_else(|| {
304 std::io::Error::other(format!(
305 "bootstrap destination has no parent directory: {}",
306 dest.display()
307 ))
308 })?;
309 fs_ops::atomic_install_dir(&target.source_path, doc_dest)?;
310 crate::hash::compute_hash(doc_dest, ItemKind::BootstrapDoc).map(ContentHash::from)
311 }
312 ItemKind::Skill => {
313 if target.is_flat_skill {
314 crate::fs::atomic_install_dir_filtered(
315 &target.source_path,
316 dest,
317 crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL,
318 )?;
319 } else {
320 fs_ops::atomic_install_dir(&target.source_path, dest)?;
321 }
322 crate::hash::compute_hash(dest, ItemKind::Skill).map(ContentHash::from)
324 }
325 }
326}
327
328fn write_file_and_verify(dest: &Path, content: &[u8]) -> Result<ContentHash, MarsError> {
330 fs_ops::atomic_write_file(dest, content)?;
331 let expected = ContentHash::from(crate::hash::hash_bytes(content));
332 let persisted = std::fs::read(dest)?;
333 let actual = ContentHash::from(crate::hash::hash_bytes(&persisted));
334 if expected != actual {
335 return Err(std::io::Error::other(format!(
336 "post-write verification failed for {}: expected {expected}, got {actual}",
337 dest.display()
338 ))
339 .into());
340 }
341 Ok(actual)
342}
343
344fn content_to_install(target: &TargetItem) -> Result<Vec<u8>, MarsError> {
346 if let Some(content) = &target.rewritten_content {
347 Ok(content.as_bytes().to_vec())
348 } else if target.id.kind == ItemKind::BootstrapDoc {
349 Ok(std::fs::read(target.source_path.join("BOOTSTRAP.md"))?)
350 } else {
351 Ok(std::fs::read(&target.source_path)?)
352 }
353}
354
355fn read_target_content_for_merge(target: &TargetItem) -> Result<Vec<u8>, MarsError> {
357 match target.id.kind {
358 ItemKind::Agent | ItemKind::Hook | ItemKind::McpServer | ItemKind::BootstrapDoc => {
359 content_to_install(target)
360 }
361 ItemKind::Skill => read_item_content(&target.source_path, target.id.kind),
362 }
363}
364
365fn read_item_content(path: &Path, kind: ItemKind) -> Result<Vec<u8>, MarsError> {
370 match kind {
371 ItemKind::Agent | ItemKind::Hook | ItemKind::McpServer => Ok(std::fs::read(path)?),
372 ItemKind::BootstrapDoc => Ok(std::fs::read(path.join("BOOTSTRAP.md"))?),
373 ItemKind::Skill => {
374 let skill_md = path.join("SKILL.md");
376 if skill_md.exists() {
377 Ok(std::fs::read(&skill_md)?)
378 } else {
379 Ok(Vec::new())
380 }
381 }
382 }
383}
384
385fn cache_base_content(
390 cache_bases_dir: &Path,
391 installed_checksum: &ContentHash,
392 dest: &Path,
393 kind: ItemKind,
394) -> Result<(), MarsError> {
395 std::fs::create_dir_all(cache_bases_dir)?;
396 let safe_filename = installed_checksum.as_ref().replace(':', "_");
398 let cache_path = cache_bases_dir.join(&safe_filename);
399
400 if cache_path.exists() {
402 return Ok(());
403 }
404
405 match kind {
406 ItemKind::Agent | ItemKind::Hook | ItemKind::McpServer => {
407 let content = std::fs::read(dest)?;
408 fs_ops::atomic_write_file(&cache_path, &content)?;
409 }
410 ItemKind::BootstrapDoc => {
411 let content = std::fs::read(dest)?;
412 fs_ops::atomic_write_file(&cache_path, &content)?;
413 }
414 ItemKind::Skill => {
415 let skill_md = dest.join("SKILL.md");
417 if skill_md.exists() {
418 let content = std::fs::read(&skill_md)?;
419 fs_ops::atomic_write_file(&cache_path, &content)?;
420 }
421 }
422 }
423
424 Ok(())
425}
426
427pub fn prune_orphans(
432 root: &Path,
433 lock: &crate::lock::LockFile,
434 target: &crate::sync::target::TargetState,
435) -> Result<Vec<ActionOutcome>, MarsError> {
436 let mut outcomes = Vec::new();
437
438 for (dest_path_str, locked_item) in lock.flat_items() {
439 if !target.items.contains_key(&dest_path_str) {
440 let dest = removal_path(root, &dest_path_str, locked_item.kind);
441 if dest.exists() {
442 fs_ops::safe_remove(&dest)?;
443 }
444 outcomes.push(ActionOutcome {
445 item_id: ItemId {
446 kind: locked_item.kind,
447 name: ItemName::from(dest_path_str.item_name(locked_item.kind)),
448 },
449 action: ActionTaken::Removed,
450 dest_path: dest_path_str,
451 source_name: locked_item.source,
452 source_checksum: None,
453 installed_checksum: None,
454 });
455 }
456 }
457
458 Ok(outcomes)
459}
460
461fn removal_path(root: &Path, dest_path: &DestPath, kind: ItemKind) -> std::path::PathBuf {
462 let dest = dest_path.resolve(root);
463 if kind == ItemKind::BootstrapDoc {
464 if dest_path.as_str().split('/').count() >= 3 {
465 dest.parent()
466 .map(Path::to_path_buf)
467 .unwrap_or_else(|| dest.clone())
468 } else {
469 dest
470 }
471 } else {
472 dest
473 }
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479 use crate::hash;
480 use crate::lock::{ItemId, ItemKind, LockedItem};
481 use crate::sync::plan::{PlannedAction, SyncPlan};
482 use crate::sync::target::TargetItem;
483 use std::fs;
484 use std::path::PathBuf;
485 use tempfile::TempDir;
486
487 fn make_agent_target(name: &str, source_path: PathBuf, content: &[u8]) -> TargetItem {
488 TargetItem {
489 id: ItemId {
490 kind: ItemKind::Agent,
491 name: name.into(),
492 },
493 source_name: "test-source".into(),
494 origin: crate::types::SourceOrigin::Dependency("test-source".into()),
495 source_id: crate::types::SourceId::Path {
496 canonical: source_path.clone(),
497 subpath: None,
498 },
499 source_path,
500 dest_path: format!("agents/{name}.md").into(),
501 source_hash: hash::hash_bytes(content).into(),
502 is_flat_skill: false,
503 rewritten_content: None,
504 }
505 }
506
507 fn make_bootstrap_target(name: &str, source_path: PathBuf) -> TargetItem {
508 TargetItem {
509 id: ItemId {
510 kind: ItemKind::BootstrapDoc,
511 name: name.into(),
512 },
513 source_name: "test-source".into(),
514 origin: crate::types::SourceOrigin::Dependency("test-source".into()),
515 source_id: crate::types::SourceId::Path {
516 canonical: source_path.clone(),
517 subpath: None,
518 },
519 source_hash: crate::hash::compute_hash(&source_path, ItemKind::BootstrapDoc)
520 .unwrap()
521 .into(),
522 source_path,
523 dest_path: format!("bootstrap/{name}/BOOTSTRAP.md").into(),
524 is_flat_skill: false,
525 rewritten_content: None,
526 }
527 }
528
529 fn setup_source_agent(dir: &Path, name: &str, content: &[u8]) -> PathBuf {
530 let agents_dir = dir.join("source").join("agents");
531 fs::create_dir_all(&agents_dir).unwrap();
532 let path = agents_dir.join(format!("{name}.md"));
533 fs::write(&path, content).unwrap();
534 path
535 }
536
537 #[test]
540 fn install_creates_new_file() {
541 let root = TempDir::new().unwrap();
542 let source_dir = TempDir::new().unwrap();
543 let cache_dir = TempDir::new().unwrap();
544 let bases_dir = cache_dir.path().join("bases");
545
546 let content = b"# new agent content";
547 let source_path = setup_source_agent(source_dir.path(), "coder", content);
548 let target = make_agent_target("coder", source_path, content);
549
550 let plan = SyncPlan {
551 actions: vec![PlannedAction::Install {
552 target: target.clone(),
553 }],
554 };
555
556 let options = SyncOptions {
557 force: false,
558 dry_run: false,
559 frozen: false,
560 no_refresh_models: false,
561 };
562
563 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
564 assert_eq!(result.outcomes.len(), 1);
565
566 let outcome = &result.outcomes[0];
567 assert!(matches!(outcome.action, ActionTaken::Installed));
568
569 let installed_path = root.path().join("agents/coder.md");
571 assert!(installed_path.exists());
572 assert_eq!(fs::read(&installed_path).unwrap(), content);
573
574 assert_eq!(
576 outcome.source_checksum.as_deref(),
577 Some(hash::hash_bytes(content).as_str())
578 );
579 assert!(outcome.installed_checksum.is_some());
580 }
581
582 #[test]
583 fn install_caches_base_content() {
584 let root = TempDir::new().unwrap();
585 let source_dir = TempDir::new().unwrap();
586 let cache_dir = TempDir::new().unwrap();
587 let bases_dir = cache_dir.path().join("bases");
588
589 let content = b"# cached content";
590 let source_path = setup_source_agent(source_dir.path(), "coder", content);
591 let target = make_agent_target("coder", source_path, content);
592
593 let plan = SyncPlan {
594 actions: vec![PlannedAction::Install { target }],
595 };
596
597 let options = SyncOptions {
598 force: false,
599 dry_run: false,
600 frozen: false,
601 no_refresh_models: false,
602 };
603
604 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
605 let installed_checksum = result.outcomes[0].installed_checksum.as_ref().unwrap();
606
607 let cached = bases_dir.join(installed_checksum.as_ref().replace(':', "_"));
609 assert!(cached.exists(), "base content should be cached");
610 assert_eq!(fs::read(&cached).unwrap(), content);
611 }
612
613 #[test]
616 fn overwrite_replaces_existing_file() {
617 let root = TempDir::new().unwrap();
618 let source_dir = TempDir::new().unwrap();
619 let cache_dir = TempDir::new().unwrap();
620 let bases_dir = cache_dir.path().join("bases");
621
622 let agents_dir = root.path().join("agents");
624 fs::create_dir_all(&agents_dir).unwrap();
625 fs::write(agents_dir.join("coder.md"), b"# old content").unwrap();
626
627 let new_content = b"# new content";
628 let source_path = setup_source_agent(source_dir.path(), "coder", new_content);
629 let target = make_agent_target("coder", source_path, new_content);
630
631 let plan = SyncPlan {
632 actions: vec![PlannedAction::Overwrite { target }],
633 };
634
635 let options = SyncOptions {
636 force: false,
637 dry_run: false,
638 frozen: false,
639 no_refresh_models: false,
640 };
641
642 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
643 assert!(matches!(result.outcomes[0].action, ActionTaken::Updated));
644
645 let installed = fs::read(root.path().join("agents/coder.md")).unwrap();
646 assert_eq!(installed, new_content);
647 }
648
649 #[test]
650 fn install_bootstrap_doc_directory_to_canonical_file_path() {
651 let root = TempDir::new().unwrap();
652 let source_dir = TempDir::new().unwrap();
653 let cache_dir = TempDir::new().unwrap();
654 let bases_dir = cache_dir.path().join("bases");
655 let bootstrap_dir = source_dir.path().join("bootstrap/global-auth");
656 fs::create_dir_all(&bootstrap_dir).unwrap();
657 fs::write(bootstrap_dir.join("BOOTSTRAP.md"), b"# auth").unwrap();
658
659 let target = make_bootstrap_target("global-auth", bootstrap_dir);
660 let plan = SyncPlan {
661 actions: vec![PlannedAction::Install { target }],
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
672 assert!(matches!(result.outcomes[0].action, ActionTaken::Installed));
673 assert_eq!(
674 fs::read(root.path().join("bootstrap/global-auth/BOOTSTRAP.md")).unwrap(),
675 b"# auth"
676 );
677 }
678
679 #[test]
682 fn remove_deletes_file() {
683 let root = TempDir::new().unwrap();
684 let cache_dir = TempDir::new().unwrap();
685 let bases_dir = cache_dir.path().join("bases");
686
687 let agents_dir = root.path().join("agents");
689 fs::create_dir_all(&agents_dir).unwrap();
690 fs::write(agents_dir.join("orphan.md"), b"# orphan").unwrap();
691
692 let locked = LockedItem {
693 source: "old-source".into(),
694 kind: ItemKind::Agent,
695 version: None,
696 source_checksum: "sha256:aaa".into(),
697 installed_checksum: "sha256:bbb".into(),
698 dest_path: "agents/orphan.md".into(),
699 };
700
701 let plan = SyncPlan {
702 actions: vec![PlannedAction::Remove { locked }],
703 };
704
705 let options = SyncOptions {
706 force: false,
707 dry_run: false,
708 frozen: false,
709 no_refresh_models: false,
710 };
711
712 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
713 assert!(matches!(result.outcomes[0].action, ActionTaken::Removed));
714 assert!(!root.path().join("agents/orphan.md").exists());
715 }
716
717 #[test]
718 fn remove_skill_directory() {
719 let root = TempDir::new().unwrap();
720 let cache_dir = TempDir::new().unwrap();
721 let bases_dir = cache_dir.path().join("bases");
722
723 let skill_dir = root.path().join("skills/old-skill");
725 fs::create_dir_all(&skill_dir).unwrap();
726 fs::write(skill_dir.join("SKILL.md"), b"# old skill").unwrap();
727
728 let locked = LockedItem {
729 source: "old-source".into(),
730 kind: ItemKind::Skill,
731 version: None,
732 source_checksum: "sha256:aaa".into(),
733 installed_checksum: "sha256:bbb".into(),
734 dest_path: "skills/old-skill".into(),
735 };
736
737 let plan = SyncPlan {
738 actions: vec![PlannedAction::Remove { locked }],
739 };
740
741 let options = SyncOptions {
742 force: false,
743 dry_run: false,
744 frozen: false,
745 no_refresh_models: false,
746 };
747
748 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
749 assert!(matches!(result.outcomes[0].action, ActionTaken::Removed));
750 assert!(!root.path().join("skills/old-skill").exists());
751 }
752
753 #[test]
754 fn remove_bootstrap_doc_removes_container_directory() {
755 let root = TempDir::new().unwrap();
756 let cache_dir = TempDir::new().unwrap();
757 let bases_dir = cache_dir.path().join("bases");
758 let bootstrap_dir = root.path().join("bootstrap/global-auth");
759 fs::create_dir_all(&bootstrap_dir).unwrap();
760 fs::write(bootstrap_dir.join("BOOTSTRAP.md"), b"# auth").unwrap();
761
762 let locked = LockedItem {
763 source: "old-source".into(),
764 kind: ItemKind::BootstrapDoc,
765 version: None,
766 source_checksum: "sha256:aaa".into(),
767 installed_checksum: "sha256:bbb".into(),
768 dest_path: "bootstrap/global-auth/BOOTSTRAP.md".into(),
769 };
770
771 let plan = SyncPlan {
772 actions: vec![PlannedAction::Remove { locked }],
773 };
774 let options = SyncOptions {
775 force: false,
776 dry_run: false,
777 frozen: false,
778 no_refresh_models: false,
779 };
780
781 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
782 assert!(matches!(result.outcomes[0].action, ActionTaken::Removed));
783 assert!(!bootstrap_dir.exists());
784 }
785
786 #[test]
787 fn remove_degenerate_bootstrap_doc_path_removes_exact_file_only() {
788 let root = TempDir::new().unwrap();
789 let cache_dir = TempDir::new().unwrap();
790 let bases_dir = cache_dir.path().join("bases");
791 let bootstrap_dir = root.path().join("bootstrap");
792 fs::create_dir_all(&bootstrap_dir).unwrap();
793 fs::write(bootstrap_dir.join("BOOTSTRAP.md"), b"# root").unwrap();
794 fs::write(bootstrap_dir.join("keep.md"), b"# keep").unwrap();
795
796 let locked = LockedItem {
797 source: "old-source".into(),
798 kind: ItemKind::BootstrapDoc,
799 version: None,
800 source_checksum: "sha256:aaa".into(),
801 installed_checksum: "sha256:bbb".into(),
802 dest_path: "bootstrap/BOOTSTRAP.md".into(),
803 };
804
805 let plan = SyncPlan {
806 actions: vec![PlannedAction::Remove { locked }],
807 };
808 let options = SyncOptions {
809 force: false,
810 dry_run: false,
811 frozen: false,
812 no_refresh_models: false,
813 };
814
815 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
816 assert!(matches!(result.outcomes[0].action, ActionTaken::Removed));
817 assert!(!bootstrap_dir.join("BOOTSTRAP.md").exists());
818 assert!(bootstrap_dir.join("keep.md").exists());
819 }
820
821 #[test]
824 fn dry_run_does_not_modify_files() {
825 let root = TempDir::new().unwrap();
826 let source_dir = TempDir::new().unwrap();
827 let cache_dir = TempDir::new().unwrap();
828 let bases_dir = cache_dir.path().join("bases");
829
830 let content = b"# new agent";
831 let source_path = setup_source_agent(source_dir.path(), "coder", content);
832 let target = make_agent_target("coder", source_path, content);
833
834 let plan = SyncPlan {
835 actions: vec![PlannedAction::Install { target }],
836 };
837
838 let options = SyncOptions {
839 force: false,
840 dry_run: true,
841 frozen: false,
842 no_refresh_models: false,
843 };
844
845 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
846 assert_eq!(result.outcomes.len(), 1);
847 assert!(matches!(result.outcomes[0].action, ActionTaken::Installed));
848
849 assert!(!root.path().join("agents/coder.md").exists());
851 }
852
853 #[test]
856 fn skip_produces_skipped_outcome() {
857 let root = TempDir::new().unwrap();
858 let cache_dir = TempDir::new().unwrap();
859 let bases_dir = cache_dir.path().join("bases");
860
861 let plan = SyncPlan {
862 actions: vec![PlannedAction::Skip {
863 item_id: ItemId {
864 kind: ItemKind::Agent,
865 name: "stable".into(),
866 },
867 dest_path: "agents/stable.md".into(),
868 source_name: "base".into(),
869 installed_checksum: Some("sha256:stable".into()),
870 reason: "unchanged",
871 }],
872 };
873
874 let options = SyncOptions {
875 force: false,
876 dry_run: false,
877 frozen: false,
878 no_refresh_models: false,
879 };
880
881 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
882 assert!(matches!(result.outcomes[0].action, ActionTaken::Skipped));
883 assert_eq!(
884 result.outcomes[0].dest_path,
885 crate::types::DestPath::from("agents/stable.md")
886 );
887 assert_eq!(result.outcomes[0].source_name, "base");
888 assert_eq!(
889 result.outcomes[0].installed_checksum.as_deref(),
890 Some("sha256:stable")
891 );
892 }
893
894 #[test]
895 fn keep_local_produces_kept_outcome() {
896 let root = TempDir::new().unwrap();
897 let cache_dir = TempDir::new().unwrap();
898 let bases_dir = cache_dir.path().join("bases");
899
900 let plan = SyncPlan {
901 actions: vec![PlannedAction::KeepLocal {
902 item_id: ItemId {
903 kind: ItemKind::Agent,
904 name: "modified".into(),
905 },
906 dest_path: "agents/modified.md".into(),
907 source_name: "base".into(),
908 }],
909 };
910
911 let options = SyncOptions {
912 force: false,
913 dry_run: false,
914 frozen: false,
915 no_refresh_models: false,
916 };
917
918 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
919 assert!(matches!(result.outcomes[0].action, ActionTaken::Kept));
920 assert_eq!(
921 result.outcomes[0].dest_path,
922 crate::types::DestPath::from("agents/modified.md")
923 );
924 assert_eq!(result.outcomes[0].source_name, "base");
925 }
926
927 #[test]
930 fn install_skill_directory() {
931 let root = TempDir::new().unwrap();
932 let source_dir = TempDir::new().unwrap();
933 let cache_dir = TempDir::new().unwrap();
934 let bases_dir = cache_dir.path().join("bases");
935
936 let source_skill = source_dir.path().join("skills/planning");
938 fs::create_dir_all(&source_skill).unwrap();
939 fs::write(source_skill.join("SKILL.md"), b"# Planning skill").unwrap();
940 fs::write(source_skill.join("helper.md"), b"# Helper").unwrap();
941
942 let skill_hash = hash::compute_hash(&source_skill, ItemKind::Skill).unwrap();
943
944 let target = TargetItem {
945 id: ItemId {
946 kind: ItemKind::Skill,
947 name: "planning".into(),
948 },
949 source_name: "test".into(),
950 origin: crate::types::SourceOrigin::Dependency("test".into()),
951 source_id: crate::types::SourceId::Path {
952 canonical: source_skill.clone(),
953 subpath: None,
954 },
955 source_path: source_skill,
956 dest_path: "skills/planning".into(),
957 source_hash: skill_hash.into(),
958 is_flat_skill: false,
959 rewritten_content: None,
960 };
961
962 let plan = SyncPlan {
963 actions: vec![PlannedAction::Install { target }],
964 };
965
966 let options = SyncOptions {
967 force: false,
968 dry_run: false,
969 frozen: false,
970 no_refresh_models: false,
971 };
972
973 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
974 assert!(matches!(result.outcomes[0].action, ActionTaken::Installed));
975
976 let installed_dir = root.path().join("skills/planning");
977 assert!(installed_dir.exists());
978 assert!(installed_dir.join("SKILL.md").exists());
979 assert!(installed_dir.join("helper.md").exists());
980 assert_eq!(
981 fs::read_to_string(installed_dir.join("SKILL.md")).unwrap(),
982 "# Planning skill"
983 );
984 }
985
986 #[test]
987 fn install_flat_skill_excludes_repo_metadata() {
988 let root = TempDir::new().unwrap();
989 let source_dir = TempDir::new().unwrap();
990 let cache_dir = TempDir::new().unwrap();
991 let bases_dir = cache_dir.path().join("bases");
992
993 let flat_source = source_dir.path().join("flat-skill");
994 fs::create_dir_all(flat_source.join(".git")).unwrap();
995 fs::create_dir_all(flat_source.join("resources")).unwrap();
996 fs::write(flat_source.join("SKILL.md"), b"# Flat skill").unwrap();
997 fs::write(flat_source.join("resources/guide.md"), b"# Guide").unwrap();
998 fs::write(flat_source.join("mars.toml"), b"[sources]").unwrap();
999 fs::write(flat_source.join(".gitignore"), b"target/").unwrap();
1000 fs::write(flat_source.join(".git/config"), b"[core]").unwrap();
1001
1002 let source_hash = hash::compute_skill_hash_filtered(
1003 &flat_source,
1004 crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL,
1005 )
1006 .unwrap();
1007
1008 let target = TargetItem {
1009 id: ItemId {
1010 kind: ItemKind::Skill,
1011 name: "flat-skill".into(),
1012 },
1013 source_name: "test".into(),
1014 origin: crate::types::SourceOrigin::Dependency("test".into()),
1015 source_id: crate::types::SourceId::Path {
1016 canonical: flat_source.clone(),
1017 subpath: None,
1018 },
1019 source_path: flat_source,
1020 dest_path: "skills/flat-skill".into(),
1021 source_hash: source_hash.into(),
1022 is_flat_skill: true,
1023 rewritten_content: None,
1024 };
1025
1026 let plan = SyncPlan {
1027 actions: vec![PlannedAction::Install { target }],
1028 };
1029
1030 let options = SyncOptions {
1031 force: false,
1032 dry_run: false,
1033 frozen: false,
1034 no_refresh_models: false,
1035 };
1036
1037 execute(root.path(), &plan, &options, &bases_dir).unwrap();
1038
1039 let installed = root.path().join("skills/flat-skill");
1040 assert!(installed.join("SKILL.md").exists());
1041 assert!(installed.join("resources/guide.md").exists());
1042 assert!(!installed.join(".git").exists());
1043 assert!(!installed.join("mars.toml").exists());
1044 assert!(!installed.join(".gitignore").exists());
1045 }
1046
1047 #[test]
1050 fn prune_removes_orphaned_items() {
1051 let root = TempDir::new().unwrap();
1052
1053 let agents_dir = root.path().join("agents");
1055 fs::create_dir_all(&agents_dir).unwrap();
1056 fs::write(agents_dir.join("old.md"), b"# orphan").unwrap();
1057
1058 let mut lock_items = indexmap::IndexMap::new();
1059 lock_items.insert(
1060 "agent/old".to_string(),
1061 crate::lock::LockedItemV2 {
1062 source: "old-source".into(),
1063 kind: ItemKind::Agent,
1064 version: None,
1065 source_checksum: "sha256:aaa".into(),
1066 outputs: vec![crate::lock::OutputRecord {
1067 target_root: ".mars".to_string(),
1068 dest_path: "agents/old.md".into(),
1069 installed_checksum: "sha256:bbb".into(),
1070 }],
1071 },
1072 );
1073 let lock = crate::lock::LockFile {
1074 version: 2,
1075 dependencies: indexmap::IndexMap::new(),
1076 items: lock_items,
1077 config_entries: std::collections::BTreeMap::new(),
1078 };
1079
1080 let target = crate::sync::target::TargetState {
1082 items: indexmap::IndexMap::new(),
1083 };
1084
1085 let outcomes = prune_orphans(root.path(), &lock, &target).unwrap();
1086 assert_eq!(outcomes.len(), 1);
1087 assert!(matches!(outcomes[0].action, ActionTaken::Removed));
1088 assert!(!root.path().join("agents/old.md").exists());
1089 }
1090
1091 #[test]
1094 fn extract_agent_name() {
1095 assert_eq!(
1096 crate::types::DestPath::from("agents/coder.md").item_name(ItemKind::Agent),
1097 "coder"
1098 );
1099 }
1100
1101 #[test]
1102 fn extract_skill_name() {
1103 assert_eq!(
1104 crate::types::DestPath::from("skills/planning").item_name(ItemKind::Skill),
1105 "planning"
1106 );
1107 }
1108}