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
116 for outcome in outcomes {
117 let dest_rel = outcome.dest_path.as_str();
118
119 match &outcome.action {
120 ActionTaken::Removed => {
121 let target_path = target_root.join(dest_rel);
123 if target_path.exists() || target_path.symlink_metadata().is_ok() {
124 if let Err(e) = fs_ops::safe_remove(&target_path) {
125 errors.push(format!("failed to remove {dest_rel}: {e}"));
126 } else {
127 items_removed += 1;
128 }
129 }
130 }
131 ActionTaken::Skipped => {
132 expected_paths.insert(dest_rel.to_string());
134 let source = mars_dir.join(dest_rel);
135 let dest = target_root.join(dest_rel);
136 if source.exists() || source.symlink_metadata().is_ok() {
137 if force || !dest.exists() {
138 match copy_item_to_target(&source, &dest) {
139 Ok(()) => items_synced += 1,
140 Err(e) => errors.push(format!("failed to copy {dest_rel}: {e}")),
141 }
142 } else if let Some(expected_checksum) = &outcome.installed_checksum {
143 match crate::hash::compute_hash(&dest, outcome.item_id.kind) {
144 Ok(actual) => {
145 let actual = ContentHash::from(actual);
146 if &actual != expected_checksum {
147 diag.warn(
148 "target-divergent",
149 format!(
150 "target `{target_name}` item `{}` diverged from `.mars` (preserved local content; run `mars sync --force` or `mars repair` to reset)",
151 dest_rel
152 ),
153 );
154 }
155 }
156 Err(e) => {
157 errors.push(format!("failed to verify {dest_rel} checksum: {e}"))
158 }
159 }
160 }
161 }
162 }
163 _ => {
164 expected_paths.insert(dest_rel.to_string());
167 let source = mars_dir.join(dest_rel);
168 let dest = target_root.join(dest_rel);
169 if source.exists() || source.symlink_metadata().is_ok() {
170 match copy_item_to_target(&source, &dest) {
171 Ok(()) => items_synced += 1,
172 Err(e) => errors.push(format!("failed to copy {dest_rel}: {e}")),
173 }
174 }
175 }
176 }
177 }
178
179 let orphan_removed = cleanup_orphans(
181 target_root,
182 &expected_paths,
183 previous_managed_paths,
184 &mut errors,
185 );
186 items_removed += orphan_removed;
187
188 Ok(TargetSyncOutcome {
189 target: target_name.to_string(),
190 items_synced,
191 items_removed,
192 errors,
193 })
194}
195
196fn copy_item_to_target(source: &Path, dest: &Path) -> Result<(), MarsError> {
201 if let Some(parent) = dest.parent() {
203 std::fs::create_dir_all(parent)?;
204 }
205
206 let metadata = std::fs::metadata(source)?;
208
209 if metadata.is_dir() {
210 fs_ops::atomic_copy_dir(source, dest)?;
211 } else if metadata.is_file() {
212 fs_ops::atomic_copy_file(source, dest)?;
213 }
214
215 Ok(())
216}
217
218fn cleanup_orphans(
225 target_root: &Path,
226 expected: &HashSet<String>,
227 previous_managed_paths: &HashSet<String>,
228 errors: &mut Vec<String>,
229) -> usize {
230 let mut removed = 0;
231
232 for subdir in ["agents", "skills"] {
233 let scan_dir = target_root.join(subdir);
234 if !scan_dir.exists() {
235 continue;
236 }
237
238 if scan_dir.symlink_metadata().is_ok()
240 && scan_dir
241 .symlink_metadata()
242 .map(|m| m.file_type().is_symlink())
243 .unwrap_or(false)
244 {
245 continue;
246 }
247
248 let entries = match std::fs::read_dir(&scan_dir) {
249 Ok(e) => e,
250 Err(_) => continue,
251 };
252
253 for entry in entries.flatten() {
254 let file_name = entry.file_name();
255 let name_str = file_name.to_string_lossy();
256
257 if name_str.starts_with('.') {
259 continue;
260 }
261
262 let rel_path_str = format!("{}/{}", subdir, name_str);
264 if previous_managed_paths.contains(&rel_path_str) && !expected.contains(&rel_path_str) {
265 let full_path = entry.path();
266 if let Err(e) = fs_ops::safe_remove(&full_path) {
267 errors.push(format!("failed to remove orphan {}: {e}", rel_path_str));
268 } else {
269 removed += 1;
270 }
271 }
272 }
273 }
274
275 removed
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use crate::diagnostic::DiagnosticCollector;
282 use crate::hash;
283 use crate::sync::apply::{ActionOutcome, ActionTaken};
284 use crate::types::{DestPath, ItemName};
285 use tempfile::TempDir;
286
287 fn make_outcome(dest: &str, action: ActionTaken) -> ActionOutcome {
288 ActionOutcome {
289 item_id: crate::lock::ItemId {
290 kind: crate::lock::ItemKind::Agent,
291 name: ItemName::from("test"),
292 },
293 action,
294 dest_path: DestPath::from(dest),
295 source_name: "test-source".into(),
296 source_checksum: None,
297 installed_checksum: None,
298 }
299 }
300
301 fn managed_paths(paths: &[&str]) -> HashSet<String> {
302 paths
303 .iter()
304 .map(|p| (*p).to_string())
305 .collect::<HashSet<String>>()
306 }
307
308 fn make_skipped_with_checksum(dest: &str, checksum: &str) -> ActionOutcome {
309 let mut outcome = make_outcome(dest, ActionTaken::Skipped);
310 outcome.installed_checksum = Some(checksum.into());
311 outcome
312 }
313
314 #[test]
315 fn sync_copies_installed_items_to_target() {
316 let dir = TempDir::new().unwrap();
317 let mars_dir = dir.path().join(".mars");
318 let target = dir.path().join(".agents");
319
320 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
322 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
323
324 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
325 let mut diag = DiagnosticCollector::new();
326
327 let results = sync_managed_targets(
328 dir.path(),
329 &mars_dir,
330 &[".agents".to_string()],
331 &outcomes,
332 &managed_paths(&[]),
333 false,
334 &mut diag,
335 );
336
337 assert_eq!(results.len(), 1);
338 assert_eq!(results[0].items_synced, 1);
339 assert!(results[0].errors.is_empty());
340 assert!(target.join("agents/coder.md").exists());
341 assert_eq!(
342 std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
343 "# Coder"
344 );
345 }
346
347 #[test]
348 fn sync_removes_items_from_target() {
349 let dir = TempDir::new().unwrap();
350 let mars_dir = dir.path().join(".mars");
351 let target = dir.path().join(".agents");
352
353 std::fs::create_dir_all(&mars_dir).unwrap();
354 std::fs::create_dir_all(target.join("agents")).unwrap();
355 std::fs::write(target.join("agents/old.md"), "# Old").unwrap();
356
357 let outcomes = vec![make_outcome("agents/old.md", ActionTaken::Removed)];
358 let mut diag = DiagnosticCollector::new();
359
360 let results = sync_managed_targets(
361 dir.path(),
362 &mars_dir,
363 &[".agents".to_string()],
364 &outcomes,
365 &managed_paths(&["agents/old.md"]),
366 false,
367 &mut diag,
368 );
369
370 assert_eq!(results[0].items_removed, 1);
371 assert!(!target.join("agents/old.md").exists());
372 }
373
374 #[test]
375 fn sync_cleans_up_previous_managed_orphans() {
376 let dir = TempDir::new().unwrap();
377 let mars_dir = dir.path().join(".mars");
378 let target = dir.path().join(".agents");
379
380 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
382 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
383
384 std::fs::create_dir_all(target.join("agents")).unwrap();
386 std::fs::write(target.join("agents/orphan.md"), "# Orphan").unwrap();
387
388 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
389 let mut diag = DiagnosticCollector::new();
390
391 let results = sync_managed_targets(
392 dir.path(),
393 &mars_dir,
394 &[".agents".to_string()],
395 &outcomes,
396 &managed_paths(&["agents/orphan.md"]),
397 false,
398 &mut diag,
399 );
400
401 assert!(target.join("agents/coder.md").exists());
402 assert!(!target.join("agents/orphan.md").exists());
403 assert_eq!(results[0].items_removed, 1);
404 }
405
406 #[test]
407 fn sync_preserves_unmanaged_files_in_target() {
408 let dir = TempDir::new().unwrap();
409 let mars_dir = dir.path().join(".mars");
410 let target = dir.path().join(".agents");
411
412 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
413 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
414
415 std::fs::create_dir_all(target.join("agents")).unwrap();
416 std::fs::write(target.join("agents/custom.md"), "# User custom").unwrap();
417
418 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
419 let mut diag = DiagnosticCollector::new();
420
421 let results = sync_managed_targets(
422 dir.path(),
423 &mars_dir,
424 &[".agents".to_string()],
425 &outcomes,
426 &managed_paths(&[]),
427 false,
428 &mut diag,
429 );
430
431 assert!(target.join("agents/coder.md").exists());
432 assert!(target.join("agents/custom.md").exists());
433 assert_eq!(results[0].items_removed, 0);
434 }
435
436 #[test]
437 fn sync_multiple_targets() {
438 let dir = TempDir::new().unwrap();
439 let mars_dir = dir.path().join(".mars");
440
441 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
442 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
443
444 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
445 let mut diag = DiagnosticCollector::new();
446
447 let results = sync_managed_targets(
448 dir.path(),
449 &mars_dir,
450 &[".agents".to_string(), ".claude".to_string()],
451 &outcomes,
452 &managed_paths(&[]),
453 false,
454 &mut diag,
455 );
456
457 assert_eq!(results.len(), 2);
458 assert!(dir.path().join(".agents/agents/coder.md").exists());
459 assert!(dir.path().join(".claude/agents/coder.md").exists());
460 }
461
462 #[test]
463 fn sync_skill_directory() {
464 let dir = TempDir::new().unwrap();
465 let mars_dir = dir.path().join(".mars");
466 let target = dir.path().join(".agents");
467
468 std::fs::create_dir_all(mars_dir.join("skills/planning")).unwrap();
469 std::fs::write(mars_dir.join("skills/planning/SKILL.md"), "# Planning").unwrap();
470
471 let mut outcome = make_outcome("skills/planning", ActionTaken::Installed);
472 outcome.item_id.kind = crate::lock::ItemKind::Skill;
473 let outcomes = vec![outcome];
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_eq!(results[0].items_synced, 1);
487 assert!(target.join("skills/planning/SKILL.md").exists());
488 }
489
490 #[test]
491 fn cleanup_orphans_uses_forward_slash_keys_for_expected_paths() {
492 let dir = TempDir::new().unwrap();
493 let target_root = dir.path().join(".agents");
494 std::fs::create_dir_all(target_root.join("agents")).unwrap();
495 std::fs::write(target_root.join("agents/coder.md"), "# Managed").unwrap();
496 std::fs::write(target_root.join("agents/orphan.md"), "# Orphan").unwrap();
497
498 let mut expected = HashSet::new();
499 expected.insert(
500 DestPath::new(r"agents\coder.md")
501 .unwrap()
502 .as_str()
503 .to_string(),
504 );
505
506 let removed = cleanup_orphans(
507 &target_root,
508 &expected,
509 &managed_paths(&["agents/coder.md", "agents/orphan.md"]),
510 &mut Vec::new(),
511 );
512
513 assert_eq!(removed, 1);
514 assert!(target_root.join("agents/coder.md").exists());
515 assert!(!target_root.join("agents/orphan.md").exists());
516 }
517
518 #[test]
519 fn sync_convergence_on_rerun() {
520 let dir = TempDir::new().unwrap();
521 let mars_dir = dir.path().join(".mars");
522 let target = dir.path().join(".agents");
523
524 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
525 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
526
527 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
528 let mut diag = DiagnosticCollector::new();
529
530 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 let outcomes2 = vec![make_outcome("agents/coder.md", ActionTaken::Skipped)];
543 let results = sync_managed_targets(
544 dir.path(),
545 &mars_dir,
546 &[".agents".to_string()],
547 &outcomes2,
548 &managed_paths(&["agents/coder.md"]),
549 false,
550 &mut diag,
551 );
552
553 assert!(target.join("agents/coder.md").exists());
554 assert_eq!(results[0].items_synced, 0);
556 }
557
558 #[test]
559 fn sync_force_refreshes_skipped_target_content() {
560 let dir = TempDir::new().unwrap();
561 let mars_dir = dir.path().join(".mars");
562 let target = dir.path().join(".agents");
563
564 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
565 std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
566
567 std::fs::create_dir_all(target.join("agents")).unwrap();
568 std::fs::write(target.join("agents/coder.md"), "# Tampered").unwrap();
569
570 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Skipped)];
571 let mut diag = DiagnosticCollector::new();
572 let results = sync_managed_targets(
573 dir.path(),
574 &mars_dir,
575 &[".agents".to_string()],
576 &outcomes,
577 &managed_paths(&["agents/coder.md"]),
578 true,
579 &mut diag,
580 );
581
582 assert_eq!(results[0].items_synced, 1);
583 assert_eq!(
584 std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
585 "# Canonical"
586 );
587 }
588
589 #[test]
590 fn sync_skipped_recopies_missing_target() {
591 let dir = TempDir::new().unwrap();
592 let mars_dir = dir.path().join(".mars");
593 let target = dir.path().join(".agents");
594
595 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
596 std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
597
598 let checksum = hash::hash_bytes(b"# Canonical");
599 let outcomes = vec![make_skipped_with_checksum("agents/coder.md", &checksum)];
600 let mut diag = DiagnosticCollector::new();
601 let results = sync_managed_targets(
602 dir.path(),
603 &mars_dir,
604 &[".agents".to_string()],
605 &outcomes,
606 &managed_paths(&["agents/coder.md"]),
607 false,
608 &mut diag,
609 );
610
611 assert_eq!(results[0].items_synced, 1);
612 assert!(target.join("agents/coder.md").exists());
613 }
614
615 #[test]
616 fn sync_skipped_warns_on_divergent_target_and_preserves_local_content() {
617 let dir = TempDir::new().unwrap();
618 let mars_dir = dir.path().join(".mars");
619 let target = dir.path().join(".agents");
620
621 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
622 std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
623
624 std::fs::create_dir_all(target.join("agents")).unwrap();
625 std::fs::write(target.join("agents/coder.md"), "# Locally edited").unwrap();
626
627 let checksum = hash::hash_bytes(b"# Canonical");
628 let outcomes = vec![make_skipped_with_checksum("agents/coder.md", &checksum)];
629 let mut diag = DiagnosticCollector::new();
630 let results = sync_managed_targets(
631 dir.path(),
632 &mars_dir,
633 &[".agents".to_string()],
634 &outcomes,
635 &managed_paths(&["agents/coder.md"]),
636 false,
637 &mut diag,
638 );
639
640 assert_eq!(results[0].items_synced, 0);
641 assert_eq!(
642 std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
643 "# Locally edited"
644 );
645
646 let diagnostics = diag.drain();
647 assert!(
648 diagnostics
649 .iter()
650 .any(|d| d.code == "target-divergent" && d.message.contains("agents/coder.md"))
651 );
652 }
653}