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