1use std::collections::HashSet;
9use std::path::Path;
10
11use crate::diagnostic::DiagnosticCollector;
12use crate::error::MarsError;
13use crate::reconcile::fs_ops;
14use crate::sync::apply::{ActionOutcome, ActionTaken};
15use crate::types::ContentHash;
16
17#[derive(Debug, Clone)]
19pub struct ManagedTarget {
20 pub path: String,
22}
23
24#[derive(Debug, Clone)]
26pub struct TargetSyncOutcome {
27 pub target: String,
29 pub items_synced: usize,
31 pub items_removed: usize,
33 pub errors: Vec<String>,
35}
36
37pub fn sync_managed_targets(
46 project_root: &Path,
47 mars_dir: &Path,
48 targets: &[String],
49 outcomes: &[ActionOutcome],
50 previous_managed_paths: &HashSet<String>,
51 force: bool,
52 diag: &mut DiagnosticCollector,
53) -> Vec<TargetSyncOutcome> {
54 let mut results = Vec::new();
55
56 for target_name in targets {
57 let target_root = project_root.join(target_name);
58 match sync_one_target(
59 mars_dir,
60 &target_root,
61 target_name,
62 outcomes,
63 previous_managed_paths,
64 force,
65 diag,
66 ) {
67 Ok(outcome) => {
68 if !outcome.errors.is_empty() {
69 for err in &outcome.errors {
70 diag.warn(
71 "target-sync-error",
72 format!("target `{target_name}`: {err}"),
73 );
74 }
75 }
76 results.push(outcome);
77 }
78 Err(e) => {
79 diag.warn(
80 "target-sync-failed",
81 format!("target `{target_name}` sync failed: {e}"),
82 );
83 results.push(TargetSyncOutcome {
84 target: target_name.clone(),
85 items_synced: 0,
86 items_removed: 0,
87 errors: vec![e.to_string()],
88 });
89 }
90 }
91 }
92
93 results
94}
95
96fn sync_one_target(
98 mars_dir: &Path,
99 target_root: &Path,
100 target_name: &str,
101 outcomes: &[ActionOutcome],
102 previous_managed_paths: &HashSet<String>,
103 force: bool,
104 diag: &mut DiagnosticCollector,
105) -> Result<TargetSyncOutcome, MarsError> {
106 let mut items_synced = 0;
107 let mut items_removed = 0;
108 let mut errors = Vec::new();
109
110 std::fs::create_dir_all(target_root)?;
112
113 let mut expected_paths: HashSet<String> = HashSet::new();
115 let native_skill_variant_key = crate::target::TargetRegistry::new()
116 .get(target_name)
117 .and_then(|adapter| adapter.skill_variant_key())
118 .map(str::to_owned);
119
120 for outcome in outcomes {
121 if outcome.item_id.kind == crate::lock::ItemKind::BootstrapDoc {
122 continue;
126 }
127 let dest_rel = outcome.dest_path.as_str();
128 match &outcome.action {
129 ActionTaken::Removed => {
130 let target_path = target_root.join(dest_rel);
132 if target_path.exists() || target_path.symlink_metadata().is_ok() {
133 if let Err(e) = fs_ops::safe_remove(&target_path) {
134 errors.push(format!("failed to remove {dest_rel}: {e}"));
135 } else {
136 items_removed += 1;
137 }
138 }
139 }
140 ActionTaken::Skipped => {
141 expected_paths.insert(dest_rel.to_string());
143 let source = mars_dir.join(dest_rel);
144 let dest = target_root.join(dest_rel);
145 if source.exists() || source.symlink_metadata().is_ok() {
146 let should_refresh_native_skill = outcome.item_id.kind
147 == crate::lock::ItemKind::Skill
148 && native_skill_variant_key.is_some();
149 if force || !dest.exists() || should_refresh_native_skill {
150 let previous_target_hash = if should_refresh_native_skill && dest.exists() {
151 crate::hash::compute_hash(&dest, outcome.item_id.kind).ok()
152 } else {
153 None
154 };
155 match copy_item_to_target(
156 &source,
157 &dest,
158 outcome.item_id.kind,
159 outcome.item_id.name.as_str(),
160 native_skill_variant_key.as_deref(),
161 diag,
162 ) {
163 Ok(()) => {
164 items_synced += 1;
165 if let Some(previous_target_hash) = previous_target_hash
166 && let Ok(current_target_hash) =
167 crate::hash::compute_hash(&dest, outcome.item_id.kind)
168 && previous_target_hash != current_target_hash
169 {
170 diag.warn(
171 "target-native-projection-repaired",
172 format!(
173 "repaired diverged native projection: {target_name}/{dest_rel}/SKILL.md"
174 ),
175 );
176 }
177 }
178 Err(e) => errors.push(format!("failed to copy {dest_rel}: {e}")),
179 }
180 } else if native_skill_variant_key.is_none()
181 && let Some(expected_checksum) = &outcome.installed_checksum
182 {
183 match crate::hash::compute_hash(&dest, outcome.item_id.kind) {
184 Ok(actual) => {
185 let actual = ContentHash::from(actual);
186 if &actual != expected_checksum {
187 diag.warn(
188 "target-divergent",
189 format!(
190 "target `{target_name}` item `{}` diverged from `.mars` (preserved local content; run `mars sync --force` or `mars repair` to reset)",
191 dest_rel
192 ),
193 );
194 }
195 }
196 Err(e) => {
197 errors.push(format!("failed to verify {dest_rel} checksum: {e}"))
198 }
199 }
200 }
201 }
202 }
203 _ => {
204 expected_paths.insert(dest_rel.to_string());
207 let source = mars_dir.join(dest_rel);
208 let dest = target_root.join(dest_rel);
209 if source.exists() || source.symlink_metadata().is_ok() {
210 match copy_item_to_target(
211 &source,
212 &dest,
213 outcome.item_id.kind,
214 outcome.item_id.name.as_str(),
215 native_skill_variant_key.as_deref(),
216 diag,
217 ) {
218 Ok(()) => items_synced += 1,
219 Err(e) => errors.push(format!("failed to copy {dest_rel}: {e}")),
220 }
221 }
222 }
223 }
224 }
225
226 let orphan_removed = cleanup_orphans(
228 target_root,
229 &expected_paths,
230 previous_managed_paths,
231 &mut errors,
232 );
233 items_removed += orphan_removed;
234
235 Ok(TargetSyncOutcome {
236 target: target_name.to_string(),
237 items_synced,
238 items_removed,
239 errors,
240 })
241}
242
243fn copy_item_to_target(
248 source: &Path,
249 dest: &Path,
250 kind: crate::lock::ItemKind,
251 item_name: &str,
252 native_skill_variant_key: Option<&str>,
253 diag: &mut DiagnosticCollector,
254) -> Result<(), MarsError> {
255 if kind == crate::lock::ItemKind::Skill && native_skill_variant_key.is_some() {
256 crate::compiler::variants::validate_skill_variants(source, item_name, diag);
257 return crate::compiler::variants::project_skill_for_target(
258 source,
259 dest,
260 native_skill_variant_key,
261 diag,
262 item_name,
263 );
264 }
265
266 if let Some(parent) = dest.parent() {
268 std::fs::create_dir_all(parent)?;
269 }
270
271 let metadata = std::fs::metadata(source)?;
273
274 if metadata.is_dir() {
275 fs_ops::atomic_copy_dir(source, dest)?;
276 } else if metadata.is_file() {
277 fs_ops::atomic_copy_file(source, dest)?;
278 }
279
280 Ok(())
281}
282
283fn cleanup_orphans(
292 target_root: &Path,
293 expected: &HashSet<String>,
294 previous_managed_paths: &HashSet<String>,
295 errors: &mut Vec<String>,
296) -> usize {
297 let mut removed = 0;
298
299 for managed_path in previous_managed_paths {
302 if expected.contains(managed_path) {
303 continue;
304 }
305
306 let full_path = target_root.join(managed_path);
307
308 if !full_path.exists() && full_path.symlink_metadata().is_err() {
310 continue;
311 }
312
313 if full_path
315 .symlink_metadata()
316 .map(|m| m.file_type().is_symlink())
317 .unwrap_or(false)
318 {
319 continue;
320 }
321
322 if let Err(e) = fs_ops::safe_remove(&full_path) {
323 errors.push(format!("failed to remove orphan {managed_path}: {e}"));
324 } else {
325 removed += 1;
326 }
327 }
328
329 removed
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335 use crate::diagnostic::DiagnosticCollector;
336 use crate::hash;
337 use crate::sync::apply::{ActionOutcome, ActionTaken};
338 use crate::types::{DestPath, ItemName};
339 use tempfile::TempDir;
340
341 fn make_outcome(dest: &str, action: ActionTaken) -> ActionOutcome {
342 ActionOutcome {
343 item_id: crate::lock::ItemId {
344 kind: crate::lock::ItemKind::Agent,
345 name: ItemName::from("test"),
346 },
347 action,
348 dest_path: DestPath::from(dest),
349 source_name: "test-source".into(),
350 source_checksum: None,
351 installed_checksum: None,
352 }
353 }
354
355 fn managed_paths(paths: &[&str]) -> HashSet<String> {
356 paths
357 .iter()
358 .map(|p| (*p).to_string())
359 .collect::<HashSet<String>>()
360 }
361
362 fn make_skipped_with_checksum(dest: &str, checksum: &str) -> ActionOutcome {
363 let mut outcome = make_outcome(dest, ActionTaken::Skipped);
364 outcome.installed_checksum = Some(checksum.into());
365 outcome
366 }
367
368 #[test]
369 fn sync_copies_installed_items_to_target() {
370 let dir = TempDir::new().unwrap();
371 let mars_dir = dir.path().join(".mars");
372 let target = dir.path().join(".agents");
373
374 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
376 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
377
378 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
379 let mut diag = DiagnosticCollector::new();
380
381 let results = sync_managed_targets(
382 dir.path(),
383 &mars_dir,
384 &[".agents".to_string()],
385 &outcomes,
386 &managed_paths(&[]),
387 false,
388 &mut diag,
389 );
390
391 assert_eq!(results.len(), 1);
392 assert_eq!(results[0].items_synced, 1);
393 assert!(results[0].errors.is_empty());
394 assert!(target.join("agents/coder.md").exists());
395 assert_eq!(
396 std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
397 "# Coder"
398 );
399 }
400
401 #[test]
402 fn sync_removes_items_from_target() {
403 let dir = TempDir::new().unwrap();
404 let mars_dir = dir.path().join(".mars");
405 let target = dir.path().join(".agents");
406
407 std::fs::create_dir_all(&mars_dir).unwrap();
408 std::fs::create_dir_all(target.join("agents")).unwrap();
409 std::fs::write(target.join("agents/old.md"), "# Old").unwrap();
410
411 let outcomes = vec![make_outcome("agents/old.md", ActionTaken::Removed)];
412 let mut diag = DiagnosticCollector::new();
413
414 let results = sync_managed_targets(
415 dir.path(),
416 &mars_dir,
417 &[".agents".to_string()],
418 &outcomes,
419 &managed_paths(&["agents/old.md"]),
420 false,
421 &mut diag,
422 );
423
424 assert_eq!(results[0].items_removed, 1);
425 assert!(!target.join("agents/old.md").exists());
426 }
427
428 #[test]
429 fn sync_cleans_up_previous_managed_orphans() {
430 let dir = TempDir::new().unwrap();
431 let mars_dir = dir.path().join(".mars");
432 let target = dir.path().join(".agents");
433
434 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
436 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
437
438 std::fs::create_dir_all(target.join("agents")).unwrap();
440 std::fs::write(target.join("agents/orphan.md"), "# Orphan").unwrap();
441
442 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
443 let mut diag = DiagnosticCollector::new();
444
445 let results = sync_managed_targets(
446 dir.path(),
447 &mars_dir,
448 &[".agents".to_string()],
449 &outcomes,
450 &managed_paths(&["agents/orphan.md"]),
451 false,
452 &mut diag,
453 );
454
455 assert!(target.join("agents/coder.md").exists());
456 assert!(!target.join("agents/orphan.md").exists());
457 assert_eq!(results[0].items_removed, 1);
458 }
459
460 #[test]
461 fn sync_preserves_unmanaged_files_in_target() {
462 let dir = TempDir::new().unwrap();
463 let mars_dir = dir.path().join(".mars");
464 let target = dir.path().join(".agents");
465
466 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
467 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
468
469 std::fs::create_dir_all(target.join("agents")).unwrap();
470 std::fs::write(target.join("agents/custom.md"), "# User custom").unwrap();
471
472 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
473 let mut diag = DiagnosticCollector::new();
474
475 let results = sync_managed_targets(
476 dir.path(),
477 &mars_dir,
478 &[".agents".to_string()],
479 &outcomes,
480 &managed_paths(&[]),
481 false,
482 &mut diag,
483 );
484
485 assert!(target.join("agents/coder.md").exists());
486 assert!(target.join("agents/custom.md").exists());
487 assert_eq!(results[0].items_removed, 0);
488 }
489
490 #[test]
491 fn sync_removed_agent_outcome_removes_existing_target_agent_without_copying() {
492 let dir = TempDir::new().unwrap();
493 let mars_dir = dir.path().join(".mars");
494 let target = dir.path().join(".agents");
495
496 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
497 std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
498 std::fs::create_dir_all(target.join("agents")).unwrap();
499 std::fs::write(target.join("agents/coder.md"), "# Existing target copy").unwrap();
500
501 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Removed)];
502 let mut diag = DiagnosticCollector::new();
503
504 let results = sync_managed_targets(
505 dir.path(),
506 &mars_dir,
507 &[".agents".to_string()],
508 &outcomes,
509 &managed_paths(&["agents/coder.md"]),
510 false,
511 &mut diag,
512 );
513
514 assert_eq!(results[0].items_synced, 0);
515 assert_eq!(results[0].items_removed, 1);
516 assert!(!target.join("agents/coder.md").exists());
517 assert!(results[0].errors.is_empty());
518 }
519
520 #[test]
521 fn sync_multiple_targets() {
522 let dir = TempDir::new().unwrap();
523 let mars_dir = dir.path().join(".mars");
524
525 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
526 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
527
528 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
529 let mut diag = DiagnosticCollector::new();
530
531 let results = sync_managed_targets(
532 dir.path(),
533 &mars_dir,
534 &[".agents".to_string(), ".claude".to_string()],
535 &outcomes,
536 &managed_paths(&[]),
537 false,
538 &mut diag,
539 );
540
541 assert_eq!(results.len(), 2);
542 assert!(dir.path().join(".agents/agents/coder.md").exists());
543 assert!(dir.path().join(".claude/agents/coder.md").exists());
544 }
545
546 #[test]
547 fn sync_skill_directory() {
548 let dir = TempDir::new().unwrap();
549 let mars_dir = dir.path().join(".mars");
550 let target = dir.path().join(".agents");
551
552 std::fs::create_dir_all(mars_dir.join("skills/planning")).unwrap();
553 std::fs::write(mars_dir.join("skills/planning/SKILL.md"), "# Planning").unwrap();
554
555 let mut outcome = make_outcome("skills/planning", ActionTaken::Installed);
556 outcome.item_id.kind = crate::lock::ItemKind::Skill;
557 let outcomes = vec![outcome];
558 let mut diag = DiagnosticCollector::new();
559
560 let results = sync_managed_targets(
561 dir.path(),
562 &mars_dir,
563 &[".agents".to_string()],
564 &outcomes,
565 &managed_paths(&[]),
566 false,
567 &mut diag,
568 );
569
570 assert_eq!(results[0].items_synced, 1);
571 assert!(target.join("skills/planning/SKILL.md").exists());
572 }
573
574 #[test]
575 fn sync_projects_skills_for_native_harness_targets() {
576 let dir = TempDir::new().unwrap();
577 let mars_dir = dir.path().join(".mars");
578 let target = dir.path().join(".claude");
579
580 std::fs::create_dir_all(mars_dir.join("skills/planning/resources")).unwrap();
581 std::fs::create_dir_all(mars_dir.join("skills/planning/variants/claude")).unwrap();
582 std::fs::create_dir_all(target.join("skills")).unwrap();
583 std::fs::write(target.join("skills/orphan"), "# Orphan").unwrap();
584 std::fs::write(mars_dir.join("skills/planning/SKILL.md"), "# Base").unwrap();
585 std::fs::write(
586 mars_dir.join("skills/planning/resources/BOOTSTRAP.md"),
587 "# Bootstrap",
588 )
589 .unwrap();
590 std::fs::write(
591 mars_dir.join("skills/planning/variants/claude/SKILL.md"),
592 "# Claude",
593 )
594 .unwrap();
595
596 let mut outcome = make_outcome("skills/planning", ActionTaken::Installed);
597 outcome.item_id.kind = crate::lock::ItemKind::Skill;
598 let outcomes = vec![outcome];
599 let mut diag = DiagnosticCollector::new();
600
601 let results = sync_managed_targets(
602 dir.path(),
603 &mars_dir,
604 &[".claude".to_string()],
605 &outcomes,
606 &managed_paths(&["skills/planning", "skills/orphan"]),
607 false,
608 &mut diag,
609 );
610
611 assert_eq!(results[0].items_synced, 1);
612 assert_eq!(
613 std::fs::read_to_string(target.join("skills/planning/SKILL.md")).unwrap(),
614 "# Claude"
615 );
616 assert_eq!(
617 std::fs::read_to_string(target.join("skills/planning/resources/BOOTSTRAP.md")).unwrap(),
618 "# Bootstrap"
619 );
620 assert!(!target.join("skills/planning/variants").exists());
621 assert!(!target.join("skills/orphan").exists());
622 }
623
624 #[test]
625 fn cleanup_orphans_uses_forward_slash_keys_for_expected_paths() {
626 let dir = TempDir::new().unwrap();
627 let target_root = dir.path().join(".agents");
628 std::fs::create_dir_all(target_root.join("agents")).unwrap();
629 std::fs::write(target_root.join("agents/coder.md"), "# Managed").unwrap();
630 std::fs::write(target_root.join("agents/orphan.md"), "# Orphan").unwrap();
631
632 let mut expected = HashSet::new();
633 expected.insert(
634 DestPath::new(r"agents\coder.md")
635 .unwrap()
636 .as_str()
637 .to_string(),
638 );
639
640 let removed = cleanup_orphans(
641 &target_root,
642 &expected,
643 &managed_paths(&["agents/coder.md", "agents/orphan.md"]),
644 &mut Vec::new(),
645 );
646
647 assert_eq!(removed, 1);
648 assert!(target_root.join("agents/coder.md").exists());
649 assert!(!target_root.join("agents/orphan.md").exists());
650 }
651
652 #[test]
653 fn sync_convergence_on_rerun() {
654 let dir = TempDir::new().unwrap();
655 let mars_dir = dir.path().join(".mars");
656 let target = dir.path().join(".agents");
657
658 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
659 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
660
661 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
662 let mut diag = DiagnosticCollector::new();
663
664 sync_managed_targets(
666 dir.path(),
667 &mars_dir,
668 &[".agents".to_string()],
669 &outcomes,
670 &managed_paths(&[]),
671 false,
672 &mut diag,
673 );
674
675 let outcomes2 = vec![make_outcome("agents/coder.md", ActionTaken::Skipped)];
677 let results = sync_managed_targets(
678 dir.path(),
679 &mars_dir,
680 &[".agents".to_string()],
681 &outcomes2,
682 &managed_paths(&["agents/coder.md"]),
683 false,
684 &mut diag,
685 );
686
687 assert!(target.join("agents/coder.md").exists());
688 assert_eq!(results[0].items_synced, 0);
690 }
691
692 #[test]
693 fn sync_force_refreshes_skipped_target_content() {
694 let dir = TempDir::new().unwrap();
695 let mars_dir = dir.path().join(".mars");
696 let target = dir.path().join(".agents");
697
698 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
699 std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
700
701 std::fs::create_dir_all(target.join("agents")).unwrap();
702 std::fs::write(target.join("agents/coder.md"), "# Tampered").unwrap();
703
704 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Skipped)];
705 let mut diag = DiagnosticCollector::new();
706 let results = sync_managed_targets(
707 dir.path(),
708 &mars_dir,
709 &[".agents".to_string()],
710 &outcomes,
711 &managed_paths(&["agents/coder.md"]),
712 true,
713 &mut diag,
714 );
715
716 assert_eq!(results[0].items_synced, 1);
717 assert_eq!(
718 std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
719 "# Canonical"
720 );
721 }
722
723 #[test]
724 fn sync_skipped_recopies_missing_target() {
725 let dir = TempDir::new().unwrap();
726 let mars_dir = dir.path().join(".mars");
727 let target = dir.path().join(".agents");
728
729 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
730 std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
731
732 let checksum = hash::hash_bytes(b"# Canonical");
733 let outcomes = vec![make_skipped_with_checksum("agents/coder.md", &checksum)];
734 let mut diag = DiagnosticCollector::new();
735 let results = sync_managed_targets(
736 dir.path(),
737 &mars_dir,
738 &[".agents".to_string()],
739 &outcomes,
740 &managed_paths(&["agents/coder.md"]),
741 false,
742 &mut diag,
743 );
744
745 assert_eq!(results[0].items_synced, 1);
746 assert!(target.join("agents/coder.md").exists());
747 }
748
749 #[test]
750 fn sync_skipped_warns_on_divergent_target_and_preserves_local_content() {
751 let dir = TempDir::new().unwrap();
752 let mars_dir = dir.path().join(".mars");
753 let target = dir.path().join(".agents");
754
755 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
756 std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
757
758 std::fs::create_dir_all(target.join("agents")).unwrap();
759 std::fs::write(target.join("agents/coder.md"), "# Locally edited").unwrap();
760
761 let checksum = hash::hash_bytes(b"# Canonical");
762 let outcomes = vec![make_skipped_with_checksum("agents/coder.md", &checksum)];
763 let mut diag = DiagnosticCollector::new();
764 let results = sync_managed_targets(
765 dir.path(),
766 &mars_dir,
767 &[".agents".to_string()],
768 &outcomes,
769 &managed_paths(&["agents/coder.md"]),
770 false,
771 &mut diag,
772 );
773
774 assert_eq!(results[0].items_synced, 0);
775 assert_eq!(
776 std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
777 "# Locally edited"
778 );
779
780 let diagnostics = diag.drain();
781 assert!(
782 diagnostics
783 .iter()
784 .any(|d| d.code == "target-divergent" && d.message.contains("agents/coder.md"))
785 );
786 }
787}