Skip to main content

mars_agents/target_sync/
mod.rs

1//! Target sync — copy content from .mars/ canonical store to managed targets.
2//!
3//! After `apply_plan()` writes resolved content to `.mars/agents/` and `.mars/skills/`,
4//! this module copies that content to all configured target directories (`.agents/`, `.claude/`, etc.).
5//!
6//! All targets are managed outputs — they get copies (not symlinks) of .mars/ content.
7
8use 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/// A directory that mars manages — materialized from .mars/.
18#[derive(Debug, Clone)]
19pub struct ManagedTarget {
20    /// Target directory path relative to project root (e.g. ".claude", ".agents").
21    pub path: String,
22}
23
24/// Result of syncing content to a single target directory.
25#[derive(Debug, Clone)]
26pub struct TargetSyncOutcome {
27    /// Target directory name (e.g. ".claude").
28    pub target: String,
29    /// Number of items successfully synced.
30    pub items_synced: usize,
31    /// Number of items removed (orphan cleanup).
32    pub items_removed: usize,
33    /// Non-fatal errors encountered during sync.
34    pub errors: Vec<String>,
35}
36
37/// Sync all managed targets from .mars/ canonical store.
38///
39/// For each configured target, copies content from `.mars/agents/` and `.mars/skills/`
40/// into the target directory.
41/// Cleans up orphaned items that are no longer in the apply outcomes.
42///
43/// Target sync is non-fatal by default (D9) — errors per-target are recorded but don't
44/// stop other targets from being synced.
45pub 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
96/// Sync a single target directory from .mars/ canonical store.
97fn 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    // Ensure target directory exists
111    std::fs::create_dir_all(target_root)?;
112
113    // Track expected paths for orphan cleanup
114    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                // Remove from target too
122                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                // Item is unchanged in .mars/ — still expected in target
133                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                // Installed, Updated, Merged, Conflicted, Kept
165                // All of these mean content exists in .mars/ and should be copied to target
166                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    // Orphan cleanup: scan target for items not in expected set
180    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
196/// Copy an item (file or directory) from .mars/ to a target directory.
197///
198/// Follows symlinks on the source side (D26 — targets get file copies, not symlinks).
199/// Uses atomic operations via the reconcile layer.
200fn copy_item_to_target(source: &Path, dest: &Path) -> Result<(), MarsError> {
201    // Ensure parent directories exist
202    if let Some(parent) = dest.parent() {
203        std::fs::create_dir_all(parent)?;
204    }
205
206    // Follow symlinks to determine if source is a file or directory
207    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
218/// Clean up orphaned items in a target directory.
219///
220/// Scans `agents/` and `skills/` subdirectories in the target. Removes
221/// entries only if they were previously managed by mars (present in old
222/// lock ownership) and are no longer expected in the current sync.
223/// Returns the number of items removed.
224fn 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        // Skip if it's a symlink (legacy link setup — don't touch)
239        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            // Skip hidden files (like .mars-tmp-*)
258            if name_str.starts_with('.') {
259                continue;
260            }
261
262            // Build forward-slash relative path for consistent cross-platform comparison
263            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        // Set up .mars/ content
321        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        // Set up .mars/ with one agent
381        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
382        std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
383
384        // Set up target with an extra agent (orphan)
385        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        // First run
531        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        // Second run with Skipped action — should converge (file already exists)
542        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        // items_synced should be 0 since file already exists
555        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}