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 refresh_models: false,
561 no_refresh_models: false,
562 };
563
564 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
565 assert_eq!(result.outcomes.len(), 1);
566
567 let outcome = &result.outcomes[0];
568 assert!(matches!(outcome.action, ActionTaken::Installed));
569
570 let installed_path = root.path().join("agents/coder.md");
572 assert!(installed_path.exists());
573 assert_eq!(fs::read(&installed_path).unwrap(), content);
574
575 assert_eq!(
577 outcome.source_checksum.as_deref(),
578 Some(hash::hash_bytes(content).as_str())
579 );
580 assert!(outcome.installed_checksum.is_some());
581 }
582
583 #[test]
584 fn install_caches_base_content() {
585 let root = TempDir::new().unwrap();
586 let source_dir = TempDir::new().unwrap();
587 let cache_dir = TempDir::new().unwrap();
588 let bases_dir = cache_dir.path().join("bases");
589
590 let content = b"# cached content";
591 let source_path = setup_source_agent(source_dir.path(), "coder", content);
592 let target = make_agent_target("coder", source_path, content);
593
594 let plan = SyncPlan {
595 actions: vec![PlannedAction::Install { target }],
596 };
597
598 let options = SyncOptions {
599 force: false,
600 dry_run: false,
601 frozen: false,
602 refresh_models: false,
603 no_refresh_models: false,
604 };
605
606 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
607 let installed_checksum = result.outcomes[0].installed_checksum.as_ref().unwrap();
608
609 let cached = bases_dir.join(installed_checksum.as_ref().replace(':', "_"));
611 assert!(cached.exists(), "base content should be cached");
612 assert_eq!(fs::read(&cached).unwrap(), content);
613 }
614
615 #[test]
618 fn overwrite_replaces_existing_file() {
619 let root = TempDir::new().unwrap();
620 let source_dir = TempDir::new().unwrap();
621 let cache_dir = TempDir::new().unwrap();
622 let bases_dir = cache_dir.path().join("bases");
623
624 let agents_dir = root.path().join("agents");
626 fs::create_dir_all(&agents_dir).unwrap();
627 fs::write(agents_dir.join("coder.md"), b"# old content").unwrap();
628
629 let new_content = b"# new content";
630 let source_path = setup_source_agent(source_dir.path(), "coder", new_content);
631 let target = make_agent_target("coder", source_path, new_content);
632
633 let plan = SyncPlan {
634 actions: vec![PlannedAction::Overwrite { target }],
635 };
636
637 let options = SyncOptions {
638 force: false,
639 dry_run: false,
640 frozen: false,
641 refresh_models: false,
642 no_refresh_models: false,
643 };
644
645 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
646 assert!(matches!(result.outcomes[0].action, ActionTaken::Updated));
647
648 let installed = fs::read(root.path().join("agents/coder.md")).unwrap();
649 assert_eq!(installed, new_content);
650 }
651
652 #[test]
653 fn install_bootstrap_doc_directory_to_canonical_file_path() {
654 let root = TempDir::new().unwrap();
655 let source_dir = TempDir::new().unwrap();
656 let cache_dir = TempDir::new().unwrap();
657 let bases_dir = cache_dir.path().join("bases");
658 let bootstrap_dir = source_dir.path().join("bootstrap/global-auth");
659 fs::create_dir_all(&bootstrap_dir).unwrap();
660 fs::write(bootstrap_dir.join("BOOTSTRAP.md"), b"# auth").unwrap();
661
662 let target = make_bootstrap_target("global-auth", bootstrap_dir);
663 let plan = SyncPlan {
664 actions: vec![PlannedAction::Install { target }],
665 };
666 let options = SyncOptions {
667 force: false,
668 dry_run: false,
669 frozen: false,
670 refresh_models: false,
671 no_refresh_models: false,
672 };
673
674 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
675
676 assert!(matches!(result.outcomes[0].action, ActionTaken::Installed));
677 assert_eq!(
678 fs::read(root.path().join("bootstrap/global-auth/BOOTSTRAP.md")).unwrap(),
679 b"# auth"
680 );
681 }
682
683 #[test]
686 fn remove_deletes_file() {
687 let root = TempDir::new().unwrap();
688 let cache_dir = TempDir::new().unwrap();
689 let bases_dir = cache_dir.path().join("bases");
690
691 let agents_dir = root.path().join("agents");
693 fs::create_dir_all(&agents_dir).unwrap();
694 fs::write(agents_dir.join("orphan.md"), b"# orphan").unwrap();
695
696 let locked = LockedItem {
697 source: "old-source".into(),
698 kind: ItemKind::Agent,
699 version: None,
700 source_checksum: "sha256:aaa".into(),
701 installed_checksum: "sha256:bbb".into(),
702 dest_path: "agents/orphan.md".into(),
703 };
704
705 let plan = SyncPlan {
706 actions: vec![PlannedAction::Remove { locked }],
707 };
708
709 let options = SyncOptions {
710 force: false,
711 dry_run: false,
712 frozen: false,
713 refresh_models: false,
714 no_refresh_models: false,
715 };
716
717 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
718 assert!(matches!(result.outcomes[0].action, ActionTaken::Removed));
719 assert!(!root.path().join("agents/orphan.md").exists());
720 }
721
722 #[test]
723 fn remove_skill_directory() {
724 let root = TempDir::new().unwrap();
725 let cache_dir = TempDir::new().unwrap();
726 let bases_dir = cache_dir.path().join("bases");
727
728 let skill_dir = root.path().join("skills/old-skill");
730 fs::create_dir_all(&skill_dir).unwrap();
731 fs::write(skill_dir.join("SKILL.md"), b"# old skill").unwrap();
732
733 let locked = LockedItem {
734 source: "old-source".into(),
735 kind: ItemKind::Skill,
736 version: None,
737 source_checksum: "sha256:aaa".into(),
738 installed_checksum: "sha256:bbb".into(),
739 dest_path: "skills/old-skill".into(),
740 };
741
742 let plan = SyncPlan {
743 actions: vec![PlannedAction::Remove { locked }],
744 };
745
746 let options = SyncOptions {
747 force: false,
748 dry_run: false,
749 frozen: false,
750 refresh_models: false,
751 no_refresh_models: false,
752 };
753
754 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
755 assert!(matches!(result.outcomes[0].action, ActionTaken::Removed));
756 assert!(!root.path().join("skills/old-skill").exists());
757 }
758
759 #[test]
760 fn remove_bootstrap_doc_removes_container_directory() {
761 let root = TempDir::new().unwrap();
762 let cache_dir = TempDir::new().unwrap();
763 let bases_dir = cache_dir.path().join("bases");
764 let bootstrap_dir = root.path().join("bootstrap/global-auth");
765 fs::create_dir_all(&bootstrap_dir).unwrap();
766 fs::write(bootstrap_dir.join("BOOTSTRAP.md"), b"# auth").unwrap();
767
768 let locked = LockedItem {
769 source: "old-source".into(),
770 kind: ItemKind::BootstrapDoc,
771 version: None,
772 source_checksum: "sha256:aaa".into(),
773 installed_checksum: "sha256:bbb".into(),
774 dest_path: "bootstrap/global-auth/BOOTSTRAP.md".into(),
775 };
776
777 let plan = SyncPlan {
778 actions: vec![PlannedAction::Remove { locked }],
779 };
780 let options = SyncOptions {
781 force: false,
782 dry_run: false,
783 frozen: false,
784 refresh_models: false,
785 no_refresh_models: false,
786 };
787
788 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
789 assert!(matches!(result.outcomes[0].action, ActionTaken::Removed));
790 assert!(!bootstrap_dir.exists());
791 }
792
793 #[test]
794 fn remove_degenerate_bootstrap_doc_path_removes_exact_file_only() {
795 let root = TempDir::new().unwrap();
796 let cache_dir = TempDir::new().unwrap();
797 let bases_dir = cache_dir.path().join("bases");
798 let bootstrap_dir = root.path().join("bootstrap");
799 fs::create_dir_all(&bootstrap_dir).unwrap();
800 fs::write(bootstrap_dir.join("BOOTSTRAP.md"), b"# root").unwrap();
801 fs::write(bootstrap_dir.join("keep.md"), b"# keep").unwrap();
802
803 let locked = LockedItem {
804 source: "old-source".into(),
805 kind: ItemKind::BootstrapDoc,
806 version: None,
807 source_checksum: "sha256:aaa".into(),
808 installed_checksum: "sha256:bbb".into(),
809 dest_path: "bootstrap/BOOTSTRAP.md".into(),
810 };
811
812 let plan = SyncPlan {
813 actions: vec![PlannedAction::Remove { locked }],
814 };
815 let options = SyncOptions {
816 force: false,
817 dry_run: false,
818 frozen: false,
819 refresh_models: false,
820 no_refresh_models: false,
821 };
822
823 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
824 assert!(matches!(result.outcomes[0].action, ActionTaken::Removed));
825 assert!(!bootstrap_dir.join("BOOTSTRAP.md").exists());
826 assert!(bootstrap_dir.join("keep.md").exists());
827 }
828
829 #[test]
832 fn dry_run_does_not_modify_files() {
833 let root = TempDir::new().unwrap();
834 let source_dir = TempDir::new().unwrap();
835 let cache_dir = TempDir::new().unwrap();
836 let bases_dir = cache_dir.path().join("bases");
837
838 let content = b"# new agent";
839 let source_path = setup_source_agent(source_dir.path(), "coder", content);
840 let target = make_agent_target("coder", source_path, content);
841
842 let plan = SyncPlan {
843 actions: vec![PlannedAction::Install { target }],
844 };
845
846 let options = SyncOptions {
847 force: false,
848 dry_run: true,
849 frozen: false,
850 refresh_models: false,
851 no_refresh_models: false,
852 };
853
854 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
855 assert_eq!(result.outcomes.len(), 1);
856 assert!(matches!(result.outcomes[0].action, ActionTaken::Installed));
857
858 assert!(!root.path().join("agents/coder.md").exists());
860 }
861
862 #[test]
865 fn skip_produces_skipped_outcome() {
866 let root = TempDir::new().unwrap();
867 let cache_dir = TempDir::new().unwrap();
868 let bases_dir = cache_dir.path().join("bases");
869
870 let plan = SyncPlan {
871 actions: vec![PlannedAction::Skip {
872 item_id: ItemId {
873 kind: ItemKind::Agent,
874 name: "stable".into(),
875 },
876 dest_path: "agents/stable.md".into(),
877 source_name: "base".into(),
878 installed_checksum: Some("sha256:stable".into()),
879 reason: "unchanged",
880 }],
881 };
882
883 let options = SyncOptions {
884 force: false,
885 dry_run: false,
886 frozen: false,
887 refresh_models: false,
888 no_refresh_models: false,
889 };
890
891 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
892 assert!(matches!(result.outcomes[0].action, ActionTaken::Skipped));
893 assert_eq!(
894 result.outcomes[0].dest_path,
895 crate::types::DestPath::from("agents/stable.md")
896 );
897 assert_eq!(result.outcomes[0].source_name, "base");
898 assert_eq!(
899 result.outcomes[0].installed_checksum.as_deref(),
900 Some("sha256:stable")
901 );
902 }
903
904 #[test]
905 fn keep_local_produces_kept_outcome() {
906 let root = TempDir::new().unwrap();
907 let cache_dir = TempDir::new().unwrap();
908 let bases_dir = cache_dir.path().join("bases");
909
910 let plan = SyncPlan {
911 actions: vec![PlannedAction::KeepLocal {
912 item_id: ItemId {
913 kind: ItemKind::Agent,
914 name: "modified".into(),
915 },
916 dest_path: "agents/modified.md".into(),
917 source_name: "base".into(),
918 }],
919 };
920
921 let options = SyncOptions {
922 force: false,
923 dry_run: false,
924 frozen: false,
925 refresh_models: false,
926 no_refresh_models: false,
927 };
928
929 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
930 assert!(matches!(result.outcomes[0].action, ActionTaken::Kept));
931 assert_eq!(
932 result.outcomes[0].dest_path,
933 crate::types::DestPath::from("agents/modified.md")
934 );
935 assert_eq!(result.outcomes[0].source_name, "base");
936 }
937
938 #[test]
941 fn install_skill_directory() {
942 let root = TempDir::new().unwrap();
943 let source_dir = TempDir::new().unwrap();
944 let cache_dir = TempDir::new().unwrap();
945 let bases_dir = cache_dir.path().join("bases");
946
947 let source_skill = source_dir.path().join("skills/planning");
949 fs::create_dir_all(&source_skill).unwrap();
950 fs::write(source_skill.join("SKILL.md"), b"# Planning skill").unwrap();
951 fs::write(source_skill.join("helper.md"), b"# Helper").unwrap();
952
953 let skill_hash = hash::compute_hash(&source_skill, ItemKind::Skill).unwrap();
954
955 let target = TargetItem {
956 id: ItemId {
957 kind: ItemKind::Skill,
958 name: "planning".into(),
959 },
960 source_name: "test".into(),
961 origin: crate::types::SourceOrigin::Dependency("test".into()),
962 source_id: crate::types::SourceId::Path {
963 canonical: source_skill.clone(),
964 subpath: None,
965 },
966 source_path: source_skill,
967 dest_path: "skills/planning".into(),
968 source_hash: skill_hash.into(),
969 is_flat_skill: false,
970 rewritten_content: None,
971 };
972
973 let plan = SyncPlan {
974 actions: vec![PlannedAction::Install { target }],
975 };
976
977 let options = SyncOptions {
978 force: false,
979 dry_run: false,
980 frozen: false,
981 refresh_models: false,
982 no_refresh_models: false,
983 };
984
985 let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
986 assert!(matches!(result.outcomes[0].action, ActionTaken::Installed));
987
988 let installed_dir = root.path().join("skills/planning");
989 assert!(installed_dir.exists());
990 assert!(installed_dir.join("SKILL.md").exists());
991 assert!(installed_dir.join("helper.md").exists());
992 assert_eq!(
993 fs::read_to_string(installed_dir.join("SKILL.md")).unwrap(),
994 "# Planning skill"
995 );
996 }
997
998 #[test]
999 fn install_flat_skill_excludes_repo_metadata() {
1000 let root = TempDir::new().unwrap();
1001 let source_dir = TempDir::new().unwrap();
1002 let cache_dir = TempDir::new().unwrap();
1003 let bases_dir = cache_dir.path().join("bases");
1004
1005 let flat_source = source_dir.path().join("flat-skill");
1006 fs::create_dir_all(flat_source.join(".git")).unwrap();
1007 fs::create_dir_all(flat_source.join("resources")).unwrap();
1008 fs::write(flat_source.join("SKILL.md"), b"# Flat skill").unwrap();
1009 fs::write(flat_source.join("resources/guide.md"), b"# Guide").unwrap();
1010 fs::write(flat_source.join("mars.toml"), b"[sources]").unwrap();
1011 fs::write(flat_source.join(".gitignore"), b"target/").unwrap();
1012 fs::write(flat_source.join(".git/config"), b"[core]").unwrap();
1013
1014 let source_hash = hash::compute_skill_hash_filtered(
1015 &flat_source,
1016 crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL,
1017 )
1018 .unwrap();
1019
1020 let target = TargetItem {
1021 id: ItemId {
1022 kind: ItemKind::Skill,
1023 name: "flat-skill".into(),
1024 },
1025 source_name: "test".into(),
1026 origin: crate::types::SourceOrigin::Dependency("test".into()),
1027 source_id: crate::types::SourceId::Path {
1028 canonical: flat_source.clone(),
1029 subpath: None,
1030 },
1031 source_path: flat_source,
1032 dest_path: "skills/flat-skill".into(),
1033 source_hash: source_hash.into(),
1034 is_flat_skill: true,
1035 rewritten_content: None,
1036 };
1037
1038 let plan = SyncPlan {
1039 actions: vec![PlannedAction::Install { target }],
1040 };
1041
1042 let options = SyncOptions {
1043 force: false,
1044 dry_run: false,
1045 frozen: false,
1046 refresh_models: false,
1047 no_refresh_models: false,
1048 };
1049
1050 execute(root.path(), &plan, &options, &bases_dir).unwrap();
1051
1052 let installed = root.path().join("skills/flat-skill");
1053 assert!(installed.join("SKILL.md").exists());
1054 assert!(installed.join("resources/guide.md").exists());
1055 assert!(!installed.join(".git").exists());
1056 assert!(!installed.join("mars.toml").exists());
1057 assert!(!installed.join(".gitignore").exists());
1058 }
1059
1060 #[test]
1063 fn prune_removes_orphaned_items() {
1064 let root = TempDir::new().unwrap();
1065
1066 let agents_dir = root.path().join("agents");
1068 fs::create_dir_all(&agents_dir).unwrap();
1069 fs::write(agents_dir.join("old.md"), b"# orphan").unwrap();
1070
1071 let mut lock_items = indexmap::IndexMap::new();
1072 lock_items.insert(
1073 "agent/old".to_string(),
1074 crate::lock::LockedItemV2 {
1075 source: "old-source".into(),
1076 kind: ItemKind::Agent,
1077 version: None,
1078 source_checksum: "sha256:aaa".into(),
1079 outputs: vec![crate::lock::OutputRecord {
1080 target_root: ".mars".to_string(),
1081 dest_path: "agents/old.md".into(),
1082 installed_checksum: "sha256:bbb".into(),
1083 }],
1084 },
1085 );
1086 let lock = crate::lock::LockFile {
1087 version: 2,
1088 dependencies: indexmap::IndexMap::new(),
1089 items: lock_items,
1090 config_entries: std::collections::BTreeMap::new(),
1091 };
1092
1093 let target = crate::sync::target::TargetState {
1095 items: indexmap::IndexMap::new(),
1096 };
1097
1098 let outcomes = prune_orphans(root.path(), &lock, &target).unwrap();
1099 assert_eq!(outcomes.len(), 1);
1100 assert!(matches!(outcomes[0].action, ActionTaken::Removed));
1101 assert!(!root.path().join("agents/old.md").exists());
1102 }
1103
1104 #[test]
1107 fn extract_agent_name() {
1108 assert_eq!(
1109 crate::types::DestPath::from("agents/coder.md").item_name(ItemKind::Agent),
1110 "coder"
1111 );
1112 }
1113
1114 #[test]
1115 fn extract_skill_name() {
1116 assert_eq!(
1117 crate::types::DestPath::from("skills/planning").item_name(ItemKind::Skill),
1118 "planning"
1119 );
1120 }
1121}