1use std::collections::{HashMap, HashSet};
9use std::path::Path;
10
11use crate::diagnostic::DiagnosticCollector;
12use crate::error::MarsError;
13use crate::lock::LockFile;
14use crate::platform::fs as 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 TargetSyncedOutput {
23 pub dest_path: String,
24 pub installed_checksum: ContentHash,
25}
26
27#[derive(Debug, Clone)]
29pub struct TargetSyncOutcome {
30 pub target: String,
32 pub items_synced: usize,
34 pub items_removed: usize,
36 pub errors: Vec<String>,
38 pub synced_outputs: Vec<TargetSyncedOutput>,
40 pub removed_dest_paths: Vec<String>,
42}
43
44pub struct TargetSyncContext<'a> {
46 pub old_lock: &'a LockFile,
47 pub force: bool,
48 pub collision_hint: CollisionAdoptHint,
49 pub orphan_preserve_paths: Option<&'a HashMap<String, HashSet<String>>>,
51}
52
53pub fn sync_managed_targets(
62 project_root: &Path,
63 mars_dir: &Path,
64 targets: &[String],
65 outcomes: &[ActionOutcome],
66 ctx: &TargetSyncContext<'_>,
67 diag: &mut DiagnosticCollector,
68) -> Vec<TargetSyncOutcome> {
69 let mut results = Vec::new();
70
71 for target_name in targets {
72 let target_root = project_root.join(target_name);
73 match sync_one_target(mars_dir, &target_root, target_name, outcomes, ctx, diag) {
74 Ok(outcome) => {
75 if !outcome.errors.is_empty() {
76 for err in &outcome.errors {
77 diag.warn(
78 "target-sync-error",
79 format!("target `{target_name}`: {err}"),
80 );
81 }
82 }
83 results.push(outcome);
84 }
85 Err(e) => {
86 diag.warn(
87 "target-sync-failed",
88 format!("target `{target_name}` sync failed: {e}"),
89 );
90 results.push(TargetSyncOutcome {
91 target: target_name.clone(),
92 items_synced: 0,
93 items_removed: 0,
94 errors: vec![e.to_string()],
95 synced_outputs: Vec::new(),
96 removed_dest_paths: Vec::new(),
97 });
98 }
99 }
100 }
101
102 results
103}
104
105fn sync_one_target(
106 mars_dir: &Path,
107 target_root: &Path,
108 target_name: &str,
109 outcomes: &[ActionOutcome],
110 ctx: &TargetSyncContext<'_>,
111 diag: &mut DiagnosticCollector,
112) -> Result<TargetSyncOutcome, MarsError> {
113 let old_lock = ctx.old_lock;
114 let force = ctx.force;
115 let collision_hint = ctx.collision_hint;
116 let mut items_synced = 0;
117 let mut items_removed = 0;
118 let mut errors = Vec::new();
119 let mut synced_outputs = Vec::new();
120 let mut removed_dest_paths = Vec::new();
121 let previous_managed_paths = old_lock.output_dest_paths_for_target(target_name);
122
123 std::fs::create_dir_all(target_root)?;
124
125 let mut expected_paths: HashSet<String> = HashSet::new();
126 let target_registry = crate::target::TargetRegistry::new();
127 let target_adapter = target_registry.get(target_name);
128 let native_skill_variant_key = target_adapter
129 .and_then(|adapter| adapter.skill_variant_key())
130 .map(str::to_owned);
131 let target_accepts_canonical_agents = target_adapter
132 .map(|adapter| {
133 adapter
134 .default_dest_path(crate::lock::ItemKind::Agent, "__mars_probe__")
135 .is_some()
136 })
137 .unwrap_or(true);
138
139 for outcome in outcomes {
140 if outcome.item_id.kind == crate::lock::ItemKind::BootstrapDoc {
141 continue;
142 }
143 let canonical_dest_rel = outcome.dest_path.as_str();
144 let hook_dest;
145 let dest_rel = if outcome.item_id.kind == crate::lock::ItemKind::Hook {
146 let Some((hook_target, target_dest)) =
147 crate::sync::target::hook_target_dest_path(&outcome.dest_path)
148 else {
149 continue;
150 };
151 if hook_target != target_name.trim_start_matches('.') {
152 continue;
153 }
154 hook_dest = target_dest;
155 hook_dest.as_str()
156 } else {
157 canonical_dest_rel
158 };
159 if outcome.item_id.kind == crate::lock::ItemKind::Agent && !target_accepts_canonical_agents
160 {
161 if matches!(outcome.action, ActionTaken::Removed) {
162 let target_path = target_root.join(dest_rel);
163 if remove_target_path_if_managed(
164 &target_path,
165 target_name,
166 dest_rel,
167 old_lock,
168 &mut errors,
169 ) {
170 items_removed += 1;
171 removed_dest_paths.push(dest_rel.to_string());
172 }
173 }
174 continue;
175 }
176 match &outcome.action {
177 ActionTaken::Removed => {
178 let target_path = target_root.join(dest_rel);
179 if remove_target_path_if_managed(
180 &target_path,
181 target_name,
182 dest_rel,
183 old_lock,
184 &mut errors,
185 ) {
186 items_removed += 1;
187 removed_dest_paths.push(dest_rel.to_string());
188 }
189 }
190 ActionTaken::Skipped => {
191 expected_paths.insert(dest_rel.to_string());
192 let source = mars_dir.join(canonical_dest_rel);
193 let dest = target_root.join(dest_rel);
194 if source.exists() || source.symlink_metadata().is_ok() {
195 let should_refresh_native_skill = outcome.item_id.kind
196 == crate::lock::ItemKind::Skill
197 && native_skill_variant_key.is_some();
198 let dest_exists = surface_ownership::target_dest_exists(&dest);
199 let managed_dest_is_current_or_stale = dest_exists
200 && old_lock.contains_output(target_name, dest_rel)
201 && (should_refresh_native_skill
202 || disk_matches_recorded_or_desired(
203 &dest,
204 outcome.item_id.kind,
205 target_name,
206 dest_rel,
207 old_lock,
208 outcome.installed_checksum.as_ref(),
209 ));
210 let wants_copy = force
211 || !dest_exists
212 || should_refresh_native_skill
213 || managed_dest_is_current_or_stale;
214 if wants_copy {
215 if should_copy_to_target(&dest, target_name, dest_rel, ctx, diag) {
216 if let Some(variant_key) = native_skill_variant_key.as_deref()
217 && should_refresh_native_skill
218 {
219 crate::compiler::variants::validate_skill_variants(
220 &source,
221 outcome.item_id.name.as_str(),
222 diag,
223 );
224 let projection =
225 match crate::compiler::variants::prepare_native_skill_projection(
226 &source,
227 variant_key,
228 diag,
229 outcome.item_id.name.as_str(),
230 ) {
231 Ok(projection) => projection,
232 Err(e) => {
233 errors
234 .push(format!("failed to prepare {dest_rel}: {e}"));
235 continue;
236 }
237 };
238 let target_projection_matches =
239 crate::compiler::variants::prepared_native_skill_projection_matches(
240 &dest,
241 &projection,
242 )
243 .unwrap_or(false);
244 if target_projection_matches {
245 synced_outputs.push(TargetSyncedOutput {
246 dest_path: dest_rel.to_string(),
247 installed_checksum: projection.installed_checksum,
248 });
249 continue;
250 }
251 match crate::compiler::variants::project_prepared_skill_for_target(
252 &source,
253 &dest,
254 Some(&projection),
255 ) {
256 Ok(wrote) => {
257 if wrote {
258 items_synced += 1;
259 }
260 synced_outputs.push(TargetSyncedOutput {
261 dest_path: dest_rel.to_string(),
262 installed_checksum: projection
263 .installed_checksum
264 .clone(),
265 });
266 if dest_exists {
267 diag.warn(
268 "target-native-projection-repaired",
269 format!(
270 "repaired diverged native projection: {target_name}/{dest_rel}/SKILL.md"
271 ),
272 );
273 }
274 }
275 Err(e) => {
276 errors.push(format!("failed to copy {dest_rel}: {e}"))
277 }
278 }
279 continue;
280 }
281 match copy_item_to_target(
282 &source,
283 &dest,
284 outcome.item_id.kind,
285 outcome.item_id.name.as_str(),
286 native_skill_variant_key.as_deref(),
287 diag,
288 ) {
289 Ok(true) => {
290 items_synced += 1;
291 record_synced_output(
292 &mut synced_outputs,
293 &dest,
294 dest_rel,
295 outcome.item_id.kind,
296 );
297 }
298 Ok(false) => {
299 record_synced_output(
302 &mut synced_outputs,
303 &dest,
304 dest_rel,
305 outcome.item_id.kind,
306 );
307 }
308 Err(e) => errors.push(format!("failed to copy {dest_rel}: {e}")),
309 }
310 }
311 } else if native_skill_variant_key.is_none() && dest_exists {
312 if old_lock
313 .installed_checksum_for_output(target_name, dest_rel)
314 .is_none()
315 {
316 surface_ownership::warn_no_installed_claim_collision(
317 target_name,
318 dest_rel,
319 collision_hint,
320 diag,
321 );
322 } else if let Some(expected_checksum) = &outcome.installed_checksum {
323 match crate::hash::compute_hash(&dest, outcome.item_id.kind) {
324 Ok(actual) => {
325 let actual = ContentHash::from(actual);
326 if &actual != expected_checksum {
327 diag.warn(
328 "target-divergent",
329 format!(
330 "target `{target_name}` item `{}` diverged from `.mars` (preserved local content; run `{cmd1}` or `{cmd2}` to reset)",
331 dest_rel,
332 cmd1 = managed_cmd("mars sync --force"),
333 cmd2 = managed_cmd("mars repair"),
334 ),
335 );
336 }
337 }
338 Err(e) => errors
339 .push(format!("failed to verify {dest_rel} checksum: {e}")),
340 }
341 }
342 }
343 }
344 }
345 _ => {
346 expected_paths.insert(dest_rel.to_string());
347 let source = mars_dir.join(canonical_dest_rel);
348 let dest = target_root.join(dest_rel);
349 if (source.exists() || source.symlink_metadata().is_ok())
350 && should_copy_to_target(&dest, target_name, dest_rel, ctx, diag)
351 {
352 match copy_item_to_target(
353 &source,
354 &dest,
355 outcome.item_id.kind,
356 outcome.item_id.name.as_str(),
357 native_skill_variant_key.as_deref(),
358 diag,
359 ) {
360 Ok(true) => {
361 items_synced += 1;
362 record_synced_output(
363 &mut synced_outputs,
364 &dest,
365 dest_rel,
366 outcome.item_id.kind,
367 );
368 }
369 Ok(false) => {
370 record_synced_output(
373 &mut synced_outputs,
374 &dest,
375 dest_rel,
376 outcome.item_id.kind,
377 );
378 }
379 Err(e) => errors.push(format!("failed to copy {dest_rel}: {e}")),
380 }
381 }
382 }
383 }
384 }
385
386 if let Some(preserve) = ctx.orphan_preserve_paths
387 && let Some(paths) = preserve.get(target_name)
388 {
389 expected_paths.extend(paths.iter().cloned());
390 }
391
392 let orphan_removed = cleanup_orphans(
393 target_root,
394 &expected_paths,
395 &previous_managed_paths,
396 &mut removed_dest_paths,
397 &mut errors,
398 );
399 items_removed += orphan_removed;
400
401 Ok(TargetSyncOutcome {
402 target: target_name.to_string(),
403 items_synced,
404 items_removed,
405 errors,
406 synced_outputs,
407 removed_dest_paths,
408 })
409}
410
411fn should_copy_to_target(
412 dest: &Path,
413 target_name: &str,
414 dest_rel: &str,
415 ctx: &TargetSyncContext<'_>,
416 diag: &mut DiagnosticCollector,
417) -> bool {
418 let dest_exists = surface_ownership::target_dest_exists(dest);
419 match surface_ownership::copy_decision(
420 ctx.old_lock,
421 target_name,
422 dest_rel,
423 dest_exists,
424 ctx.force,
425 ) {
426 SurfaceCopyDecision::Proceed => {
427 if dest_exists
428 && ctx.force
429 && ctx
430 .old_lock
431 .installed_checksum_for_output(target_name, dest_rel)
432 .is_none()
433 {
434 surface_ownership::warn_no_installed_claim_adopted(
435 target_name,
436 dest_rel,
437 ctx.collision_hint,
438 diag,
439 );
440 }
441 true
442 }
443 SurfaceCopyDecision::SkipWithoutInstalledClaim => {
444 surface_ownership::warn_no_installed_claim_collision(
445 target_name,
446 dest_rel,
447 ctx.collision_hint,
448 diag,
449 );
450 false
451 }
452 }
453}
454
455fn disk_matches_recorded_or_desired(
456 dest: &Path,
457 kind: crate::lock::ItemKind,
458 target_name: &str,
459 dest_rel: &str,
460 old_lock: &LockFile,
461 desired_checksum: Option<&ContentHash>,
462) -> bool {
463 let Ok(actual) = crate::hash::compute_hash(dest, kind).map(ContentHash::from) else {
464 return false;
465 };
466 if desired_checksum.is_some_and(|desired| desired == &actual) {
467 return true;
468 }
469 old_lock.items.values().any(|item| {
470 item.outputs.iter().any(|recorded| {
471 recorded.target_root == target_name
472 && crate::target::dest_paths_equivalent(recorded.dest_path.as_str(), dest_rel)
473 && recorded.installed_checksum() == Some(&actual)
474 })
475 })
476}
477
478fn remove_target_path_if_managed(
479 target_path: &Path,
480 target_name: &str,
481 dest_rel: &str,
482 old_lock: &LockFile,
483 errors: &mut Vec<String>,
484) -> bool {
485 if !surface_ownership::target_dest_exists(target_path) {
486 return false;
487 }
488 if !surface_ownership::may_delete(old_lock, target_name, dest_rel) {
489 return false;
490 }
491 match fs_ops::safe_remove(target_path) {
492 Ok(()) => true,
493 Err(e) => {
494 errors.push(format!("failed to remove {dest_rel}: {e}"));
495 false
496 }
497 }
498}
499
500fn record_synced_output(
501 synced_outputs: &mut Vec<TargetSyncedOutput>,
502 dest: &Path,
503 dest_rel: &str,
504 kind: crate::lock::ItemKind,
505) {
506 if let Ok(checksum) = crate::hash::compute_hash(dest, kind) {
507 synced_outputs.push(TargetSyncedOutput {
508 dest_path: dest_rel.to_string(),
509 installed_checksum: ContentHash::from(checksum),
510 });
511 }
512}
513
514fn copy_item_to_target(
522 source: &Path,
523 dest: &Path,
524 kind: crate::lock::ItemKind,
525 item_name: &str,
526 native_skill_variant_key: Option<&str>,
527 diag: &mut DiagnosticCollector,
528) -> Result<bool, MarsError> {
529 if kind == crate::lock::ItemKind::Skill && native_skill_variant_key.is_some() {
530 crate::compiler::variants::validate_skill_variants(source, item_name, diag);
531 return crate::compiler::variants::project_skill_for_target(
532 source,
533 dest,
534 native_skill_variant_key,
535 diag,
536 item_name,
537 );
538 }
539
540 if let Some(parent) = dest.parent() {
542 std::fs::create_dir_all(parent)?;
543 }
544
545 let metadata = std::fs::metadata(source)?;
547
548 if metadata.is_dir() {
549 if dest.exists() && fs_ops::directory_trees_content_equal(source, dest)? {
550 return Ok(false);
551 }
552 fs_ops::atomic_copy_dir(source, dest)?;
553 } else if metadata.is_file() {
554 if fs_ops::file_content_equal(source, dest)? {
555 return Ok(false);
556 }
557 fs_ops::atomic_copy_file(source, dest)?;
558 }
559
560 Ok(true)
561}
562
563fn cleanup_orphans(
572 target_root: &Path,
573 expected: &HashSet<String>,
574 previous_managed_paths: &HashSet<String>,
575 removed_dest_paths: &mut Vec<String>,
576 errors: &mut Vec<String>,
577) -> usize {
578 let mut removed = 0;
579
580 for managed_path in previous_managed_paths {
583 if expected.contains(managed_path) {
584 continue;
585 }
586
587 let full_path = target_root.join(managed_path);
588
589 if !full_path.exists() && full_path.symlink_metadata().is_err() {
592 removed_dest_paths.push(managed_path.clone());
593 continue;
594 }
595
596 if full_path
598 .symlink_metadata()
599 .map(|m| m.file_type().is_symlink())
600 .unwrap_or(false)
601 {
602 continue;
603 }
604
605 if let Err(e) = fs_ops::safe_remove(&full_path) {
606 errors.push(format!("failed to remove orphan {managed_path}: {e}"));
607 } else {
608 removed += 1;
609 removed_dest_paths.push(managed_path.clone());
610 }
611 }
612
613 removed
614}
615
616#[cfg(test)]
617mod tests {
618 use super::*;
619 use crate::diagnostic::DiagnosticCollector;
620 use crate::hash;
621 use crate::lock::{ItemKind, LockFile, LockedItemV2, OutputRecord};
622 use crate::surface_ownership::CollisionAdoptHint;
623 use crate::sync::apply::{ActionOutcome, ActionTaken};
624 use crate::types::{DestPath, ItemName};
625 use tempfile::TempDir;
626
627 fn make_outcome(dest: &str, action: ActionTaken) -> ActionOutcome {
628 ActionOutcome {
629 item_id: crate::lock::ItemId {
630 kind: crate::lock::ItemKind::Agent,
631 name: ItemName::from("test"),
632 },
633 action,
634 dest_path: DestPath::from(dest),
635 source_name: "test-source".into(),
636 source_checksum: None,
637 installed_checksum: None,
638 }
639 }
640
641 fn lock_with_target_outputs(target: &str, outputs: &[(&str, &str)]) -> LockFile {
642 let mut lock = LockFile::empty();
643 for (dest, checksum) in outputs {
644 let name = dest.rsplit('/').next().unwrap_or("item");
645 lock.items.insert(
646 format!("agent/{name}"),
647 LockedItemV2 {
648 source: "test".into(),
649 kind: ItemKind::Agent,
650 version: None,
651 source_checksum: "sha256:src".into(),
652 outputs: vec![OutputRecord::installed(
653 target.to_string(),
654 (*dest).into(),
655 (*checksum).into(),
656 )],
657 },
658 );
659 }
660 lock
661 }
662
663 fn lock_with_skill_target_outputs(target: &str, outputs: &[(&str, &str)]) -> LockFile {
664 let mut lock = LockFile::empty();
665 for (dest, checksum) in outputs {
666 let name = dest.rsplit('/').next().unwrap_or("item");
667 lock.items.insert(
668 format!("skill/{name}"),
669 LockedItemV2 {
670 source: "test".into(),
671 kind: ItemKind::Skill,
672 version: None,
673 source_checksum: "sha256:src".into(),
674 outputs: vec![OutputRecord::installed(
675 target.to_string(),
676 (*dest).into(),
677 (*checksum).into(),
678 )],
679 },
680 );
681 }
682 lock
683 }
684
685 fn target_sync_ctx<'a>(old_lock: &'a LockFile, force: bool) -> TargetSyncContext<'a> {
686 TargetSyncContext {
687 old_lock,
688 force,
689 collision_hint: CollisionAdoptHint::SyncForce,
690 orphan_preserve_paths: None,
691 }
692 }
693
694 fn make_skipped_with_checksum(dest: &str, checksum: &str) -> ActionOutcome {
695 let mut outcome = make_outcome(dest, ActionTaken::Skipped);
696 outcome.installed_checksum = Some(checksum.into());
697 outcome
698 }
699
700 #[test]
701 fn sync_copies_installed_items_to_target() {
702 let dir = TempDir::new().unwrap();
703 let mars_dir = dir.path().join(".mars");
704 let target = dir.path().join(".agents");
705
706 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
708 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
709
710 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
711 let mut diag = DiagnosticCollector::new();
712
713 let results = sync_managed_targets(
714 dir.path(),
715 &mars_dir,
716 &[".agents".to_string()],
717 &outcomes,
718 &target_sync_ctx(&LockFile::empty(), false),
719 &mut diag,
720 );
721
722 assert_eq!(results.len(), 1);
723 assert_eq!(results[0].items_synced, 1);
724 assert!(results[0].errors.is_empty());
725 assert!(target.join("agents/coder.md").exists());
726 assert_eq!(
727 std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
728 "# Coder"
729 );
730 }
731
732 #[test]
733 fn sync_removes_items_from_target() {
734 let dir = TempDir::new().unwrap();
735 let mars_dir = dir.path().join(".mars");
736 let target = dir.path().join(".agents");
737
738 std::fs::create_dir_all(&mars_dir).unwrap();
739 std::fs::create_dir_all(target.join("agents")).unwrap();
740 std::fs::write(target.join("agents/old.md"), "# Old").unwrap();
741
742 let outcomes = vec![make_outcome("agents/old.md", ActionTaken::Removed)];
743 let mut diag = DiagnosticCollector::new();
744
745 let results = sync_managed_targets(
746 dir.path(),
747 &mars_dir,
748 &[".agents".to_string()],
749 &outcomes,
750 &target_sync_ctx(
751 &lock_with_target_outputs(".agents", &[("agents/old.md", "sha256:old")]),
752 false,
753 ),
754 &mut diag,
755 );
756
757 assert_eq!(results[0].items_removed, 1);
758 assert!(!target.join("agents/old.md").exists());
759 }
760
761 #[test]
762 fn sync_cleans_up_previous_managed_orphans() {
763 let dir = TempDir::new().unwrap();
764 let mars_dir = dir.path().join(".mars");
765 let target = dir.path().join(".agents");
766
767 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
769 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
770
771 std::fs::create_dir_all(target.join("agents")).unwrap();
773 std::fs::write(target.join("agents/orphan.md"), "# Orphan").unwrap();
774
775 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
776 let mut diag = DiagnosticCollector::new();
777
778 let results = sync_managed_targets(
779 dir.path(),
780 &mars_dir,
781 &[".agents".to_string()],
782 &outcomes,
783 &target_sync_ctx(
784 &lock_with_target_outputs(".agents", &[("agents/orphan.md", "sha256:orphan")]),
785 false,
786 ),
787 &mut diag,
788 );
789
790 assert!(target.join("agents/coder.md").exists());
791 assert!(!target.join("agents/orphan.md").exists());
792 assert_eq!(results[0].items_removed, 1);
793 }
794
795 #[test]
796 fn sync_preserves_unmanaged_files_in_target() {
797 let dir = TempDir::new().unwrap();
798 let mars_dir = dir.path().join(".mars");
799 let target = dir.path().join(".agents");
800
801 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
802 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
803
804 std::fs::create_dir_all(target.join("agents")).unwrap();
805 std::fs::write(target.join("agents/custom.md"), "# User custom").unwrap();
806
807 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
808 let mut diag = DiagnosticCollector::new();
809
810 let results = sync_managed_targets(
811 dir.path(),
812 &mars_dir,
813 &[".agents".to_string()],
814 &outcomes,
815 &target_sync_ctx(&LockFile::empty(), false),
816 &mut diag,
817 );
818
819 assert!(target.join("agents/coder.md").exists());
820 assert!(target.join("agents/custom.md").exists());
821 assert_eq!(results[0].items_removed, 0);
822 }
823
824 #[test]
825 fn sync_removed_agent_outcome_removes_existing_target_agent_without_copying() {
826 let dir = TempDir::new().unwrap();
827 let mars_dir = dir.path().join(".mars");
828 let target = dir.path().join(".agents");
829
830 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
831 std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
832 std::fs::create_dir_all(target.join("agents")).unwrap();
833 std::fs::write(target.join("agents/coder.md"), "# Existing target copy").unwrap();
834
835 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Removed)];
836 let mut diag = DiagnosticCollector::new();
837
838 let results = sync_managed_targets(
839 dir.path(),
840 &mars_dir,
841 &[".agents".to_string()],
842 &outcomes,
843 &target_sync_ctx(
844 &lock_with_target_outputs(".agents", &[("agents/coder.md", "sha256:coder")]),
845 false,
846 ),
847 &mut diag,
848 );
849
850 assert_eq!(results[0].items_synced, 0);
851 assert_eq!(results[0].items_removed, 1);
852 assert!(!target.join("agents/coder.md").exists());
853 assert!(results[0].errors.is_empty());
854 }
855
856 #[test]
857 fn selective_orphan_preserve_keeps_native_agent_without_agent_outcomes() {
858 let dir = TempDir::new().unwrap();
859 let mars_dir = dir.path().join(".mars");
860 let target = dir.path().join(".claude");
861
862 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
863 std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
864 std::fs::create_dir_all(target.join("agents")).unwrap();
865 std::fs::write(target.join("agents/coder.md"), "# Native").unwrap();
866
867 let old_lock = lock_with_target_outputs(".claude", &[("agents/coder.md", "sha256:native")]);
868 let mut preserve = HashMap::new();
869 preserve.insert(
870 ".claude".to_string(),
871 HashSet::from(["agents/coder.md".to_string()]),
872 );
873 let mut diag = DiagnosticCollector::new();
874
875 let results = sync_managed_targets(
876 dir.path(),
877 &mars_dir,
878 &[".claude".to_string()],
879 &[],
880 &TargetSyncContext {
881 old_lock: &old_lock,
882 force: false,
883 collision_hint: CollisionAdoptHint::SyncForce,
884 orphan_preserve_paths: Some(&preserve),
885 },
886 &mut diag,
887 );
888
889 assert!(target.join("agents/coder.md").exists());
890 assert_eq!(results[0].items_removed, 0);
891 assert!(
892 !results[0]
893 .removed_dest_paths
894 .iter()
895 .any(|path| path == "agents/coder.md"),
896 "selective steady-state must not remove managed native agent before compile"
897 );
898 }
899
900 #[test]
901 fn sync_multiple_targets() {
902 let dir = TempDir::new().unwrap();
903 let mars_dir = dir.path().join(".mars");
904
905 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
906 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
907
908 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
909 let mut diag = DiagnosticCollector::new();
910
911 let results = sync_managed_targets(
912 dir.path(),
913 &mars_dir,
914 &[".agents".to_string(), ".custom-target".to_string()],
915 &outcomes,
916 &target_sync_ctx(&LockFile::empty(), false),
917 &mut diag,
918 );
919
920 assert_eq!(results.len(), 2);
921 assert!(dir.path().join(".agents/agents/coder.md").exists());
922 assert!(dir.path().join(".custom-target/agents/coder.md").exists());
923 }
924
925 #[test]
926 fn sync_native_targets_skip_canonical_agent_markdown_copies() {
927 let dir = TempDir::new().unwrap();
928 let mars_dir = dir.path().join(".mars");
929
930 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
931 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
932
933 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
934 let mut diag = DiagnosticCollector::new();
935
936 let results = sync_managed_targets(
937 dir.path(),
938 &mars_dir,
939 &[
940 ".claude".to_string(),
941 ".codex".to_string(),
942 ".opencode".to_string(),
943 ".pi".to_string(),
944 ],
945 &outcomes,
946 &target_sync_ctx(&LockFile::empty(), false),
947 &mut diag,
948 );
949
950 assert_eq!(results.len(), 4);
951 assert!(results.iter().all(|outcome| outcome.items_synced == 0));
952 assert!(!dir.path().join(".claude/agents/coder.md").exists());
953 assert!(!dir.path().join(".codex/agents/coder.md").exists());
954 assert!(!dir.path().join(".opencode/agents/coder.md").exists());
955 assert!(!dir.path().join(".pi/agents/coder.md").exists());
956 }
957
958 #[test]
959 fn sync_unknown_target_still_copies_canonical_agents() {
960 let dir = TempDir::new().unwrap();
961 let mars_dir = dir.path().join(".mars");
962
963 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
964 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
965
966 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
967 let mut diag = DiagnosticCollector::new();
968
969 let results = sync_managed_targets(
970 dir.path(),
971 &mars_dir,
972 &[".custom-target".to_string()],
973 &outcomes,
974 &target_sync_ctx(&LockFile::empty(), false),
975 &mut diag,
976 );
977
978 assert_eq!(results[0].items_synced, 1);
979 assert!(dir.path().join(".custom-target/agents/coder.md").exists());
980 }
981
982 #[test]
983 fn sync_skill_directory() {
984 let dir = TempDir::new().unwrap();
985 let mars_dir = dir.path().join(".mars");
986 let target = dir.path().join(".agents");
987
988 std::fs::create_dir_all(mars_dir.join("skills/planning")).unwrap();
989 std::fs::write(mars_dir.join("skills/planning/SKILL.md"), "# Planning").unwrap();
990
991 let mut outcome = make_outcome("skills/planning", ActionTaken::Installed);
992 outcome.item_id.kind = crate::lock::ItemKind::Skill;
993 let outcomes = vec![outcome];
994 let mut diag = DiagnosticCollector::new();
995
996 let results = sync_managed_targets(
997 dir.path(),
998 &mars_dir,
999 &[".agents".to_string()],
1000 &outcomes,
1001 &target_sync_ctx(&LockFile::empty(), false),
1002 &mut diag,
1003 );
1004
1005 assert_eq!(results[0].items_synced, 1);
1006 assert!(target.join("skills/planning/SKILL.md").exists());
1007 }
1008
1009 #[test]
1010 fn sync_projects_skills_for_native_harness_targets() {
1011 let dir = TempDir::new().unwrap();
1012 let mars_dir = dir.path().join(".mars");
1013 let target = dir.path().join(".claude");
1014
1015 std::fs::create_dir_all(mars_dir.join("skills/planning/resources")).unwrap();
1016 std::fs::create_dir_all(mars_dir.join("skills/planning/variants/claude")).unwrap();
1017 std::fs::create_dir_all(target.join("skills")).unwrap();
1018 std::fs::write(target.join("skills/orphan"), "# Orphan").unwrap();
1019 std::fs::write(mars_dir.join("skills/planning/SKILL.md"), "# Base").unwrap();
1020 std::fs::write(
1021 mars_dir.join("skills/planning/resources/BOOTSTRAP.md"),
1022 "# Bootstrap",
1023 )
1024 .unwrap();
1025 std::fs::write(
1026 mars_dir.join("skills/planning/variants/claude/SKILL.md"),
1027 "# Claude",
1028 )
1029 .unwrap();
1030
1031 let mut outcome = make_outcome("skills/planning", ActionTaken::Installed);
1032 outcome.item_id.kind = crate::lock::ItemKind::Skill;
1033 let outcomes = vec![outcome];
1034 let mut diag = DiagnosticCollector::new();
1035
1036 let results = sync_managed_targets(
1037 dir.path(),
1038 &mars_dir,
1039 &[".claude".to_string()],
1040 &outcomes,
1041 &target_sync_ctx(
1042 &lock_with_skill_target_outputs(
1043 ".claude",
1044 &[
1045 ("skills/planning", "sha256:planning"),
1046 ("skills/orphan", "sha256:orphan"),
1047 ],
1048 ),
1049 false,
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("skills/planning/SKILL.md")).unwrap(),
1057 "# Claude"
1058 );
1059 assert_eq!(
1060 std::fs::read_to_string(target.join("skills/planning/resources/BOOTSTRAP.md")).unwrap(),
1061 "# Bootstrap"
1062 );
1063 assert!(!target.join("skills/planning/variants").exists());
1064 assert!(!target.join("skills/orphan").exists());
1065 }
1066
1067 #[test]
1068 fn cleanup_orphans_uses_forward_slash_keys_for_expected_paths() {
1069 let dir = TempDir::new().unwrap();
1070 let target_root = dir.path().join(".agents");
1071 std::fs::create_dir_all(target_root.join("agents")).unwrap();
1072 std::fs::write(target_root.join("agents/coder.md"), "# Managed").unwrap();
1073 std::fs::write(target_root.join("agents/orphan.md"), "# Orphan").unwrap();
1074
1075 let mut expected = HashSet::new();
1076 expected.insert(
1077 DestPath::new(r"agents\coder.md")
1078 .unwrap()
1079 .as_str()
1080 .to_string(),
1081 );
1082
1083 let previous = lock_with_target_outputs(
1084 ".agents",
1085 &[
1086 ("agents/coder.md", "sha256:coder"),
1087 ("agents/orphan.md", "sha256:orphan"),
1088 ],
1089 );
1090 let previous_paths = previous.output_dest_paths_for_target(".agents");
1091 let mut removed_dest_paths = Vec::new();
1092 let removed = cleanup_orphans(
1093 &target_root,
1094 &expected,
1095 &previous_paths,
1096 &mut removed_dest_paths,
1097 &mut Vec::new(),
1098 );
1099
1100 assert_eq!(removed, 1);
1101 assert!(target_root.join("agents/coder.md").exists());
1102 assert!(!target_root.join("agents/orphan.md").exists());
1103 }
1104
1105 #[test]
1106 fn sync_convergence_on_rerun() {
1107 let dir = TempDir::new().unwrap();
1108 let mars_dir = dir.path().join(".mars");
1109 let target = dir.path().join(".agents");
1110
1111 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
1112 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
1113
1114 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
1115 let mut diag = DiagnosticCollector::new();
1116
1117 sync_managed_targets(
1119 dir.path(),
1120 &mars_dir,
1121 &[".agents".to_string()],
1122 &outcomes,
1123 &target_sync_ctx(&LockFile::empty(), false),
1124 &mut diag,
1125 );
1126
1127 let outcomes2 = vec![make_outcome("agents/coder.md", ActionTaken::Skipped)];
1129 let results = sync_managed_targets(
1130 dir.path(),
1131 &mars_dir,
1132 &[".agents".to_string()],
1133 &outcomes2,
1134 &target_sync_ctx(
1135 &lock_with_target_outputs(".agents", &[("agents/coder.md", "sha256:coder")]),
1136 false,
1137 ),
1138 &mut diag,
1139 );
1140
1141 assert!(target.join("agents/coder.md").exists());
1142 assert_eq!(results[0].items_synced, 0);
1144 }
1145
1146 #[test]
1147 fn sync_force_refreshes_skipped_target_content() {
1148 let dir = TempDir::new().unwrap();
1149 let mars_dir = dir.path().join(".mars");
1150 let target = dir.path().join(".agents");
1151
1152 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
1153 std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
1154
1155 std::fs::create_dir_all(target.join("agents")).unwrap();
1156 std::fs::write(target.join("agents/coder.md"), "# Tampered").unwrap();
1157
1158 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Skipped)];
1159 let mut diag = DiagnosticCollector::new();
1160 let results = sync_managed_targets(
1161 dir.path(),
1162 &mars_dir,
1163 &[".agents".to_string()],
1164 &outcomes,
1165 &target_sync_ctx(
1166 &lock_with_target_outputs(".agents", &[("agents/coder.md", "sha256:coder")]),
1167 true,
1168 ),
1169 &mut diag,
1170 );
1171
1172 assert_eq!(results[0].items_synced, 1);
1173 assert_eq!(
1174 std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
1175 "# Canonical"
1176 );
1177 }
1178
1179 #[test]
1180 fn sync_skipped_recopies_missing_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
1188 let checksum = hash::hash_bytes(b"# Canonical");
1189 let outcomes = vec![make_skipped_with_checksum("agents/coder.md", &checksum)];
1190 let mut diag = DiagnosticCollector::new();
1191 let results = sync_managed_targets(
1192 dir.path(),
1193 &mars_dir,
1194 &[".agents".to_string()],
1195 &outcomes,
1196 &target_sync_ctx(
1197 &lock_with_target_outputs(".agents", &[("agents/coder.md", "sha256:coder")]),
1198 false,
1199 ),
1200 &mut diag,
1201 );
1202
1203 assert_eq!(results[0].items_synced, 1);
1204 assert!(target.join("agents/coder.md").exists());
1205 }
1206
1207 #[test]
1208 fn sync_skipped_warns_on_divergent_target_and_preserves_local_content() {
1209 let dir = TempDir::new().unwrap();
1210 let mars_dir = dir.path().join(".mars");
1211 let target = dir.path().join(".agents");
1212
1213 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
1214 std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
1215
1216 std::fs::create_dir_all(target.join("agents")).unwrap();
1217 std::fs::write(target.join("agents/coder.md"), "# Locally edited").unwrap();
1218
1219 let checksum = hash::hash_bytes(b"# Canonical");
1220 let outcomes = vec![make_skipped_with_checksum("agents/coder.md", &checksum)];
1221 let mut diag = DiagnosticCollector::new();
1222 let results = sync_managed_targets(
1223 dir.path(),
1224 &mars_dir,
1225 &[".agents".to_string()],
1226 &outcomes,
1227 &target_sync_ctx(
1228 &lock_with_target_outputs(".agents", &[("agents/coder.md", "sha256:coder")]),
1229 false,
1230 ),
1231 &mut diag,
1232 );
1233
1234 assert_eq!(results[0].items_synced, 0);
1235 assert_eq!(
1236 std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
1237 "# Locally edited"
1238 );
1239
1240 let diagnostics = diag.drain();
1241 assert!(
1242 diagnostics
1243 .iter()
1244 .any(|d| d.code == "target-divergent" && d.message.contains("agents/coder.md"))
1245 );
1246 }
1247
1248 #[test]
1249 fn sync_skipped_pending_deletion_reports_missing_installed_claim() {
1250 let dir = TempDir::new().unwrap();
1251 let mars_dir = dir.path().join(".mars");
1252 let target = dir.path().join(".agents");
1253
1254 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
1255 std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
1256 std::fs::create_dir_all(target.join("agents")).unwrap();
1257 std::fs::write(target.join("agents/coder.md"), "# User replacement").unwrap();
1258
1259 let checksum = hash::hash_bytes(b"# Canonical");
1260 let outcomes = vec![make_skipped_with_checksum("agents/coder.md", &checksum)];
1261 let mut lock = lock_with_target_outputs(".agents", &[("agents/coder.md", "sha256:old")]);
1262 lock.items.get_mut("agent/coder.md").unwrap().outputs[0].mark_pending_deletion();
1263 let mut diag = DiagnosticCollector::new();
1264
1265 let results = sync_managed_targets(
1266 dir.path(),
1267 &mars_dir,
1268 &[".agents".to_string()],
1269 &outcomes,
1270 &target_sync_ctx(&lock, false),
1271 &mut diag,
1272 );
1273
1274 assert_eq!(results[0].items_synced, 0);
1275 assert_eq!(
1276 std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
1277 "# User replacement"
1278 );
1279 let diagnostics = diag.drain();
1280 assert!(
1281 diagnostics.iter().any(|diagnostic| {
1282 diagnostic.code == "target-unmanaged-collision"
1283 && diagnostic
1284 .message
1285 .contains("has no installed-content claim")
1286 }),
1287 "a pending-deletion record carries no installed-content authority"
1288 );
1289 assert!(
1290 diagnostics
1291 .iter()
1292 .all(|diagnostic| diagnostic.code != "target-divergent")
1293 );
1294 }
1295
1296 #[test]
1297 fn sync_preserves_handwritten_collision_when_lock_only_tracks_mars() {
1298 let dir = TempDir::new().unwrap();
1299 let mars_dir = dir.path().join(".mars");
1300 let target = dir.path().join(".cursor");
1301
1302 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
1303 std::fs::write(mars_dir.join("agents/design-lead.md"), "# Canonical").unwrap();
1304 std::fs::create_dir_all(target.join("agents")).unwrap();
1305 std::fs::write(target.join("agents/cursor-only-test.md"), "# custom").unwrap();
1306 std::fs::write(target.join("agents/design-lead.md"), "# hand-written").unwrap();
1307
1308 let mut lock = LockFile::empty();
1309 lock.items.insert(
1310 "agent/design-lead".to_string(),
1311 LockedItemV2 {
1312 source: "test".into(),
1313 kind: ItemKind::Agent,
1314 version: None,
1315 source_checksum: "sha256:src".into(),
1316 outputs: vec![OutputRecord::installed(
1317 ".mars".to_string(),
1318 "agents/design-lead.md".into(),
1319 "sha256:mars".into(),
1320 )],
1321 },
1322 );
1323
1324 let outcomes = vec![make_outcome("agents/design-lead.md", ActionTaken::Removed)];
1325 let mut diag = DiagnosticCollector::new();
1326
1327 let results = sync_managed_targets(
1328 dir.path(),
1329 &mars_dir,
1330 &[".cursor".to_string()],
1331 &outcomes,
1332 &target_sync_ctx(&lock, false),
1333 &mut diag,
1334 );
1335
1336 assert_eq!(results[0].items_removed, 0);
1337 assert!(target.join("agents/cursor-only-test.md").exists());
1338 assert!(target.join("agents/design-lead.md").exists());
1339 assert_eq!(
1340 std::fs::read_to_string(target.join("agents/design-lead.md")).unwrap(),
1341 "# hand-written"
1342 );
1343 }
1344
1345 #[test]
1346 fn sync_installed_does_not_overwrite_untracked_collision_in_linked_target() {
1347 let dir = TempDir::new().unwrap();
1348 let mars_dir = dir.path().join(".mars");
1349 let target = dir.path().join(".agents");
1350
1351 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
1352 std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
1353 std::fs::create_dir_all(target.join("agents")).unwrap();
1354 std::fs::write(target.join("agents/coder.md"), "# hand-written").unwrap();
1355
1356 let mut lock = LockFile::empty();
1357 lock.items.insert(
1358 "agent/coder".to_string(),
1359 LockedItemV2 {
1360 source: "test".into(),
1361 kind: ItemKind::Agent,
1362 version: None,
1363 source_checksum: "sha256:src".into(),
1364 outputs: vec![OutputRecord::installed(
1365 ".mars".to_string(),
1366 "agents/coder.md".into(),
1367 "sha256:mars".into(),
1368 )],
1369 },
1370 );
1371
1372 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
1373 let mut diag = DiagnosticCollector::new();
1374
1375 let results = sync_managed_targets(
1376 dir.path(),
1377 &mars_dir,
1378 &[".agents".to_string()],
1379 &outcomes,
1380 &target_sync_ctx(&lock, false),
1381 &mut diag,
1382 );
1383
1384 assert_eq!(results[0].items_synced, 0);
1385 assert_eq!(
1386 std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
1387 "# hand-written"
1388 );
1389 let diagnostics = diag.drain();
1390 assert!(
1391 diagnostics
1392 .iter()
1393 .any(|d| d.code == "target-unmanaged-collision")
1394 );
1395 }
1396
1397 #[test]
1398 fn sync_force_adopts_untracked_collision_in_linked_target() {
1399 let dir = TempDir::new().unwrap();
1400 let mars_dir = dir.path().join(".mars");
1401 let target = dir.path().join(".agents");
1402
1403 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
1404 std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
1405 std::fs::create_dir_all(target.join("agents")).unwrap();
1406 std::fs::write(target.join("agents/coder.md"), "# hand-written").unwrap();
1407
1408 let mut lock = LockFile::empty();
1409 lock.items.insert(
1410 "agent/coder".to_string(),
1411 LockedItemV2 {
1412 source: "test".into(),
1413 kind: ItemKind::Agent,
1414 version: None,
1415 source_checksum: "sha256:src".into(),
1416 outputs: vec![OutputRecord::installed(
1417 ".mars".to_string(),
1418 "agents/coder.md".into(),
1419 "sha256:mars".into(),
1420 )],
1421 },
1422 );
1423
1424 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
1425 let mut diag = DiagnosticCollector::new();
1426
1427 let results = sync_managed_targets(
1428 dir.path(),
1429 &mars_dir,
1430 &[".agents".to_string()],
1431 &outcomes,
1432 &target_sync_ctx(&lock, true),
1433 &mut diag,
1434 );
1435
1436 assert_eq!(results[0].items_synced, 1);
1437 assert_eq!(
1438 std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
1439 "# Canonical"
1440 );
1441 assert!(!results[0].synced_outputs.is_empty());
1442 let diagnostics = diag.drain();
1443 assert!(
1444 diagnostics
1445 .iter()
1446 .any(|d| d.code == "target-unmanaged-adopted")
1447 );
1448 }
1449
1450 fn make_skipped_skill_outcome(dest: &str, name: &str) -> ActionOutcome {
1451 ActionOutcome {
1452 item_id: crate::lock::ItemId {
1453 kind: ItemKind::Skill,
1454 name: ItemName::from(name),
1455 },
1456 action: ActionTaken::Skipped,
1457 dest_path: DestPath::from(dest),
1458 source_name: "test-source".into(),
1459 source_checksum: None,
1460 installed_checksum: None,
1461 }
1462 }
1463
1464 fn make_installed_skill_outcome(dest: &str, name: &str) -> ActionOutcome {
1465 ActionOutcome {
1466 item_id: crate::lock::ItemId {
1467 kind: ItemKind::Skill,
1468 name: ItemName::from(name),
1469 },
1470 action: ActionTaken::Installed,
1471 dest_path: DestPath::from(dest),
1472 source_name: "test-source".into(),
1473 source_checksum: None,
1474 installed_checksum: None,
1475 }
1476 }
1477
1478 #[test]
1479 fn sync_skipped_native_skill_projection_skips_byte_identical_rewrite() {
1480 let dir = TempDir::new().unwrap();
1481 let mars_dir = dir.path().join(".mars");
1482 let target = dir.path().join(".claude");
1483 let skill_source = mars_dir.join("skills/planning");
1484 std::fs::create_dir_all(skill_source.join("variants/claude")).unwrap();
1485 std::fs::write(
1486 skill_source.join("SKILL.md"),
1487 "---\nname: planning\ndescription: Base\n---\n# Base\n",
1488 )
1489 .unwrap();
1490 std::fs::write(skill_source.join("variants/claude/SKILL.md"), "# Claude").unwrap();
1491
1492 let outcomes = vec![make_installed_skill_outcome("skills/planning", "planning")];
1493 let mut diag = DiagnosticCollector::new();
1494 sync_managed_targets(
1495 dir.path(),
1496 &mars_dir,
1497 &[".claude".to_string()],
1498 &outcomes,
1499 &target_sync_ctx(&LockFile::empty(), false),
1500 &mut diag,
1501 );
1502
1503 let native_skill = target.join("skills/planning/SKILL.md");
1504 assert!(native_skill.exists());
1505 let expected = std::fs::read_to_string(&native_skill).unwrap();
1506 let before = std::fs::metadata(&native_skill)
1507 .unwrap()
1508 .modified()
1509 .unwrap();
1510
1511 std::thread::sleep(std::time::Duration::from_millis(1100));
1512
1513 let skill_dir = target.join("skills/planning");
1514 let checksum = hash::compute_hash(&skill_dir, ItemKind::Skill).unwrap();
1515 let lock =
1516 lock_with_skill_target_outputs(".claude", &[("skills/planning", checksum.as_str())]);
1517 let outcomes2 = vec![make_skipped_skill_outcome("skills/planning", "planning")];
1518 let results = sync_managed_targets(
1519 dir.path(),
1520 &mars_dir,
1521 &[".claude".to_string()],
1522 &outcomes2,
1523 &target_sync_ctx(&lock, false),
1524 &mut diag,
1525 );
1526
1527 assert_eq!(results[0].items_synced, 0);
1528 let after = std::fs::metadata(&native_skill)
1529 .unwrap()
1530 .modified()
1531 .unwrap();
1532 assert_eq!(
1533 before, after,
1534 "no-op sync must not rewrite native skill output"
1535 );
1536 assert_eq!(std::fs::read_to_string(&native_skill).unwrap(), expected);
1537 }
1538
1539 #[cfg(unix)]
1540 #[test]
1541 fn sync_skipped_native_skill_does_not_construct_a_temporary_projection() {
1542 use std::os::unix::fs::PermissionsExt;
1543
1544 let dir = TempDir::new().unwrap();
1545 let mars_dir = dir.path().join(".mars");
1546 let target = dir.path().join(".claude");
1547 let skill_source = mars_dir.join("skills/planning");
1548 std::fs::create_dir_all(skill_source.join("resources")).unwrap();
1549 std::fs::write(skill_source.join("SKILL.md"), "# Planning").unwrap();
1550 std::fs::write(skill_source.join("resources/reference.md"), "reference").unwrap();
1551
1552 let outcomes = vec![make_installed_skill_outcome("skills/planning", "planning")];
1553 let mut diag = DiagnosticCollector::new();
1554 sync_managed_targets(
1555 dir.path(),
1556 &mars_dir,
1557 &[".claude".to_string()],
1558 &outcomes,
1559 &target_sync_ctx(&LockFile::empty(), false),
1560 &mut diag,
1561 );
1562
1563 let skill_dir = target.join("skills/planning");
1564 let checksum = hash::compute_hash(&skill_dir, ItemKind::Skill).unwrap();
1565 let lock =
1566 lock_with_skill_target_outputs(".claude", &[("skills/planning", checksum.as_str())]);
1567 let skills_parent = target.join("skills");
1568 std::fs::set_permissions(&skills_parent, std::fs::Permissions::from_mode(0o555)).unwrap();
1569
1570 let results = sync_managed_targets(
1571 dir.path(),
1572 &mars_dir,
1573 &[".claude".to_string()],
1574 &[make_skipped_skill_outcome("skills/planning", "planning")],
1575 &target_sync_ctx(&lock, false),
1576 &mut diag,
1577 );
1578
1579 std::fs::set_permissions(&skills_parent, std::fs::Permissions::from_mode(0o755)).unwrap();
1580 assert!(
1581 results[0].errors.is_empty(),
1582 "a no-op must not need a writable parent for a disposable projection: {:?}",
1583 results[0].errors
1584 );
1585 assert_eq!(results[0].items_synced, 0);
1586 }
1587
1588 #[test]
1589 fn sync_changed_native_skill_projection_rewrites_target_output() {
1590 let dir = TempDir::new().unwrap();
1591 let mars_dir = dir.path().join(".mars");
1592 let target = dir.path().join(".claude");
1593 let skill_source = mars_dir.join("skills/planning");
1594 std::fs::create_dir_all(skill_source.join("variants/claude")).unwrap();
1595 std::fs::write(
1596 skill_source.join("SKILL.md"),
1597 "---\nname: planning\ndescription: Base\n---\n# Base\n",
1598 )
1599 .unwrap();
1600 std::fs::write(skill_source.join("variants/claude/SKILL.md"), "# Claude v1").unwrap();
1601
1602 let outcomes = vec![make_installed_skill_outcome("skills/planning", "planning")];
1603 let mut diag = DiagnosticCollector::new();
1604 sync_managed_targets(
1605 dir.path(),
1606 &mars_dir,
1607 &[".claude".to_string()],
1608 &outcomes,
1609 &target_sync_ctx(&LockFile::empty(), false),
1610 &mut diag,
1611 );
1612
1613 let expected = std::fs::read_to_string(target.join("skills/planning/SKILL.md")).unwrap();
1614
1615 std::fs::write(skill_source.join("variants/claude/SKILL.md"), "# Claude v2").unwrap();
1616 let outcomes2 = vec![make_installed_skill_outcome("skills/planning", "planning")];
1617 let results = sync_managed_targets(
1618 dir.path(),
1619 &mars_dir,
1620 &[".claude".to_string()],
1621 &outcomes2,
1622 &target_sync_ctx(
1623 &lock_with_skill_target_outputs(".claude", &[("skills/planning", "sha256:old")]),
1624 false,
1625 ),
1626 &mut diag,
1627 );
1628
1629 assert_eq!(results[0].items_synced, 1);
1630 let updated = std::fs::read_to_string(target.join("skills/planning/SKILL.md")).unwrap();
1631 assert!(updated.contains("# Claude v2"));
1632 assert_ne!(updated, expected);
1633 }
1634}