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(true) => {
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 Ok(false) => {}
238 Err(e) => errors.push(format!("failed to copy {dest_rel}: {e}")),
239 }
240 }
241 } else if native_skill_variant_key.is_none()
242 && old_lock.contains_output(target_name, dest_rel)
243 && let Some(expected_checksum) = &outcome.installed_checksum
244 {
245 match crate::hash::compute_hash(&dest, outcome.item_id.kind) {
246 Ok(actual) => {
247 let actual = ContentHash::from(actual);
248 if &actual != expected_checksum {
249 diag.warn(
250 "target-divergent",
251 format!(
252 "target `{target_name}` item `{}` diverged from `.mars` (preserved local content; run `{cmd1}` or `{cmd2}` to reset)",
253 dest_rel,
254 cmd1 = managed_cmd("mars sync --force"),
255 cmd2 = managed_cmd("mars repair"),
256 ),
257 );
258 }
259 }
260 Err(e) => {
261 errors.push(format!("failed to verify {dest_rel} checksum: {e}"))
262 }
263 }
264 } else if dest_exists && !old_lock.contains_output(target_name, dest_rel) {
265 surface_ownership::warn_unmanaged_collision(
266 target_name,
267 dest_rel,
268 collision_hint,
269 diag,
270 );
271 }
272 }
273 }
274 _ => {
275 expected_paths.insert(dest_rel.to_string());
276 let source = mars_dir.join(dest_rel);
277 let dest = target_root.join(dest_rel);
278 if (source.exists() || source.symlink_metadata().is_ok())
279 && should_copy_to_target(
280 &dest,
281 target_name,
282 dest_rel,
283 old_lock,
284 force,
285 collision_hint,
286 diag,
287 )
288 {
289 match copy_item_to_target(
290 &source,
291 &dest,
292 outcome.item_id.kind,
293 outcome.item_id.name.as_str(),
294 native_skill_variant_key.as_deref(),
295 diag,
296 ) {
297 Ok(true) => {
298 items_synced += 1;
299 record_synced_output(
300 &mut synced_outputs,
301 &dest,
302 dest_rel,
303 outcome.item_id.kind,
304 );
305 }
306 Ok(false) => {}
307 Err(e) => errors.push(format!("failed to copy {dest_rel}: {e}")),
308 }
309 }
310 }
311 }
312 }
313
314 if let Some(preserve) = ctx.orphan_preserve_paths
315 && let Some(paths) = preserve.get(target_name)
316 {
317 expected_paths.extend(paths.iter().cloned());
318 }
319
320 let orphan_removed = cleanup_orphans(
321 target_root,
322 &expected_paths,
323 &previous_managed_paths,
324 &mut removed_dest_paths,
325 &mut errors,
326 );
327 items_removed += orphan_removed;
328
329 Ok(TargetSyncOutcome {
330 target: target_name.to_string(),
331 items_synced,
332 items_removed,
333 errors,
334 synced_outputs,
335 removed_dest_paths,
336 })
337}
338
339fn should_copy_to_target(
340 dest: &Path,
341 target_name: &str,
342 dest_rel: &str,
343 old_lock: &LockFile,
344 force: bool,
345 collision_hint: CollisionAdoptHint,
346 diag: &mut DiagnosticCollector,
347) -> bool {
348 let dest_exists = surface_ownership::target_dest_exists(dest);
349 match surface_ownership::copy_decision(old_lock, target_name, dest_rel, dest_exists, force) {
350 SurfaceCopyDecision::Proceed => {
351 if dest_exists && force && !old_lock.contains_output(target_name, dest_rel) {
352 surface_ownership::warn_unmanaged_adopted(
353 target_name,
354 dest_rel,
355 collision_hint,
356 diag,
357 );
358 }
359 true
360 }
361 SurfaceCopyDecision::SkipUnmanagedCollision => {
362 surface_ownership::warn_unmanaged_collision(
363 target_name,
364 dest_rel,
365 collision_hint,
366 diag,
367 );
368 false
369 }
370 }
371}
372
373fn remove_target_path_if_managed(
374 target_path: &Path,
375 target_name: &str,
376 dest_rel: &str,
377 old_lock: &LockFile,
378 errors: &mut Vec<String>,
379) -> bool {
380 if !surface_ownership::target_dest_exists(target_path) {
381 return false;
382 }
383 if !surface_ownership::may_delete(old_lock, target_name, dest_rel) {
384 return false;
385 }
386 match fs_ops::safe_remove(target_path) {
387 Ok(()) => true,
388 Err(e) => {
389 errors.push(format!("failed to remove {dest_rel}: {e}"));
390 false
391 }
392 }
393}
394
395fn record_synced_output(
396 synced_outputs: &mut Vec<TargetSyncedOutput>,
397 dest: &Path,
398 dest_rel: &str,
399 kind: crate::lock::ItemKind,
400) {
401 if let Ok(checksum) = crate::hash::compute_hash(dest, kind) {
402 synced_outputs.push(TargetSyncedOutput {
403 dest_path: dest_rel.to_string(),
404 installed_checksum: ContentHash::from(checksum),
405 });
406 }
407}
408
409fn copy_item_to_target(
417 source: &Path,
418 dest: &Path,
419 kind: crate::lock::ItemKind,
420 item_name: &str,
421 native_skill_variant_key: Option<&str>,
422 diag: &mut DiagnosticCollector,
423) -> Result<bool, MarsError> {
424 if kind == crate::lock::ItemKind::Skill && native_skill_variant_key.is_some() {
425 crate::compiler::variants::validate_skill_variants(source, item_name, diag);
426 return crate::compiler::variants::project_skill_for_target(
427 source,
428 dest,
429 native_skill_variant_key,
430 diag,
431 item_name,
432 );
433 }
434
435 if let Some(parent) = dest.parent() {
437 std::fs::create_dir_all(parent)?;
438 }
439
440 let metadata = std::fs::metadata(source)?;
442
443 if metadata.is_dir() {
444 if dest.exists() && fs_ops::directory_trees_content_equal(source, dest)? {
445 return Ok(false);
446 }
447 fs_ops::atomic_copy_dir(source, dest)?;
448 } else if metadata.is_file() {
449 if fs_ops::file_content_equal(source, dest)? {
450 return Ok(false);
451 }
452 fs_ops::atomic_copy_file(source, dest)?;
453 }
454
455 Ok(true)
456}
457
458fn cleanup_orphans(
467 target_root: &Path,
468 expected: &HashSet<String>,
469 previous_managed_paths: &HashSet<String>,
470 removed_dest_paths: &mut Vec<String>,
471 errors: &mut Vec<String>,
472) -> usize {
473 let mut removed = 0;
474
475 for managed_path in previous_managed_paths {
478 if expected.contains(managed_path) {
479 continue;
480 }
481
482 let full_path = target_root.join(managed_path);
483
484 if !full_path.exists() && full_path.symlink_metadata().is_err() {
486 continue;
487 }
488
489 if full_path
491 .symlink_metadata()
492 .map(|m| m.file_type().is_symlink())
493 .unwrap_or(false)
494 {
495 continue;
496 }
497
498 if let Err(e) = fs_ops::safe_remove(&full_path) {
499 errors.push(format!("failed to remove orphan {managed_path}: {e}"));
500 } else {
501 removed += 1;
502 removed_dest_paths.push(managed_path.clone());
503 }
504 }
505
506 removed
507}
508
509#[cfg(test)]
510mod tests {
511 use super::*;
512 use crate::diagnostic::DiagnosticCollector;
513 use crate::hash;
514 use crate::lock::{ItemKind, LockFile, LockedItemV2, OutputRecord};
515 use crate::surface_ownership::CollisionAdoptHint;
516 use crate::sync::apply::{ActionOutcome, ActionTaken};
517 use crate::types::{DestPath, ItemName};
518 use tempfile::TempDir;
519
520 fn make_outcome(dest: &str, action: ActionTaken) -> ActionOutcome {
521 ActionOutcome {
522 item_id: crate::lock::ItemId {
523 kind: crate::lock::ItemKind::Agent,
524 name: ItemName::from("test"),
525 },
526 action,
527 dest_path: DestPath::from(dest),
528 source_name: "test-source".into(),
529 source_checksum: None,
530 installed_checksum: None,
531 }
532 }
533
534 fn lock_with_target_outputs(target: &str, outputs: &[(&str, &str)]) -> LockFile {
535 let mut lock = LockFile::empty();
536 for (dest, checksum) in outputs {
537 let name = dest.rsplit('/').next().unwrap_or("item");
538 lock.items.insert(
539 format!("agent/{name}"),
540 LockedItemV2 {
541 source: "test".into(),
542 kind: ItemKind::Agent,
543 version: None,
544 source_checksum: "sha256:src".into(),
545 outputs: vec![OutputRecord {
546 target_root: target.to_string(),
547 dest_path: (*dest).into(),
548 installed_checksum: (*checksum).into(),
549 }],
550 },
551 );
552 }
553 lock
554 }
555
556 fn lock_with_skill_target_outputs(target: &str, outputs: &[(&str, &str)]) -> LockFile {
557 let mut lock = LockFile::empty();
558 for (dest, checksum) in outputs {
559 let name = dest.rsplit('/').next().unwrap_or("item");
560 lock.items.insert(
561 format!("skill/{name}"),
562 LockedItemV2 {
563 source: "test".into(),
564 kind: ItemKind::Skill,
565 version: None,
566 source_checksum: "sha256:src".into(),
567 outputs: vec![OutputRecord {
568 target_root: target.to_string(),
569 dest_path: (*dest).into(),
570 installed_checksum: (*checksum).into(),
571 }],
572 },
573 );
574 }
575 lock
576 }
577
578 fn target_sync_ctx<'a>(old_lock: &'a LockFile, force: bool) -> TargetSyncContext<'a> {
579 TargetSyncContext {
580 old_lock,
581 force,
582 collision_hint: CollisionAdoptHint::SyncForce,
583 orphan_preserve_paths: None,
584 }
585 }
586
587 fn make_skipped_with_checksum(dest: &str, checksum: &str) -> ActionOutcome {
588 let mut outcome = make_outcome(dest, ActionTaken::Skipped);
589 outcome.installed_checksum = Some(checksum.into());
590 outcome
591 }
592
593 #[test]
594 fn sync_copies_installed_items_to_target() {
595 let dir = TempDir::new().unwrap();
596 let mars_dir = dir.path().join(".mars");
597 let target = dir.path().join(".agents");
598
599 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
601 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
602
603 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
604 let mut diag = DiagnosticCollector::new();
605
606 let results = sync_managed_targets(
607 dir.path(),
608 &mars_dir,
609 &[".agents".to_string()],
610 &outcomes,
611 &target_sync_ctx(&LockFile::empty(), false),
612 &mut diag,
613 );
614
615 assert_eq!(results.len(), 1);
616 assert_eq!(results[0].items_synced, 1);
617 assert!(results[0].errors.is_empty());
618 assert!(target.join("agents/coder.md").exists());
619 assert_eq!(
620 std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
621 "# Coder"
622 );
623 }
624
625 #[test]
626 fn sync_removes_items_from_target() {
627 let dir = TempDir::new().unwrap();
628 let mars_dir = dir.path().join(".mars");
629 let target = dir.path().join(".agents");
630
631 std::fs::create_dir_all(&mars_dir).unwrap();
632 std::fs::create_dir_all(target.join("agents")).unwrap();
633 std::fs::write(target.join("agents/old.md"), "# Old").unwrap();
634
635 let outcomes = vec![make_outcome("agents/old.md", ActionTaken::Removed)];
636 let mut diag = DiagnosticCollector::new();
637
638 let results = sync_managed_targets(
639 dir.path(),
640 &mars_dir,
641 &[".agents".to_string()],
642 &outcomes,
643 &target_sync_ctx(
644 &lock_with_target_outputs(".agents", &[("agents/old.md", "sha256:old")]),
645 false,
646 ),
647 &mut diag,
648 );
649
650 assert_eq!(results[0].items_removed, 1);
651 assert!(!target.join("agents/old.md").exists());
652 }
653
654 #[test]
655 fn sync_cleans_up_previous_managed_orphans() {
656 let dir = TempDir::new().unwrap();
657 let mars_dir = dir.path().join(".mars");
658 let target = dir.path().join(".agents");
659
660 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
662 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
663
664 std::fs::create_dir_all(target.join("agents")).unwrap();
666 std::fs::write(target.join("agents/orphan.md"), "# Orphan").unwrap();
667
668 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
669 let mut diag = DiagnosticCollector::new();
670
671 let results = sync_managed_targets(
672 dir.path(),
673 &mars_dir,
674 &[".agents".to_string()],
675 &outcomes,
676 &target_sync_ctx(
677 &lock_with_target_outputs(".agents", &[("agents/orphan.md", "sha256:orphan")]),
678 false,
679 ),
680 &mut diag,
681 );
682
683 assert!(target.join("agents/coder.md").exists());
684 assert!(!target.join("agents/orphan.md").exists());
685 assert_eq!(results[0].items_removed, 1);
686 }
687
688 #[test]
689 fn sync_preserves_unmanaged_files_in_target() {
690 let dir = TempDir::new().unwrap();
691 let mars_dir = dir.path().join(".mars");
692 let target = dir.path().join(".agents");
693
694 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
695 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
696
697 std::fs::create_dir_all(target.join("agents")).unwrap();
698 std::fs::write(target.join("agents/custom.md"), "# User custom").unwrap();
699
700 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
701 let mut diag = DiagnosticCollector::new();
702
703 let results = sync_managed_targets(
704 dir.path(),
705 &mars_dir,
706 &[".agents".to_string()],
707 &outcomes,
708 &target_sync_ctx(&LockFile::empty(), false),
709 &mut diag,
710 );
711
712 assert!(target.join("agents/coder.md").exists());
713 assert!(target.join("agents/custom.md").exists());
714 assert_eq!(results[0].items_removed, 0);
715 }
716
717 #[test]
718 fn sync_removed_agent_outcome_removes_existing_target_agent_without_copying() {
719 let dir = TempDir::new().unwrap();
720 let mars_dir = dir.path().join(".mars");
721 let target = dir.path().join(".agents");
722
723 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
724 std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
725 std::fs::create_dir_all(target.join("agents")).unwrap();
726 std::fs::write(target.join("agents/coder.md"), "# Existing target copy").unwrap();
727
728 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Removed)];
729 let mut diag = DiagnosticCollector::new();
730
731 let results = sync_managed_targets(
732 dir.path(),
733 &mars_dir,
734 &[".agents".to_string()],
735 &outcomes,
736 &target_sync_ctx(
737 &lock_with_target_outputs(".agents", &[("agents/coder.md", "sha256:coder")]),
738 false,
739 ),
740 &mut diag,
741 );
742
743 assert_eq!(results[0].items_synced, 0);
744 assert_eq!(results[0].items_removed, 1);
745 assert!(!target.join("agents/coder.md").exists());
746 assert!(results[0].errors.is_empty());
747 }
748
749 #[test]
750 fn selective_orphan_preserve_keeps_native_agent_without_agent_outcomes() {
751 let dir = TempDir::new().unwrap();
752 let mars_dir = dir.path().join(".mars");
753 let target = dir.path().join(".claude");
754
755 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
756 std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
757 std::fs::create_dir_all(target.join("agents")).unwrap();
758 std::fs::write(target.join("agents/coder.md"), "# Native").unwrap();
759
760 let old_lock = lock_with_target_outputs(".claude", &[("agents/coder.md", "sha256:native")]);
761 let mut preserve = HashMap::new();
762 preserve.insert(
763 ".claude".to_string(),
764 HashSet::from(["agents/coder.md".to_string()]),
765 );
766 let mut diag = DiagnosticCollector::new();
767
768 let results = sync_managed_targets(
769 dir.path(),
770 &mars_dir,
771 &[".claude".to_string()],
772 &[],
773 &TargetSyncContext {
774 old_lock: &old_lock,
775 force: false,
776 collision_hint: CollisionAdoptHint::SyncForce,
777 orphan_preserve_paths: Some(&preserve),
778 },
779 &mut diag,
780 );
781
782 assert!(target.join("agents/coder.md").exists());
783 assert_eq!(results[0].items_removed, 0);
784 assert!(
785 !results[0]
786 .removed_dest_paths
787 .iter()
788 .any(|path| path == "agents/coder.md"),
789 "selective steady-state must not remove managed native agent before compile"
790 );
791 }
792
793 #[test]
794 fn sync_multiple_targets() {
795 let dir = TempDir::new().unwrap();
796 let mars_dir = dir.path().join(".mars");
797
798 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
799 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
800
801 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
802 let mut diag = DiagnosticCollector::new();
803
804 let results = sync_managed_targets(
805 dir.path(),
806 &mars_dir,
807 &[".agents".to_string(), ".custom-target".to_string()],
808 &outcomes,
809 &target_sync_ctx(&LockFile::empty(), false),
810 &mut diag,
811 );
812
813 assert_eq!(results.len(), 2);
814 assert!(dir.path().join(".agents/agents/coder.md").exists());
815 assert!(dir.path().join(".custom-target/agents/coder.md").exists());
816 }
817
818 #[test]
819 fn sync_native_targets_skip_canonical_agent_markdown_copies() {
820 let dir = TempDir::new().unwrap();
821 let mars_dir = dir.path().join(".mars");
822
823 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
824 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
825
826 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
827 let mut diag = DiagnosticCollector::new();
828
829 let results = sync_managed_targets(
830 dir.path(),
831 &mars_dir,
832 &[
833 ".claude".to_string(),
834 ".codex".to_string(),
835 ".opencode".to_string(),
836 ".pi".to_string(),
837 ],
838 &outcomes,
839 &target_sync_ctx(&LockFile::empty(), false),
840 &mut diag,
841 );
842
843 assert_eq!(results.len(), 4);
844 assert!(results.iter().all(|outcome| outcome.items_synced == 0));
845 assert!(!dir.path().join(".claude/agents/coder.md").exists());
846 assert!(!dir.path().join(".codex/agents/coder.md").exists());
847 assert!(!dir.path().join(".opencode/agents/coder.md").exists());
848 assert!(!dir.path().join(".pi/agents/coder.md").exists());
849 }
850
851 #[test]
852 fn sync_unknown_target_still_copies_canonical_agents() {
853 let dir = TempDir::new().unwrap();
854 let mars_dir = dir.path().join(".mars");
855
856 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
857 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
858
859 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
860 let mut diag = DiagnosticCollector::new();
861
862 let results = sync_managed_targets(
863 dir.path(),
864 &mars_dir,
865 &[".custom-target".to_string()],
866 &outcomes,
867 &target_sync_ctx(&LockFile::empty(), false),
868 &mut diag,
869 );
870
871 assert_eq!(results[0].items_synced, 1);
872 assert!(dir.path().join(".custom-target/agents/coder.md").exists());
873 }
874
875 #[test]
876 fn sync_skill_directory() {
877 let dir = TempDir::new().unwrap();
878 let mars_dir = dir.path().join(".mars");
879 let target = dir.path().join(".agents");
880
881 std::fs::create_dir_all(mars_dir.join("skills/planning")).unwrap();
882 std::fs::write(mars_dir.join("skills/planning/SKILL.md"), "# Planning").unwrap();
883
884 let mut outcome = make_outcome("skills/planning", ActionTaken::Installed);
885 outcome.item_id.kind = crate::lock::ItemKind::Skill;
886 let outcomes = vec![outcome];
887 let mut diag = DiagnosticCollector::new();
888
889 let results = sync_managed_targets(
890 dir.path(),
891 &mars_dir,
892 &[".agents".to_string()],
893 &outcomes,
894 &target_sync_ctx(&LockFile::empty(), false),
895 &mut diag,
896 );
897
898 assert_eq!(results[0].items_synced, 1);
899 assert!(target.join("skills/planning/SKILL.md").exists());
900 }
901
902 #[test]
903 fn sync_projects_skills_for_native_harness_targets() {
904 let dir = TempDir::new().unwrap();
905 let mars_dir = dir.path().join(".mars");
906 let target = dir.path().join(".claude");
907
908 std::fs::create_dir_all(mars_dir.join("skills/planning/resources")).unwrap();
909 std::fs::create_dir_all(mars_dir.join("skills/planning/variants/claude")).unwrap();
910 std::fs::create_dir_all(target.join("skills")).unwrap();
911 std::fs::write(target.join("skills/orphan"), "# Orphan").unwrap();
912 std::fs::write(mars_dir.join("skills/planning/SKILL.md"), "# Base").unwrap();
913 std::fs::write(
914 mars_dir.join("skills/planning/resources/BOOTSTRAP.md"),
915 "# Bootstrap",
916 )
917 .unwrap();
918 std::fs::write(
919 mars_dir.join("skills/planning/variants/claude/SKILL.md"),
920 "# Claude",
921 )
922 .unwrap();
923
924 let mut outcome = make_outcome("skills/planning", ActionTaken::Installed);
925 outcome.item_id.kind = crate::lock::ItemKind::Skill;
926 let outcomes = vec![outcome];
927 let mut diag = DiagnosticCollector::new();
928
929 let results = sync_managed_targets(
930 dir.path(),
931 &mars_dir,
932 &[".claude".to_string()],
933 &outcomes,
934 &target_sync_ctx(
935 &lock_with_skill_target_outputs(
936 ".claude",
937 &[
938 ("skills/planning", "sha256:planning"),
939 ("skills/orphan", "sha256:orphan"),
940 ],
941 ),
942 false,
943 ),
944 &mut diag,
945 );
946
947 assert_eq!(results[0].items_synced, 1);
948 assert_eq!(
949 std::fs::read_to_string(target.join("skills/planning/SKILL.md")).unwrap(),
950 "# Claude"
951 );
952 assert_eq!(
953 std::fs::read_to_string(target.join("skills/planning/resources/BOOTSTRAP.md")).unwrap(),
954 "# Bootstrap"
955 );
956 assert!(!target.join("skills/planning/variants").exists());
957 assert!(!target.join("skills/orphan").exists());
958 }
959
960 #[test]
961 fn cleanup_orphans_uses_forward_slash_keys_for_expected_paths() {
962 let dir = TempDir::new().unwrap();
963 let target_root = dir.path().join(".agents");
964 std::fs::create_dir_all(target_root.join("agents")).unwrap();
965 std::fs::write(target_root.join("agents/coder.md"), "# Managed").unwrap();
966 std::fs::write(target_root.join("agents/orphan.md"), "# Orphan").unwrap();
967
968 let mut expected = HashSet::new();
969 expected.insert(
970 DestPath::new(r"agents\coder.md")
971 .unwrap()
972 .as_str()
973 .to_string(),
974 );
975
976 let previous = lock_with_target_outputs(
977 ".agents",
978 &[
979 ("agents/coder.md", "sha256:coder"),
980 ("agents/orphan.md", "sha256:orphan"),
981 ],
982 );
983 let previous_paths = previous.output_dest_paths_for_target(".agents");
984 let mut removed_dest_paths = Vec::new();
985 let removed = cleanup_orphans(
986 &target_root,
987 &expected,
988 &previous_paths,
989 &mut removed_dest_paths,
990 &mut Vec::new(),
991 );
992
993 assert_eq!(removed, 1);
994 assert!(target_root.join("agents/coder.md").exists());
995 assert!(!target_root.join("agents/orphan.md").exists());
996 }
997
998 #[test]
999 fn sync_convergence_on_rerun() {
1000 let dir = TempDir::new().unwrap();
1001 let mars_dir = dir.path().join(".mars");
1002 let target = dir.path().join(".agents");
1003
1004 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
1005 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
1006
1007 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
1008 let mut diag = DiagnosticCollector::new();
1009
1010 sync_managed_targets(
1012 dir.path(),
1013 &mars_dir,
1014 &[".agents".to_string()],
1015 &outcomes,
1016 &target_sync_ctx(&LockFile::empty(), false),
1017 &mut diag,
1018 );
1019
1020 let outcomes2 = vec![make_outcome("agents/coder.md", ActionTaken::Skipped)];
1022 let results = sync_managed_targets(
1023 dir.path(),
1024 &mars_dir,
1025 &[".agents".to_string()],
1026 &outcomes2,
1027 &target_sync_ctx(
1028 &lock_with_target_outputs(".agents", &[("agents/coder.md", "sha256:coder")]),
1029 false,
1030 ),
1031 &mut diag,
1032 );
1033
1034 assert!(target.join("agents/coder.md").exists());
1035 assert_eq!(results[0].items_synced, 0);
1037 }
1038
1039 #[test]
1040 fn sync_force_refreshes_skipped_target_content() {
1041 let dir = TempDir::new().unwrap();
1042 let mars_dir = dir.path().join(".mars");
1043 let target = dir.path().join(".agents");
1044
1045 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
1046 std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
1047
1048 std::fs::create_dir_all(target.join("agents")).unwrap();
1049 std::fs::write(target.join("agents/coder.md"), "# Tampered").unwrap();
1050
1051 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Skipped)];
1052 let mut diag = DiagnosticCollector::new();
1053 let results = sync_managed_targets(
1054 dir.path(),
1055 &mars_dir,
1056 &[".agents".to_string()],
1057 &outcomes,
1058 &target_sync_ctx(
1059 &lock_with_target_outputs(".agents", &[("agents/coder.md", "sha256:coder")]),
1060 true,
1061 ),
1062 &mut diag,
1063 );
1064
1065 assert_eq!(results[0].items_synced, 1);
1066 assert_eq!(
1067 std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
1068 "# Canonical"
1069 );
1070 }
1071
1072 #[test]
1073 fn sync_skipped_recopies_missing_target() {
1074 let dir = TempDir::new().unwrap();
1075 let mars_dir = dir.path().join(".mars");
1076 let target = dir.path().join(".agents");
1077
1078 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
1079 std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
1080
1081 let checksum = hash::hash_bytes(b"# Canonical");
1082 let outcomes = vec![make_skipped_with_checksum("agents/coder.md", &checksum)];
1083 let mut diag = DiagnosticCollector::new();
1084 let results = sync_managed_targets(
1085 dir.path(),
1086 &mars_dir,
1087 &[".agents".to_string()],
1088 &outcomes,
1089 &target_sync_ctx(
1090 &lock_with_target_outputs(".agents", &[("agents/coder.md", "sha256:coder")]),
1091 false,
1092 ),
1093 &mut diag,
1094 );
1095
1096 assert_eq!(results[0].items_synced, 1);
1097 assert!(target.join("agents/coder.md").exists());
1098 }
1099
1100 #[test]
1101 fn sync_skipped_warns_on_divergent_target_and_preserves_local_content() {
1102 let dir = TempDir::new().unwrap();
1103 let mars_dir = dir.path().join(".mars");
1104 let target = dir.path().join(".agents");
1105
1106 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
1107 std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
1108
1109 std::fs::create_dir_all(target.join("agents")).unwrap();
1110 std::fs::write(target.join("agents/coder.md"), "# Locally edited").unwrap();
1111
1112 let checksum = hash::hash_bytes(b"# Canonical");
1113 let outcomes = vec![make_skipped_with_checksum("agents/coder.md", &checksum)];
1114 let mut diag = DiagnosticCollector::new();
1115 let results = sync_managed_targets(
1116 dir.path(),
1117 &mars_dir,
1118 &[".agents".to_string()],
1119 &outcomes,
1120 &target_sync_ctx(
1121 &lock_with_target_outputs(".agents", &[("agents/coder.md", "sha256:coder")]),
1122 false,
1123 ),
1124 &mut diag,
1125 );
1126
1127 assert_eq!(results[0].items_synced, 0);
1128 assert_eq!(
1129 std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
1130 "# Locally edited"
1131 );
1132
1133 let diagnostics = diag.drain();
1134 assert!(
1135 diagnostics
1136 .iter()
1137 .any(|d| d.code == "target-divergent" && d.message.contains("agents/coder.md"))
1138 );
1139 }
1140
1141 #[test]
1142 fn sync_preserves_handwritten_collision_when_lock_only_tracks_mars() {
1143 let dir = TempDir::new().unwrap();
1144 let mars_dir = dir.path().join(".mars");
1145 let target = dir.path().join(".cursor");
1146
1147 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
1148 std::fs::write(mars_dir.join("agents/design-lead.md"), "# Canonical").unwrap();
1149 std::fs::create_dir_all(target.join("agents")).unwrap();
1150 std::fs::write(target.join("agents/cursor-only-test.md"), "# custom").unwrap();
1151 std::fs::write(target.join("agents/design-lead.md"), "# hand-written").unwrap();
1152
1153 let mut lock = LockFile::empty();
1154 lock.items.insert(
1155 "agent/design-lead".to_string(),
1156 LockedItemV2 {
1157 source: "test".into(),
1158 kind: ItemKind::Agent,
1159 version: None,
1160 source_checksum: "sha256:src".into(),
1161 outputs: vec![OutputRecord {
1162 target_root: ".mars".to_string(),
1163 dest_path: "agents/design-lead.md".into(),
1164 installed_checksum: "sha256:mars".into(),
1165 }],
1166 },
1167 );
1168
1169 let outcomes = vec![make_outcome("agents/design-lead.md", ActionTaken::Removed)];
1170 let mut diag = DiagnosticCollector::new();
1171
1172 let results = sync_managed_targets(
1173 dir.path(),
1174 &mars_dir,
1175 &[".cursor".to_string()],
1176 &outcomes,
1177 &target_sync_ctx(&lock, false),
1178 &mut diag,
1179 );
1180
1181 assert_eq!(results[0].items_removed, 0);
1182 assert!(target.join("agents/cursor-only-test.md").exists());
1183 assert!(target.join("agents/design-lead.md").exists());
1184 assert_eq!(
1185 std::fs::read_to_string(target.join("agents/design-lead.md")).unwrap(),
1186 "# hand-written"
1187 );
1188 }
1189
1190 #[test]
1191 fn sync_installed_does_not_overwrite_untracked_collision_in_linked_target() {
1192 let dir = TempDir::new().unwrap();
1193 let mars_dir = dir.path().join(".mars");
1194 let target = dir.path().join(".agents");
1195
1196 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
1197 std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
1198 std::fs::create_dir_all(target.join("agents")).unwrap();
1199 std::fs::write(target.join("agents/coder.md"), "# hand-written").unwrap();
1200
1201 let mut lock = LockFile::empty();
1202 lock.items.insert(
1203 "agent/coder".to_string(),
1204 LockedItemV2 {
1205 source: "test".into(),
1206 kind: ItemKind::Agent,
1207 version: None,
1208 source_checksum: "sha256:src".into(),
1209 outputs: vec![OutputRecord {
1210 target_root: ".mars".to_string(),
1211 dest_path: "agents/coder.md".into(),
1212 installed_checksum: "sha256:mars".into(),
1213 }],
1214 },
1215 );
1216
1217 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
1218 let mut diag = DiagnosticCollector::new();
1219
1220 let results = sync_managed_targets(
1221 dir.path(),
1222 &mars_dir,
1223 &[".agents".to_string()],
1224 &outcomes,
1225 &target_sync_ctx(&lock, false),
1226 &mut diag,
1227 );
1228
1229 assert_eq!(results[0].items_synced, 0);
1230 assert_eq!(
1231 std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
1232 "# hand-written"
1233 );
1234 let diagnostics = diag.drain();
1235 assert!(
1236 diagnostics
1237 .iter()
1238 .any(|d| d.code == "target-unmanaged-collision")
1239 );
1240 }
1241
1242 #[test]
1243 fn sync_force_adopts_untracked_collision_in_linked_target() {
1244 let dir = TempDir::new().unwrap();
1245 let mars_dir = dir.path().join(".mars");
1246 let target = dir.path().join(".agents");
1247
1248 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
1249 std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
1250 std::fs::create_dir_all(target.join("agents")).unwrap();
1251 std::fs::write(target.join("agents/coder.md"), "# hand-written").unwrap();
1252
1253 let mut lock = LockFile::empty();
1254 lock.items.insert(
1255 "agent/coder".to_string(),
1256 LockedItemV2 {
1257 source: "test".into(),
1258 kind: ItemKind::Agent,
1259 version: None,
1260 source_checksum: "sha256:src".into(),
1261 outputs: vec![OutputRecord {
1262 target_root: ".mars".to_string(),
1263 dest_path: "agents/coder.md".into(),
1264 installed_checksum: "sha256:mars".into(),
1265 }],
1266 },
1267 );
1268
1269 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
1270 let mut diag = DiagnosticCollector::new();
1271
1272 let results = sync_managed_targets(
1273 dir.path(),
1274 &mars_dir,
1275 &[".agents".to_string()],
1276 &outcomes,
1277 &target_sync_ctx(&lock, true),
1278 &mut diag,
1279 );
1280
1281 assert_eq!(results[0].items_synced, 1);
1282 assert_eq!(
1283 std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
1284 "# Canonical"
1285 );
1286 assert!(!results[0].synced_outputs.is_empty());
1287 let diagnostics = diag.drain();
1288 assert!(
1289 diagnostics
1290 .iter()
1291 .any(|d| d.code == "target-unmanaged-adopted")
1292 );
1293 }
1294
1295 fn make_skipped_skill_outcome(dest: &str, name: &str) -> ActionOutcome {
1296 ActionOutcome {
1297 item_id: crate::lock::ItemId {
1298 kind: ItemKind::Skill,
1299 name: ItemName::from(name),
1300 },
1301 action: ActionTaken::Skipped,
1302 dest_path: DestPath::from(dest),
1303 source_name: "test-source".into(),
1304 source_checksum: None,
1305 installed_checksum: None,
1306 }
1307 }
1308
1309 fn make_installed_skill_outcome(dest: &str, name: &str) -> ActionOutcome {
1310 ActionOutcome {
1311 item_id: crate::lock::ItemId {
1312 kind: ItemKind::Skill,
1313 name: ItemName::from(name),
1314 },
1315 action: ActionTaken::Installed,
1316 dest_path: DestPath::from(dest),
1317 source_name: "test-source".into(),
1318 source_checksum: None,
1319 installed_checksum: None,
1320 }
1321 }
1322
1323 #[test]
1324 fn sync_skipped_native_skill_projection_skips_byte_identical_rewrite() {
1325 let dir = TempDir::new().unwrap();
1326 let mars_dir = dir.path().join(".mars");
1327 let target = dir.path().join(".claude");
1328 let skill_source = mars_dir.join("skills/planning");
1329 std::fs::create_dir_all(skill_source.join("variants/claude")).unwrap();
1330 std::fs::write(
1331 skill_source.join("SKILL.md"),
1332 "---\nname: planning\ndescription: Base\n---\n# Base\n",
1333 )
1334 .unwrap();
1335 std::fs::write(skill_source.join("variants/claude/SKILL.md"), "# Claude").unwrap();
1336
1337 let outcomes = vec![make_installed_skill_outcome("skills/planning", "planning")];
1338 let mut diag = DiagnosticCollector::new();
1339 sync_managed_targets(
1340 dir.path(),
1341 &mars_dir,
1342 &[".claude".to_string()],
1343 &outcomes,
1344 &target_sync_ctx(&LockFile::empty(), false),
1345 &mut diag,
1346 );
1347
1348 let native_skill = target.join("skills/planning/SKILL.md");
1349 assert!(native_skill.exists());
1350 let expected = std::fs::read_to_string(&native_skill).unwrap();
1351 let before = std::fs::metadata(&native_skill)
1352 .unwrap()
1353 .modified()
1354 .unwrap();
1355
1356 std::thread::sleep(std::time::Duration::from_millis(1100));
1357
1358 let skill_dir = target.join("skills/planning");
1359 let checksum = hash::compute_hash(&skill_dir, ItemKind::Skill).unwrap();
1360 let lock =
1361 lock_with_skill_target_outputs(".claude", &[("skills/planning", checksum.as_str())]);
1362 let outcomes2 = vec![make_skipped_skill_outcome("skills/planning", "planning")];
1363 let results = sync_managed_targets(
1364 dir.path(),
1365 &mars_dir,
1366 &[".claude".to_string()],
1367 &outcomes2,
1368 &target_sync_ctx(&lock, false),
1369 &mut diag,
1370 );
1371
1372 assert_eq!(results[0].items_synced, 0);
1373 let after = std::fs::metadata(&native_skill)
1374 .unwrap()
1375 .modified()
1376 .unwrap();
1377 assert_eq!(
1378 before, after,
1379 "no-op sync must not rewrite native skill output"
1380 );
1381 assert_eq!(std::fs::read_to_string(&native_skill).unwrap(), expected);
1382 }
1383
1384 #[test]
1385 fn sync_changed_native_skill_projection_rewrites_target_output() {
1386 let dir = TempDir::new().unwrap();
1387 let mars_dir = dir.path().join(".mars");
1388 let target = dir.path().join(".claude");
1389 let skill_source = mars_dir.join("skills/planning");
1390 std::fs::create_dir_all(skill_source.join("variants/claude")).unwrap();
1391 std::fs::write(
1392 skill_source.join("SKILL.md"),
1393 "---\nname: planning\ndescription: Base\n---\n# Base\n",
1394 )
1395 .unwrap();
1396 std::fs::write(skill_source.join("variants/claude/SKILL.md"), "# Claude v1").unwrap();
1397
1398 let outcomes = vec![make_installed_skill_outcome("skills/planning", "planning")];
1399 let mut diag = DiagnosticCollector::new();
1400 sync_managed_targets(
1401 dir.path(),
1402 &mars_dir,
1403 &[".claude".to_string()],
1404 &outcomes,
1405 &target_sync_ctx(&LockFile::empty(), false),
1406 &mut diag,
1407 );
1408
1409 let expected = std::fs::read_to_string(target.join("skills/planning/SKILL.md")).unwrap();
1410
1411 std::fs::write(skill_source.join("variants/claude/SKILL.md"), "# Claude v2").unwrap();
1412 let outcomes2 = vec![make_installed_skill_outcome("skills/planning", "planning")];
1413 let results = sync_managed_targets(
1414 dir.path(),
1415 &mars_dir,
1416 &[".claude".to_string()],
1417 &outcomes2,
1418 &target_sync_ctx(
1419 &lock_with_skill_target_outputs(".claude", &[("skills/planning", "sha256:old")]),
1420 false,
1421 ),
1422 &mut diag,
1423 );
1424
1425 assert_eq!(results[0].items_synced, 1);
1426 let updated = std::fs::read_to_string(target.join("skills/planning/SKILL.md")).unwrap();
1427 assert!(updated.contains("# Claude v2"));
1428 assert_ne!(updated, expected);
1429 }
1430}