1use std::path::Path;
2
3use crate::error::MarsError;
4use crate::fs::{atomic_install_dir, atomic_write};
5use crate::lock::{ItemId, ItemKind};
6use crate::platform::fs as fs_ops;
7use crate::sync::plan::{PlannedAction, SyncPlan};
8use crate::sync::target::TargetItem;
9pub use crate::sync::types::SyncOptions;
10use crate::types::{ContentHash, DestPath, ItemName, SourceName};
11
12#[derive(Debug, Clone)]
14pub struct ApplyResult {
15 pub outcomes: Vec<ActionOutcome>,
16}
17
18#[derive(Debug, Clone)]
20pub struct ActionOutcome {
21 pub item_id: ItemId,
22 pub action: ActionTaken,
23 pub dest_path: DestPath,
24 pub source_name: SourceName,
26 pub source_checksum: Option<ContentHash>,
28 pub installed_checksum: Option<ContentHash>,
30}
31
32#[derive(Debug, Clone)]
34pub enum ActionTaken {
35 Installed,
36 Updated,
37 Removed,
38 Skipped,
39 Kept,
40}
41
42pub fn execute(
54 root: &Path,
55 plan: &SyncPlan,
56 options: &SyncOptions,
57) -> Result<ApplyResult, MarsError> {
58 let mut outcomes = Vec::new();
59
60 for action in &plan.actions {
61 let outcome = if options.dry_run {
62 dry_run_action(action)
64 } else {
65 execute_action(root, action)?
66 };
67 outcomes.push(outcome);
68 }
69
70 Ok(ApplyResult { outcomes })
71}
72
73fn execute_action(root: &Path, action: &PlannedAction) -> Result<ActionOutcome, MarsError> {
75 match action {
76 PlannedAction::Install { target } => {
77 let dest = target.dest_path.resolve(root);
78
79 let installed_checksum = install_item(target, &dest)?;
81
82 Ok(ActionOutcome {
83 item_id: target.id.clone(),
84 action: ActionTaken::Installed,
85 dest_path: target.dest_path.clone(),
86 source_name: target.source_name.clone(),
87 source_checksum: Some(target.source_hash.clone()),
88 installed_checksum: Some(installed_checksum),
89 })
90 }
91
92 PlannedAction::Overwrite { target } => {
93 let dest = target.dest_path.resolve(root);
94
95 let installed_checksum = install_item(target, &dest)?;
97
98 Ok(ActionOutcome {
99 item_id: target.id.clone(),
100 action: ActionTaken::Updated,
101 dest_path: target.dest_path.clone(),
102 source_name: target.source_name.clone(),
103 source_checksum: Some(target.source_hash.clone()),
104 installed_checksum: Some(installed_checksum),
105 })
106 }
107
108 PlannedAction::Remove { locked } => {
109 let dest = removal_path(root, &locked.dest_path, locked.kind);
110 if dest.exists() {
111 fs_ops::safe_remove(&dest)?;
112 }
113
114 let item_id = ItemId {
115 kind: locked.kind,
116 name: ItemName::from(locked.dest_path.item_name(locked.kind)),
117 };
118
119 Ok(ActionOutcome {
120 item_id,
121 action: ActionTaken::Removed,
122 dest_path: locked.dest_path.clone(),
123 source_name: locked.source.clone(),
124 source_checksum: None,
125 installed_checksum: None,
126 })
127 }
128
129 PlannedAction::Skip {
130 item_id,
131 dest_path,
132 source_name,
133 installed_checksum,
134 } => Ok(ActionOutcome {
135 item_id: item_id.clone(),
136 action: ActionTaken::Skipped,
137 dest_path: dest_path.clone(),
138 source_name: source_name.clone(),
139 source_checksum: None,
140 installed_checksum: installed_checksum.clone(),
141 }),
142
143 PlannedAction::KeepLocal {
144 item_id,
145 dest_path,
146 source_name,
147 } => Ok(ActionOutcome {
148 item_id: item_id.clone(),
149 action: ActionTaken::Kept,
150 dest_path: dest_path.clone(),
151 source_name: source_name.clone(),
152 source_checksum: None,
153 installed_checksum: None,
154 }),
155 }
156}
157
158fn dry_run_action(action: &PlannedAction) -> ActionOutcome {
160 match action {
161 PlannedAction::Install { target } => ActionOutcome {
162 item_id: target.id.clone(),
163 action: ActionTaken::Installed,
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: None, },
169 PlannedAction::Overwrite { target } => ActionOutcome {
170 item_id: target.id.clone(),
171 action: ActionTaken::Updated,
172 dest_path: target.dest_path.clone(),
173 source_name: target.source_name.clone(),
174 source_checksum: Some(target.source_hash.clone()),
175 installed_checksum: None,
176 },
177 PlannedAction::Remove { locked } => {
178 let item_id = ItemId {
179 kind: locked.kind,
180 name: ItemName::from(locked.dest_path.item_name(locked.kind)),
181 };
182 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 PlannedAction::Skip {
192 item_id,
193 dest_path,
194 source_name,
195 installed_checksum,
196 ..
197 } => ActionOutcome {
198 item_id: item_id.clone(),
199 action: ActionTaken::Skipped,
200 dest_path: dest_path.clone(),
201 source_name: source_name.clone(),
202 source_checksum: None,
203 installed_checksum: installed_checksum.clone(),
204 },
205 PlannedAction::KeepLocal {
206 item_id,
207 dest_path,
208 source_name,
209 } => ActionOutcome {
210 item_id: item_id.clone(),
211 action: ActionTaken::Kept,
212 dest_path: dest_path.clone(),
213 source_name: source_name.clone(),
214 source_checksum: None,
215 installed_checksum: None,
216 },
217 }
218}
219
220fn install_item(target: &TargetItem, dest: &Path) -> Result<ContentHash, MarsError> {
224 match target.id.kind {
225 ItemKind::Agent | ItemKind::McpServer => {
226 let content = content_to_install(target)?;
227 write_file_and_verify(dest, &content)
228 }
229 ItemKind::BootstrapDoc => {
230 let doc_dest = dest.parent().ok_or_else(|| {
231 std::io::Error::other(format!(
232 "bootstrap destination has no parent directory: {}",
233 dest.display()
234 ))
235 })?;
236 atomic_install_dir(&target.source_path, doc_dest)?;
237 crate::hash::compute_hash(doc_dest, ItemKind::BootstrapDoc).map(ContentHash::from)
238 }
239 ItemKind::Skill | ItemKind::Hook => {
240 if target.is_flat_skill {
241 crate::fs::atomic_install_dir_filtered(
242 &target.source_path,
243 dest,
244 crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL,
245 )?;
246 } else {
247 atomic_install_dir(&target.source_path, dest)?;
248 }
249 crate::hash::compute_hash(dest, ItemKind::Skill).map(ContentHash::from)
251 }
252 }
253}
254
255fn write_file_and_verify(dest: &Path, content: &[u8]) -> Result<ContentHash, MarsError> {
257 atomic_write(dest, content)?;
258 let expected = ContentHash::from(crate::hash::hash_bytes(content));
259 let persisted = std::fs::read(dest)?;
260 let actual = ContentHash::from(crate::hash::hash_bytes(&persisted));
261 if expected != actual {
262 return Err(std::io::Error::other(format!(
263 "post-write verification failed for {}: expected {expected}, got {actual}",
264 dest.display()
265 ))
266 .into());
267 }
268 Ok(actual)
269}
270
271fn content_to_install(target: &TargetItem) -> Result<Vec<u8>, MarsError> {
273 if let Some(content) = &target.rewritten_content {
274 Ok(content.as_bytes().to_vec())
275 } else if target.id.kind == ItemKind::BootstrapDoc {
276 Ok(std::fs::read(target.source_path.join("BOOTSTRAP.md"))?)
277 } else {
278 Ok(std::fs::read(&target.source_path)?)
279 }
280}
281
282fn removal_path(root: &Path, dest_path: &DestPath, kind: ItemKind) -> std::path::PathBuf {
283 let dest = dest_path.resolve(root);
284 if kind == ItemKind::BootstrapDoc {
285 if dest_path.as_str().split('/').count() >= 3 {
286 dest.parent()
287 .map(Path::to_path_buf)
288 .unwrap_or_else(|| dest.clone())
289 } else {
290 dest
291 }
292 } else {
293 dest
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300 use crate::hash;
301 use crate::lock::{ItemId, ItemKind, LockedItem};
302 use crate::sync::plan::{PlannedAction, SyncPlan};
303 use crate::sync::target::TargetItem;
304 use std::fs;
305 use std::path::PathBuf;
306 use tempfile::TempDir;
307
308 fn make_agent_target(name: &str, source_path: PathBuf, content: &[u8]) -> TargetItem {
309 TargetItem {
310 id: ItemId {
311 kind: ItemKind::Agent,
312 name: name.into(),
313 },
314 source_name: "test-source".into(),
315 source_path,
316 dest_path: format!("agents/{name}.md").into(),
317 source_hash: hash::hash_bytes(content).into(),
318 is_flat_skill: false,
319 rewritten_content: None,
320 }
321 }
322
323 fn make_bootstrap_target(name: &str, source_path: PathBuf) -> TargetItem {
324 TargetItem {
325 id: ItemId {
326 kind: ItemKind::BootstrapDoc,
327 name: name.into(),
328 },
329 source_name: "test-source".into(),
330 source_hash: crate::hash::compute_hash(&source_path, ItemKind::BootstrapDoc)
331 .unwrap()
332 .into(),
333 source_path,
334 dest_path: format!("bootstrap/{name}/BOOTSTRAP.md").into(),
335 is_flat_skill: false,
336 rewritten_content: None,
337 }
338 }
339
340 fn setup_source_agent(dir: &Path, name: &str, content: &[u8]) -> PathBuf {
341 let agents_dir = dir.join("source").join("agents");
342 fs::create_dir_all(&agents_dir).unwrap();
343 let path = agents_dir.join(format!("{name}.md"));
344 fs::write(&path, content).unwrap();
345 path
346 }
347
348 #[test]
351 fn install_creates_new_file() {
352 let root = TempDir::new().unwrap();
353 let source_dir = TempDir::new().unwrap();
354
355 let content = b"# new agent content";
356 let source_path = setup_source_agent(source_dir.path(), "coder", content);
357 let target = make_agent_target("coder", source_path, content);
358
359 let plan = SyncPlan {
360 actions: vec![PlannedAction::Install {
361 target: target.clone(),
362 }],
363 };
364
365 let options = SyncOptions::default();
366
367 let result = execute(root.path(), &plan, &options).unwrap();
368 assert_eq!(result.outcomes.len(), 1);
369
370 let outcome = &result.outcomes[0];
371 assert!(matches!(outcome.action, ActionTaken::Installed));
372
373 let installed_path = root.path().join("agents/coder.md");
375 assert!(installed_path.exists());
376 assert_eq!(fs::read(&installed_path).unwrap(), content);
377
378 assert_eq!(
380 outcome.source_checksum.as_deref(),
381 Some(hash::hash_bytes(content).as_str())
382 );
383 assert!(outcome.installed_checksum.is_some());
384 }
385
386 #[test]
389 fn overwrite_replaces_existing_file() {
390 let root = TempDir::new().unwrap();
391 let source_dir = TempDir::new().unwrap();
392
393 let agents_dir = root.path().join("agents");
395 fs::create_dir_all(&agents_dir).unwrap();
396 fs::write(agents_dir.join("coder.md"), b"# old content").unwrap();
397
398 let new_content = b"# new content";
399 let source_path = setup_source_agent(source_dir.path(), "coder", new_content);
400 let target = make_agent_target("coder", source_path, new_content);
401
402 let plan = SyncPlan {
403 actions: vec![PlannedAction::Overwrite { target }],
404 };
405
406 let options = SyncOptions::default();
407
408 let result = execute(root.path(), &plan, &options).unwrap();
409 assert!(matches!(result.outcomes[0].action, ActionTaken::Updated));
410
411 let installed = fs::read(root.path().join("agents/coder.md")).unwrap();
412 assert_eq!(installed, new_content);
413 }
414
415 #[test]
416 fn install_bootstrap_doc_directory_to_canonical_file_path() {
417 let root = TempDir::new().unwrap();
418 let source_dir = TempDir::new().unwrap();
419 let bootstrap_dir = source_dir.path().join("bootstrap/global-auth");
420 fs::create_dir_all(&bootstrap_dir).unwrap();
421 fs::write(bootstrap_dir.join("BOOTSTRAP.md"), b"# auth").unwrap();
422
423 let target = make_bootstrap_target("global-auth", bootstrap_dir);
424 let plan = SyncPlan {
425 actions: vec![PlannedAction::Install { target }],
426 };
427 let options = SyncOptions::default();
428
429 let result = execute(root.path(), &plan, &options).unwrap();
430
431 assert!(matches!(result.outcomes[0].action, ActionTaken::Installed));
432 assert_eq!(
433 fs::read(root.path().join("bootstrap/global-auth/BOOTSTRAP.md")).unwrap(),
434 b"# auth"
435 );
436 }
437
438 #[test]
441 fn remove_deletes_file() {
442 let root = TempDir::new().unwrap();
443
444 let agents_dir = root.path().join("agents");
446 fs::create_dir_all(&agents_dir).unwrap();
447 fs::write(agents_dir.join("orphan.md"), b"# orphan").unwrap();
448
449 let locked = LockedItem {
450 source: "old-source".into(),
451 kind: ItemKind::Agent,
452 version: None,
453 source_checksum: "sha256:aaa".into(),
454 installed_checksum: "sha256:bbb".into(),
455 dest_path: "agents/orphan.md".into(),
456 };
457
458 let plan = SyncPlan {
459 actions: vec![PlannedAction::Remove { locked }],
460 };
461
462 let options = SyncOptions::default();
463
464 let result = execute(root.path(), &plan, &options).unwrap();
465 assert!(matches!(result.outcomes[0].action, ActionTaken::Removed));
466 assert!(!root.path().join("agents/orphan.md").exists());
467 }
468
469 #[test]
470 fn remove_skill_directory() {
471 let root = TempDir::new().unwrap();
472
473 let skill_dir = root.path().join("skills/old-skill");
475 fs::create_dir_all(&skill_dir).unwrap();
476 fs::write(skill_dir.join("SKILL.md"), b"# old skill").unwrap();
477
478 let locked = LockedItem {
479 source: "old-source".into(),
480 kind: ItemKind::Skill,
481 version: None,
482 source_checksum: "sha256:aaa".into(),
483 installed_checksum: "sha256:bbb".into(),
484 dest_path: "skills/old-skill".into(),
485 };
486
487 let plan = SyncPlan {
488 actions: vec![PlannedAction::Remove { locked }],
489 };
490
491 let options = SyncOptions::default();
492
493 let result = execute(root.path(), &plan, &options).unwrap();
494 assert!(matches!(result.outcomes[0].action, ActionTaken::Removed));
495 assert!(!root.path().join("skills/old-skill").exists());
496 }
497
498 #[test]
499 fn remove_bootstrap_doc_removes_container_directory() {
500 let root = TempDir::new().unwrap();
501 let bootstrap_dir = root.path().join("bootstrap/global-auth");
502 fs::create_dir_all(&bootstrap_dir).unwrap();
503 fs::write(bootstrap_dir.join("BOOTSTRAP.md"), b"# auth").unwrap();
504
505 let locked = LockedItem {
506 source: "old-source".into(),
507 kind: ItemKind::BootstrapDoc,
508 version: None,
509 source_checksum: "sha256:aaa".into(),
510 installed_checksum: "sha256:bbb".into(),
511 dest_path: "bootstrap/global-auth/BOOTSTRAP.md".into(),
512 };
513
514 let plan = SyncPlan {
515 actions: vec![PlannedAction::Remove { locked }],
516 };
517 let options = SyncOptions::default();
518
519 let result = execute(root.path(), &plan, &options).unwrap();
520 assert!(matches!(result.outcomes[0].action, ActionTaken::Removed));
521 assert!(!bootstrap_dir.exists());
522 }
523
524 #[test]
525 fn remove_degenerate_bootstrap_doc_path_removes_exact_file_only() {
526 let root = TempDir::new().unwrap();
527 let bootstrap_dir = root.path().join("bootstrap");
528 fs::create_dir_all(&bootstrap_dir).unwrap();
529 fs::write(bootstrap_dir.join("BOOTSTRAP.md"), b"# root").unwrap();
530 fs::write(bootstrap_dir.join("keep.md"), b"# keep").unwrap();
531
532 let locked = LockedItem {
533 source: "old-source".into(),
534 kind: ItemKind::BootstrapDoc,
535 version: None,
536 source_checksum: "sha256:aaa".into(),
537 installed_checksum: "sha256:bbb".into(),
538 dest_path: "bootstrap/BOOTSTRAP.md".into(),
539 };
540
541 let plan = SyncPlan {
542 actions: vec![PlannedAction::Remove { locked }],
543 };
544 let options = SyncOptions::default();
545
546 let result = execute(root.path(), &plan, &options).unwrap();
547 assert!(matches!(result.outcomes[0].action, ActionTaken::Removed));
548 assert!(!bootstrap_dir.join("BOOTSTRAP.md").exists());
549 assert!(bootstrap_dir.join("keep.md").exists());
550 }
551
552 #[test]
555 fn dry_run_does_not_modify_files() {
556 let root = TempDir::new().unwrap();
557 let source_dir = TempDir::new().unwrap();
558
559 let content = b"# new agent";
560 let source_path = setup_source_agent(source_dir.path(), "coder", content);
561 let target = make_agent_target("coder", source_path, content);
562
563 let plan = SyncPlan {
564 actions: vec![PlannedAction::Install { target }],
565 };
566
567 let options = SyncOptions {
568 dry_run: true,
569 ..SyncOptions::default()
570 };
571
572 let result = execute(root.path(), &plan, &options).unwrap();
573 assert_eq!(result.outcomes.len(), 1);
574 assert!(matches!(result.outcomes[0].action, ActionTaken::Installed));
575
576 assert!(!root.path().join("agents/coder.md").exists());
578 }
579
580 #[test]
583 fn skip_produces_skipped_outcome() {
584 let root = TempDir::new().unwrap();
585
586 let plan = SyncPlan {
587 actions: vec![PlannedAction::Skip {
588 item_id: ItemId {
589 kind: ItemKind::Agent,
590 name: "stable".into(),
591 },
592 dest_path: "agents/stable.md".into(),
593 source_name: "base".into(),
594 installed_checksum: Some("sha256:stable".into()),
595 }],
596 };
597
598 let options = SyncOptions::default();
599
600 let result = execute(root.path(), &plan, &options).unwrap();
601 assert!(matches!(result.outcomes[0].action, ActionTaken::Skipped));
602 assert_eq!(
603 result.outcomes[0].dest_path,
604 crate::types::DestPath::from("agents/stable.md")
605 );
606 assert_eq!(result.outcomes[0].source_name, "base");
607 assert_eq!(
608 result.outcomes[0].installed_checksum.as_deref(),
609 Some("sha256:stable")
610 );
611 }
612
613 #[test]
614 fn keep_local_produces_kept_outcome() {
615 let root = TempDir::new().unwrap();
616
617 let plan = SyncPlan {
618 actions: vec![PlannedAction::KeepLocal {
619 item_id: ItemId {
620 kind: ItemKind::Agent,
621 name: "modified".into(),
622 },
623 dest_path: "agents/modified.md".into(),
624 source_name: "base".into(),
625 }],
626 };
627
628 let options = SyncOptions::default();
629
630 let result = execute(root.path(), &plan, &options).unwrap();
631 assert!(matches!(result.outcomes[0].action, ActionTaken::Kept));
632 assert_eq!(
633 result.outcomes[0].dest_path,
634 crate::types::DestPath::from("agents/modified.md")
635 );
636 assert_eq!(result.outcomes[0].source_name, "base");
637 }
638
639 #[test]
642 fn install_skill_directory() {
643 let root = TempDir::new().unwrap();
644 let source_dir = TempDir::new().unwrap();
645
646 let source_skill = source_dir.path().join("skills/planning");
648 fs::create_dir_all(&source_skill).unwrap();
649 fs::write(source_skill.join("SKILL.md"), b"# Planning skill").unwrap();
650 fs::write(source_skill.join("helper.md"), b"# Helper").unwrap();
651
652 let skill_hash = hash::compute_hash(&source_skill, ItemKind::Skill).unwrap();
653
654 let target = TargetItem {
655 id: ItemId {
656 kind: ItemKind::Skill,
657 name: "planning".into(),
658 },
659 source_name: "test".into(),
660 source_path: source_skill,
661 dest_path: "skills/planning".into(),
662 source_hash: skill_hash.into(),
663 is_flat_skill: false,
664 rewritten_content: None,
665 };
666
667 let plan = SyncPlan {
668 actions: vec![PlannedAction::Install { target }],
669 };
670
671 let options = SyncOptions::default();
672
673 let result = execute(root.path(), &plan, &options).unwrap();
674 assert!(matches!(result.outcomes[0].action, ActionTaken::Installed));
675
676 let installed_dir = root.path().join("skills/planning");
677 assert!(installed_dir.exists());
678 assert!(installed_dir.join("SKILL.md").exists());
679 assert!(installed_dir.join("helper.md").exists());
680 assert_eq!(
681 fs::read_to_string(installed_dir.join("SKILL.md")).unwrap(),
682 "# Planning skill"
683 );
684 }
685
686 #[test]
687 fn install_flat_skill_excludes_repo_metadata() {
688 let root = TempDir::new().unwrap();
689 let source_dir = TempDir::new().unwrap();
690
691 let flat_source = source_dir.path().join("flat-skill");
692 fs::create_dir_all(flat_source.join(".git")).unwrap();
693 fs::create_dir_all(flat_source.join("resources")).unwrap();
694 fs::write(flat_source.join("SKILL.md"), b"# Flat skill").unwrap();
695 fs::write(flat_source.join("resources/guide.md"), b"# Guide").unwrap();
696 fs::write(flat_source.join("mars.toml"), b"[sources]").unwrap();
697 fs::write(flat_source.join(".gitignore"), b"target/").unwrap();
698 fs::write(flat_source.join(".git/config"), b"[core]").unwrap();
699
700 let source_hash = hash::compute_skill_hash_filtered(
701 &flat_source,
702 crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL,
703 )
704 .unwrap();
705
706 let target = TargetItem {
707 id: ItemId {
708 kind: ItemKind::Skill,
709 name: "flat-skill".into(),
710 },
711 source_name: "test".into(),
712 source_path: flat_source,
713 dest_path: "skills/flat-skill".into(),
714 source_hash: source_hash.into(),
715 is_flat_skill: true,
716 rewritten_content: None,
717 };
718
719 let plan = SyncPlan {
720 actions: vec![PlannedAction::Install { target }],
721 };
722
723 let options = SyncOptions::default();
724
725 execute(root.path(), &plan, &options).unwrap();
726
727 let installed = root.path().join("skills/flat-skill");
728 assert!(installed.join("SKILL.md").exists());
729 assert!(installed.join("resources/guide.md").exists());
730 assert!(!installed.join(".git").exists());
731 assert!(!installed.join("mars.toml").exists());
732 assert!(!installed.join(".gitignore").exists());
733 }
734
735 #[test]
738 fn extract_agent_name() {
739 assert_eq!(
740 crate::types::DestPath::from("agents/coder.md").item_name(ItemKind::Agent),
741 "coder"
742 );
743 }
744
745 #[test]
746 fn extract_skill_name() {
747 assert_eq!(
748 crate::types::DestPath::from("skills/planning").item_name(ItemKind::Skill),
749 "planning"
750 );
751 }
752}