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 native target directories (`.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").
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    let native_skill_variant_key = crate::target::TargetRegistry::new()
116        .get(target_name)
117        .and_then(|adapter| adapter.skill_variant_key())
118        .map(str::to_owned);
119
120    for outcome in outcomes {
121        if outcome.item_id.kind == crate::lock::ItemKind::BootstrapDoc {
122            // Package-level bootstrap docs are Meridian-only canonical content.
123            // Skill-level bootstrap docs still reach native targets as ordinary
124            // files inside skill directories.
125            continue;
126        }
127        let dest_rel = outcome.dest_path.as_str();
128        match &outcome.action {
129            ActionTaken::Removed => {
130                // Remove from target too
131                let target_path = target_root.join(dest_rel);
132                if target_path.exists() || target_path.symlink_metadata().is_ok() {
133                    if let Err(e) = fs_ops::safe_remove(&target_path) {
134                        errors.push(format!("failed to remove {dest_rel}: {e}"));
135                    } else {
136                        items_removed += 1;
137                    }
138                }
139            }
140            ActionTaken::Skipped => {
141                // Item is unchanged in .mars/ — still expected in target
142                expected_paths.insert(dest_rel.to_string());
143                let source = mars_dir.join(dest_rel);
144                let dest = target_root.join(dest_rel);
145                if source.exists() || source.symlink_metadata().is_ok() {
146                    let should_refresh_native_skill = outcome.item_id.kind
147                        == crate::lock::ItemKind::Skill
148                        && native_skill_variant_key.is_some();
149                    if force || !dest.exists() || should_refresh_native_skill {
150                        let previous_target_hash = if should_refresh_native_skill && dest.exists() {
151                            crate::hash::compute_hash(&dest, outcome.item_id.kind).ok()
152                        } else {
153                            None
154                        };
155                        match copy_item_to_target(
156                            &source,
157                            &dest,
158                            outcome.item_id.kind,
159                            outcome.item_id.name.as_str(),
160                            native_skill_variant_key.as_deref(),
161                            diag,
162                        ) {
163                            Ok(()) => {
164                                items_synced += 1;
165                                if let Some(previous_target_hash) = previous_target_hash
166                                    && let Ok(current_target_hash) =
167                                        crate::hash::compute_hash(&dest, outcome.item_id.kind)
168                                    && previous_target_hash != current_target_hash
169                                {
170                                    diag.warn(
171                                        "target-native-projection-repaired",
172                                        format!(
173                                            "repaired diverged native projection: {target_name}/{dest_rel}/SKILL.md"
174                                        ),
175                                    );
176                                }
177                            }
178                            Err(e) => errors.push(format!("failed to copy {dest_rel}: {e}")),
179                        }
180                    } else if native_skill_variant_key.is_none()
181                        && let Some(expected_checksum) = &outcome.installed_checksum
182                    {
183                        match crate::hash::compute_hash(&dest, outcome.item_id.kind) {
184                            Ok(actual) => {
185                                let actual = ContentHash::from(actual);
186                                if &actual != expected_checksum {
187                                    diag.warn(
188                                        "target-divergent",
189                                        format!(
190                                            "target `{target_name}` item `{}` diverged from `.mars` (preserved local content; run `mars sync --force` or `mars repair` to reset)",
191                                            dest_rel
192                                        ),
193                                    );
194                                }
195                            }
196                            Err(e) => {
197                                errors.push(format!("failed to verify {dest_rel} checksum: {e}"))
198                            }
199                        }
200                    }
201                }
202            }
203            _ => {
204                // Installed, Updated, Merged, Conflicted, Kept
205                // All of these mean content exists in .mars/ and should be copied to target
206                expected_paths.insert(dest_rel.to_string());
207                let source = mars_dir.join(dest_rel);
208                let dest = target_root.join(dest_rel);
209                if source.exists() || source.symlink_metadata().is_ok() {
210                    match copy_item_to_target(
211                        &source,
212                        &dest,
213                        outcome.item_id.kind,
214                        outcome.item_id.name.as_str(),
215                        native_skill_variant_key.as_deref(),
216                        diag,
217                    ) {
218                        Ok(()) => items_synced += 1,
219                        Err(e) => errors.push(format!("failed to copy {dest_rel}: {e}")),
220                    }
221                }
222            }
223        }
224    }
225
226    // Orphan cleanup: scan target for items not in expected set
227    let orphan_removed = cleanup_orphans(
228        target_root,
229        &expected_paths,
230        previous_managed_paths,
231        &mut errors,
232    );
233    items_removed += orphan_removed;
234
235    Ok(TargetSyncOutcome {
236        target: target_name.to_string(),
237        items_synced,
238        items_removed,
239        errors,
240    })
241}
242
243/// Copy an item (file or directory) from .mars/ to a target directory.
244///
245/// Follows symlinks on the source side (D26 — targets get file copies, not symlinks).
246/// Uses atomic operations via the reconcile layer.
247fn copy_item_to_target(
248    source: &Path,
249    dest: &Path,
250    kind: crate::lock::ItemKind,
251    item_name: &str,
252    native_skill_variant_key: Option<&str>,
253    diag: &mut DiagnosticCollector,
254) -> Result<(), MarsError> {
255    if kind == crate::lock::ItemKind::Skill && native_skill_variant_key.is_some() {
256        crate::compiler::variants::validate_skill_variants(source, item_name, diag);
257        return crate::compiler::variants::project_skill_for_target(
258            source,
259            dest,
260            native_skill_variant_key,
261            diag,
262            item_name,
263        );
264    }
265
266    // Ensure parent directories exist
267    if let Some(parent) = dest.parent() {
268        std::fs::create_dir_all(parent)?;
269    }
270
271    // Follow symlinks to determine if source is a file or directory
272    let metadata = std::fs::metadata(source)?;
273
274    if metadata.is_dir() {
275        fs_ops::atomic_copy_dir(source, dest)?;
276    } else if metadata.is_file() {
277        fs_ops::atomic_copy_file(source, dest)?;
278    }
279
280    Ok(())
281}
282
283/// Clean up orphaned items in a target directory.
284///
285/// Uses lock v2 output records (via `previous_managed_paths`) to determine
286/// what was managed in the prior sync, rather than scanning hardcoded
287/// subdirectories. Removes entries that were previously managed but are no
288/// longer expected in the current sync.
289///
290/// Returns the number of items removed.
291fn cleanup_orphans(
292    target_root: &Path,
293    expected: &HashSet<String>,
294    previous_managed_paths: &HashSet<String>,
295    errors: &mut Vec<String>,
296) -> usize {
297    let mut removed = 0;
298
299    // Lock-driven: iterate paths from the old lock, not hardcoded subdirectories.
300    // Only remove entries that were previously managed and are no longer expected.
301    for managed_path in previous_managed_paths {
302        if expected.contains(managed_path) {
303            continue;
304        }
305
306        let full_path = target_root.join(managed_path);
307
308        // Skip if the path doesn't exist (already removed or never synced to this target).
309        if !full_path.exists() && full_path.symlink_metadata().is_err() {
310            continue;
311        }
312
313        // Skip symlinked paths (legacy link setup — don't touch).
314        if full_path
315            .symlink_metadata()
316            .map(|m| m.file_type().is_symlink())
317            .unwrap_or(false)
318        {
319            continue;
320        }
321
322        if let Err(e) = fs_ops::safe_remove(&full_path) {
323            errors.push(format!("failed to remove orphan {managed_path}: {e}"));
324        } else {
325            removed += 1;
326        }
327    }
328
329    removed
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use crate::diagnostic::DiagnosticCollector;
336    use crate::hash;
337    use crate::sync::apply::{ActionOutcome, ActionTaken};
338    use crate::types::{DestPath, ItemName};
339    use tempfile::TempDir;
340
341    fn make_outcome(dest: &str, action: ActionTaken) -> ActionOutcome {
342        ActionOutcome {
343            item_id: crate::lock::ItemId {
344                kind: crate::lock::ItemKind::Agent,
345                name: ItemName::from("test"),
346            },
347            action,
348            dest_path: DestPath::from(dest),
349            source_name: "test-source".into(),
350            source_checksum: None,
351            installed_checksum: None,
352        }
353    }
354
355    fn managed_paths(paths: &[&str]) -> HashSet<String> {
356        paths
357            .iter()
358            .map(|p| (*p).to_string())
359            .collect::<HashSet<String>>()
360    }
361
362    fn make_skipped_with_checksum(dest: &str, checksum: &str) -> ActionOutcome {
363        let mut outcome = make_outcome(dest, ActionTaken::Skipped);
364        outcome.installed_checksum = Some(checksum.into());
365        outcome
366    }
367
368    #[test]
369    fn sync_copies_installed_items_to_target() {
370        let dir = TempDir::new().unwrap();
371        let mars_dir = dir.path().join(".mars");
372        let target = dir.path().join(".agents");
373
374        // Set up .mars/ content
375        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
376        std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
377
378        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
379        let mut diag = DiagnosticCollector::new();
380
381        let results = sync_managed_targets(
382            dir.path(),
383            &mars_dir,
384            &[".agents".to_string()],
385            &outcomes,
386            &managed_paths(&[]),
387            false,
388            &mut diag,
389        );
390
391        assert_eq!(results.len(), 1);
392        assert_eq!(results[0].items_synced, 1);
393        assert!(results[0].errors.is_empty());
394        assert!(target.join("agents/coder.md").exists());
395        assert_eq!(
396            std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
397            "# Coder"
398        );
399    }
400
401    #[test]
402    fn sync_removes_items_from_target() {
403        let dir = TempDir::new().unwrap();
404        let mars_dir = dir.path().join(".mars");
405        let target = dir.path().join(".agents");
406
407        std::fs::create_dir_all(&mars_dir).unwrap();
408        std::fs::create_dir_all(target.join("agents")).unwrap();
409        std::fs::write(target.join("agents/old.md"), "# Old").unwrap();
410
411        let outcomes = vec![make_outcome("agents/old.md", ActionTaken::Removed)];
412        let mut diag = DiagnosticCollector::new();
413
414        let results = sync_managed_targets(
415            dir.path(),
416            &mars_dir,
417            &[".agents".to_string()],
418            &outcomes,
419            &managed_paths(&["agents/old.md"]),
420            false,
421            &mut diag,
422        );
423
424        assert_eq!(results[0].items_removed, 1);
425        assert!(!target.join("agents/old.md").exists());
426    }
427
428    #[test]
429    fn sync_cleans_up_previous_managed_orphans() {
430        let dir = TempDir::new().unwrap();
431        let mars_dir = dir.path().join(".mars");
432        let target = dir.path().join(".agents");
433
434        // Set up .mars/ with one agent
435        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
436        std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
437
438        // Set up target with an extra agent (orphan)
439        std::fs::create_dir_all(target.join("agents")).unwrap();
440        std::fs::write(target.join("agents/orphan.md"), "# Orphan").unwrap();
441
442        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
443        let mut diag = DiagnosticCollector::new();
444
445        let results = sync_managed_targets(
446            dir.path(),
447            &mars_dir,
448            &[".agents".to_string()],
449            &outcomes,
450            &managed_paths(&["agents/orphan.md"]),
451            false,
452            &mut diag,
453        );
454
455        assert!(target.join("agents/coder.md").exists());
456        assert!(!target.join("agents/orphan.md").exists());
457        assert_eq!(results[0].items_removed, 1);
458    }
459
460    #[test]
461    fn sync_preserves_unmanaged_files_in_target() {
462        let dir = TempDir::new().unwrap();
463        let mars_dir = dir.path().join(".mars");
464        let target = dir.path().join(".agents");
465
466        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
467        std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
468
469        std::fs::create_dir_all(target.join("agents")).unwrap();
470        std::fs::write(target.join("agents/custom.md"), "# User custom").unwrap();
471
472        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
473        let mut diag = DiagnosticCollector::new();
474
475        let results = sync_managed_targets(
476            dir.path(),
477            &mars_dir,
478            &[".agents".to_string()],
479            &outcomes,
480            &managed_paths(&[]),
481            false,
482            &mut diag,
483        );
484
485        assert!(target.join("agents/coder.md").exists());
486        assert!(target.join("agents/custom.md").exists());
487        assert_eq!(results[0].items_removed, 0);
488    }
489
490    #[test]
491    fn sync_removed_agent_outcome_removes_existing_target_agent_without_copying() {
492        let dir = TempDir::new().unwrap();
493        let mars_dir = dir.path().join(".mars");
494        let target = dir.path().join(".agents");
495
496        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
497        std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
498        std::fs::create_dir_all(target.join("agents")).unwrap();
499        std::fs::write(target.join("agents/coder.md"), "# Existing target copy").unwrap();
500
501        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Removed)];
502        let mut diag = DiagnosticCollector::new();
503
504        let results = sync_managed_targets(
505            dir.path(),
506            &mars_dir,
507            &[".agents".to_string()],
508            &outcomes,
509            &managed_paths(&["agents/coder.md"]),
510            false,
511            &mut diag,
512        );
513
514        assert_eq!(results[0].items_synced, 0);
515        assert_eq!(results[0].items_removed, 1);
516        assert!(!target.join("agents/coder.md").exists());
517        assert!(results[0].errors.is_empty());
518    }
519
520    #[test]
521    fn sync_multiple_targets() {
522        let dir = TempDir::new().unwrap();
523        let mars_dir = dir.path().join(".mars");
524
525        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
526        std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
527
528        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
529        let mut diag = DiagnosticCollector::new();
530
531        let results = sync_managed_targets(
532            dir.path(),
533            &mars_dir,
534            &[".agents".to_string(), ".claude".to_string()],
535            &outcomes,
536            &managed_paths(&[]),
537            false,
538            &mut diag,
539        );
540
541        assert_eq!(results.len(), 2);
542        assert!(dir.path().join(".agents/agents/coder.md").exists());
543        assert!(dir.path().join(".claude/agents/coder.md").exists());
544    }
545
546    #[test]
547    fn sync_skill_directory() {
548        let dir = TempDir::new().unwrap();
549        let mars_dir = dir.path().join(".mars");
550        let target = dir.path().join(".agents");
551
552        std::fs::create_dir_all(mars_dir.join("skills/planning")).unwrap();
553        std::fs::write(mars_dir.join("skills/planning/SKILL.md"), "# Planning").unwrap();
554
555        let mut outcome = make_outcome("skills/planning", ActionTaken::Installed);
556        outcome.item_id.kind = crate::lock::ItemKind::Skill;
557        let outcomes = vec![outcome];
558        let mut diag = DiagnosticCollector::new();
559
560        let results = sync_managed_targets(
561            dir.path(),
562            &mars_dir,
563            &[".agents".to_string()],
564            &outcomes,
565            &managed_paths(&[]),
566            false,
567            &mut diag,
568        );
569
570        assert_eq!(results[0].items_synced, 1);
571        assert!(target.join("skills/planning/SKILL.md").exists());
572    }
573
574    #[test]
575    fn sync_projects_skills_for_native_harness_targets() {
576        let dir = TempDir::new().unwrap();
577        let mars_dir = dir.path().join(".mars");
578        let target = dir.path().join(".claude");
579
580        std::fs::create_dir_all(mars_dir.join("skills/planning/resources")).unwrap();
581        std::fs::create_dir_all(mars_dir.join("skills/planning/variants/claude")).unwrap();
582        std::fs::create_dir_all(target.join("skills")).unwrap();
583        std::fs::write(target.join("skills/orphan"), "# Orphan").unwrap();
584        std::fs::write(mars_dir.join("skills/planning/SKILL.md"), "# Base").unwrap();
585        std::fs::write(
586            mars_dir.join("skills/planning/resources/BOOTSTRAP.md"),
587            "# Bootstrap",
588        )
589        .unwrap();
590        std::fs::write(
591            mars_dir.join("skills/planning/variants/claude/SKILL.md"),
592            "# Claude",
593        )
594        .unwrap();
595
596        let mut outcome = make_outcome("skills/planning", ActionTaken::Installed);
597        outcome.item_id.kind = crate::lock::ItemKind::Skill;
598        let outcomes = vec![outcome];
599        let mut diag = DiagnosticCollector::new();
600
601        let results = sync_managed_targets(
602            dir.path(),
603            &mars_dir,
604            &[".claude".to_string()],
605            &outcomes,
606            &managed_paths(&["skills/planning", "skills/orphan"]),
607            false,
608            &mut diag,
609        );
610
611        assert_eq!(results[0].items_synced, 1);
612        assert_eq!(
613            std::fs::read_to_string(target.join("skills/planning/SKILL.md")).unwrap(),
614            "# Claude"
615        );
616        assert_eq!(
617            std::fs::read_to_string(target.join("skills/planning/resources/BOOTSTRAP.md")).unwrap(),
618            "# Bootstrap"
619        );
620        assert!(!target.join("skills/planning/variants").exists());
621        assert!(!target.join("skills/orphan").exists());
622    }
623
624    #[test]
625    fn cleanup_orphans_uses_forward_slash_keys_for_expected_paths() {
626        let dir = TempDir::new().unwrap();
627        let target_root = dir.path().join(".agents");
628        std::fs::create_dir_all(target_root.join("agents")).unwrap();
629        std::fs::write(target_root.join("agents/coder.md"), "# Managed").unwrap();
630        std::fs::write(target_root.join("agents/orphan.md"), "# Orphan").unwrap();
631
632        let mut expected = HashSet::new();
633        expected.insert(
634            DestPath::new(r"agents\coder.md")
635                .unwrap()
636                .as_str()
637                .to_string(),
638        );
639
640        let removed = cleanup_orphans(
641            &target_root,
642            &expected,
643            &managed_paths(&["agents/coder.md", "agents/orphan.md"]),
644            &mut Vec::new(),
645        );
646
647        assert_eq!(removed, 1);
648        assert!(target_root.join("agents/coder.md").exists());
649        assert!(!target_root.join("agents/orphan.md").exists());
650    }
651
652    #[test]
653    fn sync_convergence_on_rerun() {
654        let dir = TempDir::new().unwrap();
655        let mars_dir = dir.path().join(".mars");
656        let target = dir.path().join(".agents");
657
658        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
659        std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
660
661        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
662        let mut diag = DiagnosticCollector::new();
663
664        // First run
665        sync_managed_targets(
666            dir.path(),
667            &mars_dir,
668            &[".agents".to_string()],
669            &outcomes,
670            &managed_paths(&[]),
671            false,
672            &mut diag,
673        );
674
675        // Second run with Skipped action — should converge (file already exists)
676        let outcomes2 = vec![make_outcome("agents/coder.md", ActionTaken::Skipped)];
677        let results = sync_managed_targets(
678            dir.path(),
679            &mars_dir,
680            &[".agents".to_string()],
681            &outcomes2,
682            &managed_paths(&["agents/coder.md"]),
683            false,
684            &mut diag,
685        );
686
687        assert!(target.join("agents/coder.md").exists());
688        // items_synced should be 0 since file already exists
689        assert_eq!(results[0].items_synced, 0);
690    }
691
692    #[test]
693    fn sync_force_refreshes_skipped_target_content() {
694        let dir = TempDir::new().unwrap();
695        let mars_dir = dir.path().join(".mars");
696        let target = dir.path().join(".agents");
697
698        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
699        std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
700
701        std::fs::create_dir_all(target.join("agents")).unwrap();
702        std::fs::write(target.join("agents/coder.md"), "# Tampered").unwrap();
703
704        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Skipped)];
705        let mut diag = DiagnosticCollector::new();
706        let results = sync_managed_targets(
707            dir.path(),
708            &mars_dir,
709            &[".agents".to_string()],
710            &outcomes,
711            &managed_paths(&["agents/coder.md"]),
712            true,
713            &mut diag,
714        );
715
716        assert_eq!(results[0].items_synced, 1);
717        assert_eq!(
718            std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
719            "# Canonical"
720        );
721    }
722
723    #[test]
724    fn sync_skipped_recopies_missing_target() {
725        let dir = TempDir::new().unwrap();
726        let mars_dir = dir.path().join(".mars");
727        let target = dir.path().join(".agents");
728
729        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
730        std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
731
732        let checksum = hash::hash_bytes(b"# Canonical");
733        let outcomes = vec![make_skipped_with_checksum("agents/coder.md", &checksum)];
734        let mut diag = DiagnosticCollector::new();
735        let results = sync_managed_targets(
736            dir.path(),
737            &mars_dir,
738            &[".agents".to_string()],
739            &outcomes,
740            &managed_paths(&["agents/coder.md"]),
741            false,
742            &mut diag,
743        );
744
745        assert_eq!(results[0].items_synced, 1);
746        assert!(target.join("agents/coder.md").exists());
747    }
748
749    #[test]
750    fn sync_skipped_warns_on_divergent_target_and_preserves_local_content() {
751        let dir = TempDir::new().unwrap();
752        let mars_dir = dir.path().join(".mars");
753        let target = dir.path().join(".agents");
754
755        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
756        std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
757
758        std::fs::create_dir_all(target.join("agents")).unwrap();
759        std::fs::write(target.join("agents/coder.md"), "# Locally edited").unwrap();
760
761        let checksum = hash::hash_bytes(b"# Canonical");
762        let outcomes = vec![make_skipped_with_checksum("agents/coder.md", &checksum)];
763        let mut diag = DiagnosticCollector::new();
764        let results = sync_managed_targets(
765            dir.path(),
766            &mars_dir,
767            &[".agents".to_string()],
768            &outcomes,
769            &managed_paths(&["agents/coder.md"]),
770            false,
771            &mut diag,
772        );
773
774        assert_eq!(results[0].items_synced, 0);
775        assert_eq!(
776            std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
777            "# Locally edited"
778        );
779
780        let diagnostics = diag.drain();
781        assert!(
782            diagnostics
783                .iter()
784                .any(|d| d.code == "target-divergent" && d.message.contains("agents/coder.md"))
785        );
786    }
787}