1use std::collections::HashSet;
10use std::path::{Path, PathBuf};
11
12use crate::diagnostic::DiagnosticCollector;
13use crate::error::MarsError;
14use crate::reconcile::fs_ops;
15use crate::sync::apply::{ActionOutcome, ActionTaken};
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 ) {
66 Ok(outcome) => {
67 if !outcome.errors.is_empty() {
68 for err in &outcome.errors {
69 diag.warn(
70 "target-sync-error",
71 format!("target `{target_name}`: {err}"),
72 );
73 }
74 }
75 results.push(outcome);
76 }
77 Err(e) => {
78 diag.warn(
79 "target-sync-failed",
80 format!("target `{target_name}` sync failed: {e}"),
81 );
82 results.push(TargetSyncOutcome {
83 target: target_name.clone(),
84 items_synced: 0,
85 items_removed: 0,
86 errors: vec![e.to_string()],
87 });
88 }
89 }
90 }
91
92 results
93}
94
95fn sync_one_target(
97 mars_dir: &Path,
98 target_root: &Path,
99 target_name: &str,
100 outcomes: &[ActionOutcome],
101 previous_managed_paths: &HashSet<PathBuf>,
102 force: bool,
103) -> Result<TargetSyncOutcome, MarsError> {
104 let mut items_synced = 0;
105 let mut items_removed = 0;
106 let mut errors = Vec::new();
107
108 std::fs::create_dir_all(target_root)?;
110
111 let mut expected_paths: HashSet<PathBuf> = HashSet::new();
113
114 for outcome in outcomes {
115 let dest_rel = outcome.dest_path.as_path();
116
117 match &outcome.action {
118 ActionTaken::Removed => {
119 let target_path = target_root.join(dest_rel);
121 if target_path.exists() || target_path.symlink_metadata().is_ok() {
122 if let Err(e) = fs_ops::safe_remove(&target_path) {
123 errors.push(format!("failed to remove {}: {e}", dest_rel.display()));
124 } else {
125 items_removed += 1;
126 }
127 }
128 }
129 ActionTaken::Skipped => {
130 expected_paths.insert(dest_rel.to_path_buf());
132 let source = mars_dir.join(dest_rel);
135 let dest = target_root.join(dest_rel);
136 if source.exists() && (force || !dest.exists()) {
137 match copy_item_to_target(&source, &dest) {
138 Ok(()) => items_synced += 1,
139 Err(e) => {
140 errors.push(format!("failed to copy {}: {e}", dest_rel.display()))
141 }
142 }
143 }
144 }
145 _ => {
146 expected_paths.insert(dest_rel.to_path_buf());
149 let source = mars_dir.join(dest_rel);
150 let dest = target_root.join(dest_rel);
151 if source.exists() || source.symlink_metadata().is_ok() {
152 match copy_item_to_target(&source, &dest) {
153 Ok(()) => items_synced += 1,
154 Err(e) => {
155 errors.push(format!("failed to copy {}: {e}", dest_rel.display()))
156 }
157 }
158 }
159 }
160 }
161 }
162
163 let orphan_removed = cleanup_orphans(
165 target_root,
166 &expected_paths,
167 previous_managed_paths,
168 &mut errors,
169 );
170 items_removed += orphan_removed;
171
172 Ok(TargetSyncOutcome {
173 target: target_name.to_string(),
174 items_synced,
175 items_removed,
176 errors,
177 })
178}
179
180fn copy_item_to_target(source: &Path, dest: &Path) -> Result<(), MarsError> {
185 if let Some(parent) = dest.parent() {
187 std::fs::create_dir_all(parent)?;
188 }
189
190 let metadata = std::fs::metadata(source)?;
192
193 if metadata.is_dir() {
194 fs_ops::atomic_copy_dir(source, dest)?;
195 } else if metadata.is_file() {
196 fs_ops::atomic_copy_file(source, dest)?;
197 }
198
199 Ok(())
200}
201
202fn cleanup_orphans(
209 target_root: &Path,
210 expected: &HashSet<PathBuf>,
211 previous_managed_paths: &HashSet<PathBuf>,
212 errors: &mut Vec<String>,
213) -> usize {
214 let mut removed = 0;
215
216 for subdir in ["agents", "skills"] {
217 let scan_dir = target_root.join(subdir);
218 if !scan_dir.exists() {
219 continue;
220 }
221
222 if scan_dir.symlink_metadata().is_ok()
224 && scan_dir
225 .symlink_metadata()
226 .map(|m| m.file_type().is_symlink())
227 .unwrap_or(false)
228 {
229 continue;
230 }
231
232 let entries = match std::fs::read_dir(&scan_dir) {
233 Ok(e) => e,
234 Err(_) => continue,
235 };
236
237 for entry in entries.flatten() {
238 let file_name = entry.file_name();
239 let name_str = file_name.to_string_lossy();
240
241 if name_str.starts_with('.') {
243 continue;
244 }
245
246 let rel_path = PathBuf::from(subdir).join(&file_name);
247 if previous_managed_paths.contains(&rel_path) && !expected.contains(&rel_path) {
248 let full_path = entry.path();
249 if let Err(e) = fs_ops::safe_remove(&full_path) {
250 errors.push(format!(
251 "failed to remove orphan {}: {e}",
252 rel_path.display()
253 ));
254 } else {
255 removed += 1;
256 }
257 }
258 }
259 }
260
261 removed
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267 use crate::diagnostic::DiagnosticCollector;
268 use crate::sync::apply::{ActionOutcome, ActionTaken};
269 use crate::types::{DestPath, ItemName};
270 use tempfile::TempDir;
271
272 fn make_outcome(dest: &str, action: ActionTaken) -> ActionOutcome {
273 ActionOutcome {
274 item_id: crate::lock::ItemId {
275 kind: crate::lock::ItemKind::Agent,
276 name: ItemName::from("test"),
277 },
278 action,
279 dest_path: DestPath::from(dest),
280 source_name: "test-source".into(),
281 source_checksum: None,
282 installed_checksum: None,
283 }
284 }
285
286 fn managed_paths(paths: &[&str]) -> HashSet<PathBuf> {
287 paths
288 .iter()
289 .map(|p| PathBuf::from(*p))
290 .collect::<HashSet<PathBuf>>()
291 }
292
293 #[test]
294 fn sync_copies_installed_items_to_target() {
295 let dir = TempDir::new().unwrap();
296 let mars_dir = dir.path().join(".mars");
297 let target = dir.path().join(".agents");
298
299 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
301 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
302
303 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
304 let mut diag = DiagnosticCollector::new();
305
306 let results = sync_managed_targets(
307 dir.path(),
308 &mars_dir,
309 &[".agents".to_string()],
310 &outcomes,
311 &managed_paths(&[]),
312 false,
313 &mut diag,
314 );
315
316 assert_eq!(results.len(), 1);
317 assert_eq!(results[0].items_synced, 1);
318 assert!(results[0].errors.is_empty());
319 assert!(target.join("agents/coder.md").exists());
320 assert_eq!(
321 std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
322 "# Coder"
323 );
324 }
325
326 #[test]
327 fn sync_removes_items_from_target() {
328 let dir = TempDir::new().unwrap();
329 let mars_dir = dir.path().join(".mars");
330 let target = dir.path().join(".agents");
331
332 std::fs::create_dir_all(&mars_dir).unwrap();
333 std::fs::create_dir_all(target.join("agents")).unwrap();
334 std::fs::write(target.join("agents/old.md"), "# Old").unwrap();
335
336 let outcomes = vec![make_outcome("agents/old.md", ActionTaken::Removed)];
337 let mut diag = DiagnosticCollector::new();
338
339 let results = sync_managed_targets(
340 dir.path(),
341 &mars_dir,
342 &[".agents".to_string()],
343 &outcomes,
344 &managed_paths(&["agents/old.md"]),
345 false,
346 &mut diag,
347 );
348
349 assert_eq!(results[0].items_removed, 1);
350 assert!(!target.join("agents/old.md").exists());
351 }
352
353 #[test]
354 fn sync_cleans_up_previous_managed_orphans() {
355 let dir = TempDir::new().unwrap();
356 let mars_dir = dir.path().join(".mars");
357 let target = dir.path().join(".agents");
358
359 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
361 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
362
363 std::fs::create_dir_all(target.join("agents")).unwrap();
365 std::fs::write(target.join("agents/orphan.md"), "# Orphan").unwrap();
366
367 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
368 let mut diag = DiagnosticCollector::new();
369
370 let results = sync_managed_targets(
371 dir.path(),
372 &mars_dir,
373 &[".agents".to_string()],
374 &outcomes,
375 &managed_paths(&["agents/orphan.md"]),
376 false,
377 &mut diag,
378 );
379
380 assert!(target.join("agents/coder.md").exists());
381 assert!(!target.join("agents/orphan.md").exists());
382 assert_eq!(results[0].items_removed, 1);
383 }
384
385 #[test]
386 fn sync_preserves_unmanaged_files_in_target() {
387 let dir = TempDir::new().unwrap();
388 let mars_dir = dir.path().join(".mars");
389 let target = dir.path().join(".agents");
390
391 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
392 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
393
394 std::fs::create_dir_all(target.join("agents")).unwrap();
395 std::fs::write(target.join("agents/custom.md"), "# User custom").unwrap();
396
397 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
398 let mut diag = DiagnosticCollector::new();
399
400 let results = sync_managed_targets(
401 dir.path(),
402 &mars_dir,
403 &[".agents".to_string()],
404 &outcomes,
405 &managed_paths(&[]),
406 false,
407 &mut diag,
408 );
409
410 assert!(target.join("agents/coder.md").exists());
411 assert!(target.join("agents/custom.md").exists());
412 assert_eq!(results[0].items_removed, 0);
413 }
414
415 #[test]
416 fn sync_multiple_targets() {
417 let dir = TempDir::new().unwrap();
418 let mars_dir = dir.path().join(".mars");
419
420 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
421 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
422
423 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
424 let mut diag = DiagnosticCollector::new();
425
426 let results = sync_managed_targets(
427 dir.path(),
428 &mars_dir,
429 &[".agents".to_string(), ".claude".to_string()],
430 &outcomes,
431 &managed_paths(&[]),
432 false,
433 &mut diag,
434 );
435
436 assert_eq!(results.len(), 2);
437 assert!(dir.path().join(".agents/agents/coder.md").exists());
438 assert!(dir.path().join(".claude/agents/coder.md").exists());
439 }
440
441 #[test]
442 fn sync_follows_symlinks_in_mars_dir() {
443 let dir = TempDir::new().unwrap();
444 let mars_dir = dir.path().join(".mars");
445 let target = dir.path().join(".agents");
446
447 let real_dir = dir.path().join("local-agents");
449 std::fs::create_dir_all(&real_dir).unwrap();
450 std::fs::write(real_dir.join("local.md"), "# Local").unwrap();
451
452 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
453 #[cfg(unix)]
454 std::os::unix::fs::symlink(real_dir.join("local.md"), mars_dir.join("agents/local.md"))
455 .unwrap();
456
457 let outcomes = vec![make_outcome("agents/local.md", ActionTaken::Symlinked)];
458 let mut diag = DiagnosticCollector::new();
459
460 let results = sync_managed_targets(
461 dir.path(),
462 &mars_dir,
463 &[".agents".to_string()],
464 &outcomes,
465 &managed_paths(&[]),
466 false,
467 &mut diag,
468 );
469
470 assert_eq!(results[0].items_synced, 1);
471 let dest = target.join("agents/local.md");
472 assert!(dest.exists());
473 assert!(
475 !dest.symlink_metadata().unwrap().file_type().is_symlink(),
476 "target should have a regular file copy, not a symlink"
477 );
478 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Local");
479 }
480
481 #[test]
482 fn sync_skill_directory() {
483 let dir = TempDir::new().unwrap();
484 let mars_dir = dir.path().join(".mars");
485 let target = dir.path().join(".agents");
486
487 std::fs::create_dir_all(mars_dir.join("skills/planning")).unwrap();
488 std::fs::write(mars_dir.join("skills/planning/SKILL.md"), "# Planning").unwrap();
489
490 let mut outcome = make_outcome("skills/planning", ActionTaken::Installed);
491 outcome.item_id.kind = crate::lock::ItemKind::Skill;
492 let outcomes = vec![outcome];
493 let mut diag = DiagnosticCollector::new();
494
495 let results = sync_managed_targets(
496 dir.path(),
497 &mars_dir,
498 &[".agents".to_string()],
499 &outcomes,
500 &managed_paths(&[]),
501 false,
502 &mut diag,
503 );
504
505 assert_eq!(results[0].items_synced, 1);
506 assert!(target.join("skills/planning/SKILL.md").exists());
507 }
508
509 #[test]
510 fn sync_convergence_on_rerun() {
511 let dir = TempDir::new().unwrap();
512 let mars_dir = dir.path().join(".mars");
513 let target = dir.path().join(".agents");
514
515 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
516 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
517
518 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
519 let mut diag = DiagnosticCollector::new();
520
521 sync_managed_targets(
523 dir.path(),
524 &mars_dir,
525 &[".agents".to_string()],
526 &outcomes,
527 &managed_paths(&[]),
528 false,
529 &mut diag,
530 );
531
532 let outcomes2 = vec![make_outcome("agents/coder.md", ActionTaken::Skipped)];
534 let results = sync_managed_targets(
535 dir.path(),
536 &mars_dir,
537 &[".agents".to_string()],
538 &outcomes2,
539 &managed_paths(&["agents/coder.md"]),
540 false,
541 &mut diag,
542 );
543
544 assert!(target.join("agents/coder.md").exists());
545 assert_eq!(results[0].items_synced, 0);
547 }
548
549 #[test]
550 fn sync_force_refreshes_skipped_target_content() {
551 let dir = TempDir::new().unwrap();
552 let mars_dir = dir.path().join(".mars");
553 let target = dir.path().join(".agents");
554
555 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
556 std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
557
558 std::fs::create_dir_all(target.join("agents")).unwrap();
559 std::fs::write(target.join("agents/coder.md"), "# Tampered").unwrap();
560
561 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Skipped)];
562 let mut diag = DiagnosticCollector::new();
563 let results = sync_managed_targets(
564 dir.path(),
565 &mars_dir,
566 &[".agents".to_string()],
567 &outcomes,
568 &managed_paths(&["agents/coder.md"]),
569 true,
570 &mut diag,
571 );
572
573 assert_eq!(results[0].items_synced, 1);
574 assert_eq!(
575 std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
576 "# Canonical"
577 );
578 }
579}