1use std::collections::HashSet;
9use std::path::Path;
10
11use crate::diagnostic::DiagnosticCollector;
12use crate::error::MarsError;
13use crate::lock::LockFile;
14use crate::reconcile::fs_ops;
15use crate::surface_ownership::{self, CollisionAdoptHint, SurfaceCopyDecision};
16use crate::sync::apply::{ActionOutcome, ActionTaken};
17use crate::types::ContentHash;
18use crate::types::managed_cmd;
19
20#[derive(Debug, Clone)]
22pub struct ManagedTarget {
23 pub path: String,
25}
26
27#[derive(Debug, Clone)]
29pub struct TargetSyncedOutput {
30 pub dest_path: String,
31 pub installed_checksum: ContentHash,
32}
33
34#[derive(Debug, Clone)]
36pub struct TargetSyncOutcome {
37 pub target: String,
39 pub items_synced: usize,
41 pub items_removed: usize,
43 pub errors: Vec<String>,
45 pub synced_outputs: Vec<TargetSyncedOutput>,
47 pub removed_dest_paths: Vec<String>,
49}
50
51pub struct TargetSyncContext<'a> {
53 pub old_lock: &'a LockFile,
54 pub force: bool,
55 pub collision_hint: CollisionAdoptHint,
56}
57
58pub fn sync_managed_targets(
67 project_root: &Path,
68 mars_dir: &Path,
69 targets: &[String],
70 outcomes: &[ActionOutcome],
71 ctx: &TargetSyncContext<'_>,
72 diag: &mut DiagnosticCollector,
73) -> Vec<TargetSyncOutcome> {
74 let mut results = Vec::new();
75
76 for target_name in targets {
77 let target_root = project_root.join(target_name);
78 match sync_one_target(mars_dir, &target_root, target_name, outcomes, ctx, diag) {
79 Ok(outcome) => {
80 if !outcome.errors.is_empty() {
81 for err in &outcome.errors {
82 diag.warn(
83 "target-sync-error",
84 format!("target `{target_name}`: {err}"),
85 );
86 }
87 }
88 results.push(outcome);
89 }
90 Err(e) => {
91 diag.warn(
92 "target-sync-failed",
93 format!("target `{target_name}` sync failed: {e}"),
94 );
95 results.push(TargetSyncOutcome {
96 target: target_name.clone(),
97 items_synced: 0,
98 items_removed: 0,
99 errors: vec![e.to_string()],
100 synced_outputs: Vec::new(),
101 removed_dest_paths: Vec::new(),
102 });
103 }
104 }
105 }
106
107 results
108}
109
110fn sync_one_target(
111 mars_dir: &Path,
112 target_root: &Path,
113 target_name: &str,
114 outcomes: &[ActionOutcome],
115 ctx: &TargetSyncContext<'_>,
116 diag: &mut DiagnosticCollector,
117) -> Result<TargetSyncOutcome, MarsError> {
118 let old_lock = ctx.old_lock;
119 let force = ctx.force;
120 let collision_hint = ctx.collision_hint;
121 let mut items_synced = 0;
122 let mut items_removed = 0;
123 let mut errors = Vec::new();
124 let mut synced_outputs = Vec::new();
125 let mut removed_dest_paths = Vec::new();
126 let previous_managed_paths = old_lock.output_dest_paths_for_target(target_name);
127
128 std::fs::create_dir_all(target_root)?;
129
130 let mut expected_paths: HashSet<String> = HashSet::new();
131 let target_registry = crate::target::TargetRegistry::new();
132 let target_adapter = target_registry.get(target_name);
133 let native_skill_variant_key = target_adapter
134 .and_then(|adapter| adapter.skill_variant_key())
135 .map(str::to_owned);
136 let target_accepts_canonical_agents = target_adapter
137 .map(|adapter| {
138 adapter
139 .default_dest_path(crate::lock::ItemKind::Agent, "__mars_probe__")
140 .is_some()
141 })
142 .unwrap_or(true);
143
144 for outcome in outcomes {
145 if outcome.item_id.kind == crate::lock::ItemKind::BootstrapDoc {
146 continue;
147 }
148 let dest_rel = outcome.dest_path.as_str();
149 if outcome.item_id.kind == crate::lock::ItemKind::Agent && !target_accepts_canonical_agents
150 {
151 if matches!(outcome.action, ActionTaken::Removed) {
152 let target_path = target_root.join(dest_rel);
153 if remove_target_path_if_managed(
154 &target_path,
155 target_name,
156 dest_rel,
157 old_lock,
158 &mut errors,
159 ) {
160 items_removed += 1;
161 removed_dest_paths.push(dest_rel.to_string());
162 }
163 }
164 continue;
165 }
166 match &outcome.action {
167 ActionTaken::Removed => {
168 let target_path = target_root.join(dest_rel);
169 if remove_target_path_if_managed(
170 &target_path,
171 target_name,
172 dest_rel,
173 old_lock,
174 &mut errors,
175 ) {
176 items_removed += 1;
177 removed_dest_paths.push(dest_rel.to_string());
178 }
179 }
180 ActionTaken::Skipped => {
181 expected_paths.insert(dest_rel.to_string());
182 let source = mars_dir.join(dest_rel);
183 let dest = target_root.join(dest_rel);
184 if source.exists() || source.symlink_metadata().is_ok() {
185 let should_refresh_native_skill = outcome.item_id.kind
186 == crate::lock::ItemKind::Skill
187 && native_skill_variant_key.is_some();
188 let dest_exists = surface_ownership::target_dest_exists(&dest);
189 let wants_copy = force || !dest_exists || should_refresh_native_skill;
190 if wants_copy {
191 if should_copy_to_target(
192 &dest,
193 target_name,
194 dest_rel,
195 old_lock,
196 force,
197 collision_hint,
198 diag,
199 ) {
200 let previous_target_hash = if should_refresh_native_skill && dest_exists
201 {
202 crate::hash::compute_hash(&dest, outcome.item_id.kind).ok()
203 } else {
204 None
205 };
206 match copy_item_to_target(
207 &source,
208 &dest,
209 outcome.item_id.kind,
210 outcome.item_id.name.as_str(),
211 native_skill_variant_key.as_deref(),
212 diag,
213 ) {
214 Ok(()) => {
215 items_synced += 1;
216 record_synced_output(
217 &mut synced_outputs,
218 &dest,
219 dest_rel,
220 outcome.item_id.kind,
221 );
222 if let Some(previous_target_hash) = previous_target_hash
223 && let Ok(current_target_hash) =
224 crate::hash::compute_hash(&dest, outcome.item_id.kind)
225 && previous_target_hash != current_target_hash
226 {
227 diag.warn(
228 "target-native-projection-repaired",
229 format!(
230 "repaired diverged native projection: {target_name}/{dest_rel}/SKILL.md"
231 ),
232 );
233 }
234 }
235 Err(e) => errors.push(format!("failed to copy {dest_rel}: {e}")),
236 }
237 }
238 } else if native_skill_variant_key.is_none()
239 && old_lock.contains_output(target_name, dest_rel)
240 && let Some(expected_checksum) = &outcome.installed_checksum
241 {
242 match crate::hash::compute_hash(&dest, outcome.item_id.kind) {
243 Ok(actual) => {
244 let actual = ContentHash::from(actual);
245 if &actual != expected_checksum {
246 diag.warn(
247 "target-divergent",
248 format!(
249 "target `{target_name}` item `{}` diverged from `.mars` (preserved local content; run `{cmd1}` or `{cmd2}` to reset)",
250 dest_rel,
251 cmd1 = managed_cmd("mars sync --force"),
252 cmd2 = managed_cmd("mars repair"),
253 ),
254 );
255 }
256 }
257 Err(e) => {
258 errors.push(format!("failed to verify {dest_rel} checksum: {e}"))
259 }
260 }
261 } else if dest_exists && !old_lock.contains_output(target_name, dest_rel) {
262 surface_ownership::warn_unmanaged_collision(
263 target_name,
264 dest_rel,
265 collision_hint,
266 diag,
267 );
268 }
269 }
270 }
271 _ => {
272 expected_paths.insert(dest_rel.to_string());
273 let source = mars_dir.join(dest_rel);
274 let dest = target_root.join(dest_rel);
275 if (source.exists() || source.symlink_metadata().is_ok())
276 && should_copy_to_target(
277 &dest,
278 target_name,
279 dest_rel,
280 old_lock,
281 force,
282 collision_hint,
283 diag,
284 )
285 {
286 match copy_item_to_target(
287 &source,
288 &dest,
289 outcome.item_id.kind,
290 outcome.item_id.name.as_str(),
291 native_skill_variant_key.as_deref(),
292 diag,
293 ) {
294 Ok(()) => {
295 items_synced += 1;
296 record_synced_output(
297 &mut synced_outputs,
298 &dest,
299 dest_rel,
300 outcome.item_id.kind,
301 );
302 }
303 Err(e) => errors.push(format!("failed to copy {dest_rel}: {e}")),
304 }
305 }
306 }
307 }
308 }
309
310 let orphan_removed = cleanup_orphans(
311 target_root,
312 &expected_paths,
313 &previous_managed_paths,
314 &mut removed_dest_paths,
315 &mut errors,
316 );
317 items_removed += orphan_removed;
318
319 Ok(TargetSyncOutcome {
320 target: target_name.to_string(),
321 items_synced,
322 items_removed,
323 errors,
324 synced_outputs,
325 removed_dest_paths,
326 })
327}
328
329fn should_copy_to_target(
330 dest: &Path,
331 target_name: &str,
332 dest_rel: &str,
333 old_lock: &LockFile,
334 force: bool,
335 collision_hint: CollisionAdoptHint,
336 diag: &mut DiagnosticCollector,
337) -> bool {
338 let dest_exists = surface_ownership::target_dest_exists(dest);
339 match surface_ownership::copy_decision(old_lock, target_name, dest_rel, dest_exists, force) {
340 SurfaceCopyDecision::Proceed => {
341 if dest_exists && force && !old_lock.contains_output(target_name, dest_rel) {
342 surface_ownership::warn_unmanaged_adopted(
343 target_name,
344 dest_rel,
345 collision_hint,
346 diag,
347 );
348 }
349 true
350 }
351 SurfaceCopyDecision::SkipUnmanagedCollision => {
352 surface_ownership::warn_unmanaged_collision(
353 target_name,
354 dest_rel,
355 collision_hint,
356 diag,
357 );
358 false
359 }
360 }
361}
362
363fn remove_target_path_if_managed(
364 target_path: &Path,
365 target_name: &str,
366 dest_rel: &str,
367 old_lock: &LockFile,
368 errors: &mut Vec<String>,
369) -> bool {
370 if !surface_ownership::target_dest_exists(target_path) {
371 return false;
372 }
373 if !surface_ownership::may_delete(old_lock, target_name, dest_rel) {
374 return false;
375 }
376 match fs_ops::safe_remove(target_path) {
377 Ok(()) => true,
378 Err(e) => {
379 errors.push(format!("failed to remove {dest_rel}: {e}"));
380 false
381 }
382 }
383}
384
385fn record_synced_output(
386 synced_outputs: &mut Vec<TargetSyncedOutput>,
387 dest: &Path,
388 dest_rel: &str,
389 kind: crate::lock::ItemKind,
390) {
391 if let Ok(checksum) = crate::hash::compute_hash(dest, kind) {
392 synced_outputs.push(TargetSyncedOutput {
393 dest_path: dest_rel.to_string(),
394 installed_checksum: ContentHash::from(checksum),
395 });
396 }
397}
398
399fn copy_item_to_target(
404 source: &Path,
405 dest: &Path,
406 kind: crate::lock::ItemKind,
407 item_name: &str,
408 native_skill_variant_key: Option<&str>,
409 diag: &mut DiagnosticCollector,
410) -> Result<(), MarsError> {
411 if kind == crate::lock::ItemKind::Skill && native_skill_variant_key.is_some() {
412 crate::compiler::variants::validate_skill_variants(source, item_name, diag);
413 return crate::compiler::variants::project_skill_for_target(
414 source,
415 dest,
416 native_skill_variant_key,
417 diag,
418 item_name,
419 );
420 }
421
422 if let Some(parent) = dest.parent() {
424 std::fs::create_dir_all(parent)?;
425 }
426
427 let metadata = std::fs::metadata(source)?;
429
430 if metadata.is_dir() {
431 fs_ops::atomic_copy_dir(source, dest)?;
432 } else if metadata.is_file() {
433 fs_ops::atomic_copy_file(source, dest)?;
434 }
435
436 Ok(())
437}
438
439fn cleanup_orphans(
448 target_root: &Path,
449 expected: &HashSet<String>,
450 previous_managed_paths: &HashSet<String>,
451 removed_dest_paths: &mut Vec<String>,
452 errors: &mut Vec<String>,
453) -> usize {
454 let mut removed = 0;
455
456 for managed_path in previous_managed_paths {
459 if expected.contains(managed_path) {
460 continue;
461 }
462
463 let full_path = target_root.join(managed_path);
464
465 if !full_path.exists() && full_path.symlink_metadata().is_err() {
467 continue;
468 }
469
470 if full_path
472 .symlink_metadata()
473 .map(|m| m.file_type().is_symlink())
474 .unwrap_or(false)
475 {
476 continue;
477 }
478
479 if let Err(e) = fs_ops::safe_remove(&full_path) {
480 errors.push(format!("failed to remove orphan {managed_path}: {e}"));
481 } else {
482 removed += 1;
483 removed_dest_paths.push(managed_path.clone());
484 }
485 }
486
487 removed
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493 use crate::diagnostic::DiagnosticCollector;
494 use crate::hash;
495 use crate::lock::{ItemKind, LockFile, LockedItemV2, OutputRecord};
496 use crate::surface_ownership::CollisionAdoptHint;
497 use crate::sync::apply::{ActionOutcome, ActionTaken};
498 use crate::types::{DestPath, ItemName};
499 use tempfile::TempDir;
500
501 fn make_outcome(dest: &str, action: ActionTaken) -> ActionOutcome {
502 ActionOutcome {
503 item_id: crate::lock::ItemId {
504 kind: crate::lock::ItemKind::Agent,
505 name: ItemName::from("test"),
506 },
507 action,
508 dest_path: DestPath::from(dest),
509 source_name: "test-source".into(),
510 source_checksum: None,
511 installed_checksum: None,
512 }
513 }
514
515 fn lock_with_target_outputs(target: &str, outputs: &[(&str, &str)]) -> LockFile {
516 let mut lock = LockFile::empty();
517 for (dest, checksum) in outputs {
518 let name = dest.rsplit('/').next().unwrap_or("item");
519 lock.items.insert(
520 format!("agent/{name}"),
521 LockedItemV2 {
522 source: "test".into(),
523 kind: ItemKind::Agent,
524 version: None,
525 source_checksum: "sha256:src".into(),
526 outputs: vec![OutputRecord {
527 target_root: target.to_string(),
528 dest_path: (*dest).into(),
529 installed_checksum: (*checksum).into(),
530 }],
531 },
532 );
533 }
534 lock
535 }
536
537 fn lock_with_skill_target_outputs(target: &str, outputs: &[(&str, &str)]) -> LockFile {
538 let mut lock = LockFile::empty();
539 for (dest, checksum) in outputs {
540 let name = dest.rsplit('/').next().unwrap_or("item");
541 lock.items.insert(
542 format!("skill/{name}"),
543 LockedItemV2 {
544 source: "test".into(),
545 kind: ItemKind::Skill,
546 version: None,
547 source_checksum: "sha256:src".into(),
548 outputs: vec![OutputRecord {
549 target_root: target.to_string(),
550 dest_path: (*dest).into(),
551 installed_checksum: (*checksum).into(),
552 }],
553 },
554 );
555 }
556 lock
557 }
558
559 fn target_sync_ctx<'a>(old_lock: &'a LockFile, force: bool) -> TargetSyncContext<'a> {
560 TargetSyncContext {
561 old_lock,
562 force,
563 collision_hint: CollisionAdoptHint::SyncForce,
564 }
565 }
566
567 fn make_skipped_with_checksum(dest: &str, checksum: &str) -> ActionOutcome {
568 let mut outcome = make_outcome(dest, ActionTaken::Skipped);
569 outcome.installed_checksum = Some(checksum.into());
570 outcome
571 }
572
573 #[test]
574 fn sync_copies_installed_items_to_target() {
575 let dir = TempDir::new().unwrap();
576 let mars_dir = dir.path().join(".mars");
577 let target = dir.path().join(".agents");
578
579 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
581 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
582
583 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
584 let mut diag = DiagnosticCollector::new();
585
586 let results = sync_managed_targets(
587 dir.path(),
588 &mars_dir,
589 &[".agents".to_string()],
590 &outcomes,
591 &target_sync_ctx(&LockFile::empty(), false),
592 &mut diag,
593 );
594
595 assert_eq!(results.len(), 1);
596 assert_eq!(results[0].items_synced, 1);
597 assert!(results[0].errors.is_empty());
598 assert!(target.join("agents/coder.md").exists());
599 assert_eq!(
600 std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
601 "# Coder"
602 );
603 }
604
605 #[test]
606 fn sync_removes_items_from_target() {
607 let dir = TempDir::new().unwrap();
608 let mars_dir = dir.path().join(".mars");
609 let target = dir.path().join(".agents");
610
611 std::fs::create_dir_all(&mars_dir).unwrap();
612 std::fs::create_dir_all(target.join("agents")).unwrap();
613 std::fs::write(target.join("agents/old.md"), "# Old").unwrap();
614
615 let outcomes = vec![make_outcome("agents/old.md", ActionTaken::Removed)];
616 let mut diag = DiagnosticCollector::new();
617
618 let results = sync_managed_targets(
619 dir.path(),
620 &mars_dir,
621 &[".agents".to_string()],
622 &outcomes,
623 &target_sync_ctx(
624 &lock_with_target_outputs(".agents", &[("agents/old.md", "sha256:old")]),
625 false,
626 ),
627 &mut diag,
628 );
629
630 assert_eq!(results[0].items_removed, 1);
631 assert!(!target.join("agents/old.md").exists());
632 }
633
634 #[test]
635 fn sync_cleans_up_previous_managed_orphans() {
636 let dir = TempDir::new().unwrap();
637 let mars_dir = dir.path().join(".mars");
638 let target = dir.path().join(".agents");
639
640 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
642 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
643
644 std::fs::create_dir_all(target.join("agents")).unwrap();
646 std::fs::write(target.join("agents/orphan.md"), "# Orphan").unwrap();
647
648 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
649 let mut diag = DiagnosticCollector::new();
650
651 let results = sync_managed_targets(
652 dir.path(),
653 &mars_dir,
654 &[".agents".to_string()],
655 &outcomes,
656 &target_sync_ctx(
657 &lock_with_target_outputs(".agents", &[("agents/orphan.md", "sha256:orphan")]),
658 false,
659 ),
660 &mut diag,
661 );
662
663 assert!(target.join("agents/coder.md").exists());
664 assert!(!target.join("agents/orphan.md").exists());
665 assert_eq!(results[0].items_removed, 1);
666 }
667
668 #[test]
669 fn sync_preserves_unmanaged_files_in_target() {
670 let dir = TempDir::new().unwrap();
671 let mars_dir = dir.path().join(".mars");
672 let target = dir.path().join(".agents");
673
674 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
675 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
676
677 std::fs::create_dir_all(target.join("agents")).unwrap();
678 std::fs::write(target.join("agents/custom.md"), "# User custom").unwrap();
679
680 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
681 let mut diag = DiagnosticCollector::new();
682
683 let results = sync_managed_targets(
684 dir.path(),
685 &mars_dir,
686 &[".agents".to_string()],
687 &outcomes,
688 &target_sync_ctx(&LockFile::empty(), false),
689 &mut diag,
690 );
691
692 assert!(target.join("agents/coder.md").exists());
693 assert!(target.join("agents/custom.md").exists());
694 assert_eq!(results[0].items_removed, 0);
695 }
696
697 #[test]
698 fn sync_removed_agent_outcome_removes_existing_target_agent_without_copying() {
699 let dir = TempDir::new().unwrap();
700 let mars_dir = dir.path().join(".mars");
701 let target = dir.path().join(".agents");
702
703 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
704 std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
705 std::fs::create_dir_all(target.join("agents")).unwrap();
706 std::fs::write(target.join("agents/coder.md"), "# Existing target copy").unwrap();
707
708 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Removed)];
709 let mut diag = DiagnosticCollector::new();
710
711 let results = sync_managed_targets(
712 dir.path(),
713 &mars_dir,
714 &[".agents".to_string()],
715 &outcomes,
716 &target_sync_ctx(
717 &lock_with_target_outputs(".agents", &[("agents/coder.md", "sha256:coder")]),
718 false,
719 ),
720 &mut diag,
721 );
722
723 assert_eq!(results[0].items_synced, 0);
724 assert_eq!(results[0].items_removed, 1);
725 assert!(!target.join("agents/coder.md").exists());
726 assert!(results[0].errors.is_empty());
727 }
728
729 #[test]
730 fn sync_multiple_targets() {
731 let dir = TempDir::new().unwrap();
732 let mars_dir = dir.path().join(".mars");
733
734 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
735 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
736
737 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
738 let mut diag = DiagnosticCollector::new();
739
740 let results = sync_managed_targets(
741 dir.path(),
742 &mars_dir,
743 &[".agents".to_string(), ".custom-target".to_string()],
744 &outcomes,
745 &target_sync_ctx(&LockFile::empty(), false),
746 &mut diag,
747 );
748
749 assert_eq!(results.len(), 2);
750 assert!(dir.path().join(".agents/agents/coder.md").exists());
751 assert!(dir.path().join(".custom-target/agents/coder.md").exists());
752 }
753
754 #[test]
755 fn sync_native_targets_skip_canonical_agent_markdown_copies() {
756 let dir = TempDir::new().unwrap();
757 let mars_dir = dir.path().join(".mars");
758
759 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
760 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
761
762 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
763 let mut diag = DiagnosticCollector::new();
764
765 let results = sync_managed_targets(
766 dir.path(),
767 &mars_dir,
768 &[
769 ".claude".to_string(),
770 ".codex".to_string(),
771 ".opencode".to_string(),
772 ".pi".to_string(),
773 ],
774 &outcomes,
775 &target_sync_ctx(&LockFile::empty(), false),
776 &mut diag,
777 );
778
779 assert_eq!(results.len(), 4);
780 assert!(results.iter().all(|outcome| outcome.items_synced == 0));
781 assert!(!dir.path().join(".claude/agents/coder.md").exists());
782 assert!(!dir.path().join(".codex/agents/coder.md").exists());
783 assert!(!dir.path().join(".opencode/agents/coder.md").exists());
784 assert!(!dir.path().join(".pi/agents/coder.md").exists());
785 }
786
787 #[test]
788 fn sync_unknown_target_still_copies_canonical_agents() {
789 let dir = TempDir::new().unwrap();
790 let mars_dir = dir.path().join(".mars");
791
792 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
793 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
794
795 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
796 let mut diag = DiagnosticCollector::new();
797
798 let results = sync_managed_targets(
799 dir.path(),
800 &mars_dir,
801 &[".custom-target".to_string()],
802 &outcomes,
803 &target_sync_ctx(&LockFile::empty(), false),
804 &mut diag,
805 );
806
807 assert_eq!(results[0].items_synced, 1);
808 assert!(dir.path().join(".custom-target/agents/coder.md").exists());
809 }
810
811 #[test]
812 fn sync_skill_directory() {
813 let dir = TempDir::new().unwrap();
814 let mars_dir = dir.path().join(".mars");
815 let target = dir.path().join(".agents");
816
817 std::fs::create_dir_all(mars_dir.join("skills/planning")).unwrap();
818 std::fs::write(mars_dir.join("skills/planning/SKILL.md"), "# Planning").unwrap();
819
820 let mut outcome = make_outcome("skills/planning", ActionTaken::Installed);
821 outcome.item_id.kind = crate::lock::ItemKind::Skill;
822 let outcomes = vec![outcome];
823 let mut diag = DiagnosticCollector::new();
824
825 let results = sync_managed_targets(
826 dir.path(),
827 &mars_dir,
828 &[".agents".to_string()],
829 &outcomes,
830 &target_sync_ctx(&LockFile::empty(), false),
831 &mut diag,
832 );
833
834 assert_eq!(results[0].items_synced, 1);
835 assert!(target.join("skills/planning/SKILL.md").exists());
836 }
837
838 #[test]
839 fn sync_projects_skills_for_native_harness_targets() {
840 let dir = TempDir::new().unwrap();
841 let mars_dir = dir.path().join(".mars");
842 let target = dir.path().join(".claude");
843
844 std::fs::create_dir_all(mars_dir.join("skills/planning/resources")).unwrap();
845 std::fs::create_dir_all(mars_dir.join("skills/planning/variants/claude")).unwrap();
846 std::fs::create_dir_all(target.join("skills")).unwrap();
847 std::fs::write(target.join("skills/orphan"), "# Orphan").unwrap();
848 std::fs::write(mars_dir.join("skills/planning/SKILL.md"), "# Base").unwrap();
849 std::fs::write(
850 mars_dir.join("skills/planning/resources/BOOTSTRAP.md"),
851 "# Bootstrap",
852 )
853 .unwrap();
854 std::fs::write(
855 mars_dir.join("skills/planning/variants/claude/SKILL.md"),
856 "# Claude",
857 )
858 .unwrap();
859
860 let mut outcome = make_outcome("skills/planning", ActionTaken::Installed);
861 outcome.item_id.kind = crate::lock::ItemKind::Skill;
862 let outcomes = vec![outcome];
863 let mut diag = DiagnosticCollector::new();
864
865 let results = sync_managed_targets(
866 dir.path(),
867 &mars_dir,
868 &[".claude".to_string()],
869 &outcomes,
870 &target_sync_ctx(
871 &lock_with_skill_target_outputs(
872 ".claude",
873 &[
874 ("skills/planning", "sha256:planning"),
875 ("skills/orphan", "sha256:orphan"),
876 ],
877 ),
878 false,
879 ),
880 &mut diag,
881 );
882
883 assert_eq!(results[0].items_synced, 1);
884 assert_eq!(
885 std::fs::read_to_string(target.join("skills/planning/SKILL.md")).unwrap(),
886 "# Claude"
887 );
888 assert_eq!(
889 std::fs::read_to_string(target.join("skills/planning/resources/BOOTSTRAP.md")).unwrap(),
890 "# Bootstrap"
891 );
892 assert!(!target.join("skills/planning/variants").exists());
893 assert!(!target.join("skills/orphan").exists());
894 }
895
896 #[test]
897 fn cleanup_orphans_uses_forward_slash_keys_for_expected_paths() {
898 let dir = TempDir::new().unwrap();
899 let target_root = dir.path().join(".agents");
900 std::fs::create_dir_all(target_root.join("agents")).unwrap();
901 std::fs::write(target_root.join("agents/coder.md"), "# Managed").unwrap();
902 std::fs::write(target_root.join("agents/orphan.md"), "# Orphan").unwrap();
903
904 let mut expected = HashSet::new();
905 expected.insert(
906 DestPath::new(r"agents\coder.md")
907 .unwrap()
908 .as_str()
909 .to_string(),
910 );
911
912 let previous = lock_with_target_outputs(
913 ".agents",
914 &[
915 ("agents/coder.md", "sha256:coder"),
916 ("agents/orphan.md", "sha256:orphan"),
917 ],
918 );
919 let previous_paths = previous.output_dest_paths_for_target(".agents");
920 let mut removed_dest_paths = Vec::new();
921 let removed = cleanup_orphans(
922 &target_root,
923 &expected,
924 &previous_paths,
925 &mut removed_dest_paths,
926 &mut Vec::new(),
927 );
928
929 assert_eq!(removed, 1);
930 assert!(target_root.join("agents/coder.md").exists());
931 assert!(!target_root.join("agents/orphan.md").exists());
932 }
933
934 #[test]
935 fn sync_convergence_on_rerun() {
936 let dir = TempDir::new().unwrap();
937 let mars_dir = dir.path().join(".mars");
938 let target = dir.path().join(".agents");
939
940 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
941 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
942
943 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
944 let mut diag = DiagnosticCollector::new();
945
946 sync_managed_targets(
948 dir.path(),
949 &mars_dir,
950 &[".agents".to_string()],
951 &outcomes,
952 &target_sync_ctx(&LockFile::empty(), false),
953 &mut diag,
954 );
955
956 let outcomes2 = vec![make_outcome("agents/coder.md", ActionTaken::Skipped)];
958 let results = sync_managed_targets(
959 dir.path(),
960 &mars_dir,
961 &[".agents".to_string()],
962 &outcomes2,
963 &target_sync_ctx(
964 &lock_with_target_outputs(".agents", &[("agents/coder.md", "sha256:coder")]),
965 false,
966 ),
967 &mut diag,
968 );
969
970 assert!(target.join("agents/coder.md").exists());
971 assert_eq!(results[0].items_synced, 0);
973 }
974
975 #[test]
976 fn sync_force_refreshes_skipped_target_content() {
977 let dir = TempDir::new().unwrap();
978 let mars_dir = dir.path().join(".mars");
979 let target = dir.path().join(".agents");
980
981 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
982 std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
983
984 std::fs::create_dir_all(target.join("agents")).unwrap();
985 std::fs::write(target.join("agents/coder.md"), "# Tampered").unwrap();
986
987 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Skipped)];
988 let mut diag = DiagnosticCollector::new();
989 let results = sync_managed_targets(
990 dir.path(),
991 &mars_dir,
992 &[".agents".to_string()],
993 &outcomes,
994 &target_sync_ctx(
995 &lock_with_target_outputs(".agents", &[("agents/coder.md", "sha256:coder")]),
996 true,
997 ),
998 &mut diag,
999 );
1000
1001 assert_eq!(results[0].items_synced, 1);
1002 assert_eq!(
1003 std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
1004 "# Canonical"
1005 );
1006 }
1007
1008 #[test]
1009 fn sync_skipped_recopies_missing_target() {
1010 let dir = TempDir::new().unwrap();
1011 let mars_dir = dir.path().join(".mars");
1012 let target = dir.path().join(".agents");
1013
1014 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
1015 std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
1016
1017 let checksum = hash::hash_bytes(b"# Canonical");
1018 let outcomes = vec![make_skipped_with_checksum("agents/coder.md", &checksum)];
1019 let mut diag = DiagnosticCollector::new();
1020 let results = sync_managed_targets(
1021 dir.path(),
1022 &mars_dir,
1023 &[".agents".to_string()],
1024 &outcomes,
1025 &target_sync_ctx(
1026 &lock_with_target_outputs(".agents", &[("agents/coder.md", "sha256:coder")]),
1027 false,
1028 ),
1029 &mut diag,
1030 );
1031
1032 assert_eq!(results[0].items_synced, 1);
1033 assert!(target.join("agents/coder.md").exists());
1034 }
1035
1036 #[test]
1037 fn sync_skipped_warns_on_divergent_target_and_preserves_local_content() {
1038 let dir = TempDir::new().unwrap();
1039 let mars_dir = dir.path().join(".mars");
1040 let target = dir.path().join(".agents");
1041
1042 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
1043 std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
1044
1045 std::fs::create_dir_all(target.join("agents")).unwrap();
1046 std::fs::write(target.join("agents/coder.md"), "# Locally edited").unwrap();
1047
1048 let checksum = hash::hash_bytes(b"# Canonical");
1049 let outcomes = vec![make_skipped_with_checksum("agents/coder.md", &checksum)];
1050 let mut diag = DiagnosticCollector::new();
1051 let results = sync_managed_targets(
1052 dir.path(),
1053 &mars_dir,
1054 &[".agents".to_string()],
1055 &outcomes,
1056 &target_sync_ctx(
1057 &lock_with_target_outputs(".agents", &[("agents/coder.md", "sha256:coder")]),
1058 false,
1059 ),
1060 &mut diag,
1061 );
1062
1063 assert_eq!(results[0].items_synced, 0);
1064 assert_eq!(
1065 std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
1066 "# Locally edited"
1067 );
1068
1069 let diagnostics = diag.drain();
1070 assert!(
1071 diagnostics
1072 .iter()
1073 .any(|d| d.code == "target-divergent" && d.message.contains("agents/coder.md"))
1074 );
1075 }
1076
1077 #[test]
1078 fn sync_preserves_handwritten_collision_when_lock_only_tracks_mars() {
1079 let dir = TempDir::new().unwrap();
1080 let mars_dir = dir.path().join(".mars");
1081 let target = dir.path().join(".cursor");
1082
1083 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
1084 std::fs::write(mars_dir.join("agents/design-lead.md"), "# Canonical").unwrap();
1085 std::fs::create_dir_all(target.join("agents")).unwrap();
1086 std::fs::write(target.join("agents/cursor-only-test.md"), "# custom").unwrap();
1087 std::fs::write(target.join("agents/design-lead.md"), "# hand-written").unwrap();
1088
1089 let mut lock = LockFile::empty();
1090 lock.items.insert(
1091 "agent/design-lead".to_string(),
1092 LockedItemV2 {
1093 source: "test".into(),
1094 kind: ItemKind::Agent,
1095 version: None,
1096 source_checksum: "sha256:src".into(),
1097 outputs: vec![OutputRecord {
1098 target_root: ".mars".to_string(),
1099 dest_path: "agents/design-lead.md".into(),
1100 installed_checksum: "sha256:mars".into(),
1101 }],
1102 },
1103 );
1104
1105 let outcomes = vec![make_outcome("agents/design-lead.md", ActionTaken::Removed)];
1106 let mut diag = DiagnosticCollector::new();
1107
1108 let results = sync_managed_targets(
1109 dir.path(),
1110 &mars_dir,
1111 &[".cursor".to_string()],
1112 &outcomes,
1113 &target_sync_ctx(&lock, false),
1114 &mut diag,
1115 );
1116
1117 assert_eq!(results[0].items_removed, 0);
1118 assert!(target.join("agents/cursor-only-test.md").exists());
1119 assert!(target.join("agents/design-lead.md").exists());
1120 assert_eq!(
1121 std::fs::read_to_string(target.join("agents/design-lead.md")).unwrap(),
1122 "# hand-written"
1123 );
1124 }
1125
1126 #[test]
1127 fn sync_installed_does_not_overwrite_untracked_collision_in_linked_target() {
1128 let dir = TempDir::new().unwrap();
1129 let mars_dir = dir.path().join(".mars");
1130 let target = dir.path().join(".agents");
1131
1132 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
1133 std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
1134 std::fs::create_dir_all(target.join("agents")).unwrap();
1135 std::fs::write(target.join("agents/coder.md"), "# hand-written").unwrap();
1136
1137 let mut lock = LockFile::empty();
1138 lock.items.insert(
1139 "agent/coder".to_string(),
1140 LockedItemV2 {
1141 source: "test".into(),
1142 kind: ItemKind::Agent,
1143 version: None,
1144 source_checksum: "sha256:src".into(),
1145 outputs: vec![OutputRecord {
1146 target_root: ".mars".to_string(),
1147 dest_path: "agents/coder.md".into(),
1148 installed_checksum: "sha256:mars".into(),
1149 }],
1150 },
1151 );
1152
1153 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
1154 let mut diag = DiagnosticCollector::new();
1155
1156 let results = sync_managed_targets(
1157 dir.path(),
1158 &mars_dir,
1159 &[".agents".to_string()],
1160 &outcomes,
1161 &target_sync_ctx(&lock, false),
1162 &mut diag,
1163 );
1164
1165 assert_eq!(results[0].items_synced, 0);
1166 assert_eq!(
1167 std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
1168 "# hand-written"
1169 );
1170 let diagnostics = diag.drain();
1171 assert!(
1172 diagnostics
1173 .iter()
1174 .any(|d| d.code == "target-unmanaged-collision")
1175 );
1176 }
1177
1178 #[test]
1179 fn sync_force_adopts_untracked_collision_in_linked_target() {
1180 let dir = TempDir::new().unwrap();
1181 let mars_dir = dir.path().join(".mars");
1182 let target = dir.path().join(".agents");
1183
1184 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
1185 std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
1186 std::fs::create_dir_all(target.join("agents")).unwrap();
1187 std::fs::write(target.join("agents/coder.md"), "# hand-written").unwrap();
1188
1189 let mut lock = LockFile::empty();
1190 lock.items.insert(
1191 "agent/coder".to_string(),
1192 LockedItemV2 {
1193 source: "test".into(),
1194 kind: ItemKind::Agent,
1195 version: None,
1196 source_checksum: "sha256:src".into(),
1197 outputs: vec![OutputRecord {
1198 target_root: ".mars".to_string(),
1199 dest_path: "agents/coder.md".into(),
1200 installed_checksum: "sha256:mars".into(),
1201 }],
1202 },
1203 );
1204
1205 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
1206 let mut diag = DiagnosticCollector::new();
1207
1208 let results = sync_managed_targets(
1209 dir.path(),
1210 &mars_dir,
1211 &[".agents".to_string()],
1212 &outcomes,
1213 &target_sync_ctx(&lock, true),
1214 &mut diag,
1215 );
1216
1217 assert_eq!(results[0].items_synced, 1);
1218 assert_eq!(
1219 std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
1220 "# Canonical"
1221 );
1222 assert!(!results[0].synced_outputs.is_empty());
1223 let diagnostics = diag.drain();
1224 assert!(
1225 diagnostics
1226 .iter()
1227 .any(|d| d.code == "target-unmanaged-adopted")
1228 );
1229 }
1230}