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