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, 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/// 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<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
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<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    // 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<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                // 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 {}: {e}", dest_rel.display()));
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_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                // Installed, Updated, Merged, Conflicted, Kept
168                // All of these mean content exists in .mars/ and should be copied to target
169                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    // Orphan cleanup: scan target for items not in expected set
185    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
201/// Copy an item (file or directory) from .mars/ to a target directory.
202///
203/// Follows symlinks on the source side (D26 — targets get file copies, not symlinks).
204/// Uses atomic operations via the reconcile layer.
205fn copy_item_to_target(source: &Path, dest: &Path) -> Result<(), MarsError> {
206    // Ensure parent directories exist
207    if let Some(parent) = dest.parent() {
208        std::fs::create_dir_all(parent)?;
209    }
210
211    // Follow symlinks to determine if source is a file or directory
212    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
223/// Clean up orphaned items in a target directory.
224///
225/// Scans `agents/` and `skills/` subdirectories in the target. Removes
226/// entries only if they were previously managed by mars (present in old
227/// lock ownership) and are no longer expected in the current sync.
228/// Returns the number of items removed.
229fn 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        // Skip if it's a symlink (legacy link setup — don't touch)
244        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            // Skip hidden files (like .mars-tmp-*)
263            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        // Set up .mars/ content
328        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        // Set up .mars/ with one agent
388        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
389        std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
390
391        // Set up target with an extra agent (orphan)
392        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        // First run
510        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        // Second run with Skipped action — should converge (file already exists)
521        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        // items_synced should be 0 since file already exists
534        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}