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