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