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