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