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