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::{HashMap, HashSet};
9use std::path::Path;
10
11use crate::diagnostic::DiagnosticCollector;
12use crate::error::MarsError;
13use crate::lock::LockFile;
14use crate::reconcile::fs_ops;
15use crate::surface_ownership::{self, CollisionAdoptHint, SurfaceCopyDecision};
16use crate::sync::apply::{ActionOutcome, ActionTaken};
17use crate::types::ContentHash;
18use crate::types::managed_cmd;
19
20/// A directory that mars manages — materialized from .mars/.
21#[derive(Debug, Clone)]
22pub struct ManagedTarget {
23    /// Target directory path relative to project root (e.g. ".claude").
24    pub path: String,
25}
26
27/// A linked-target output recorded during sync for lock persistence.
28#[derive(Debug, Clone)]
29pub struct TargetSyncedOutput {
30    pub dest_path: String,
31    pub installed_checksum: ContentHash,
32}
33
34/// Result of syncing content to a single target directory.
35#[derive(Debug, Clone)]
36pub struct TargetSyncOutcome {
37    /// Target directory name (e.g. ".claude").
38    pub target: String,
39    /// Number of items successfully synced.
40    pub items_synced: usize,
41    /// Number of items removed (orphan cleanup).
42    pub items_removed: usize,
43    /// Non-fatal errors encountered during sync.
44    pub errors: Vec<String>,
45    /// Outputs successfully copied to this target (for lock persistence).
46    pub synced_outputs: Vec<TargetSyncedOutput>,
47    /// Dest paths removed from this target (for lock persistence).
48    pub removed_dest_paths: Vec<String>,
49}
50
51/// Per-run target sync options shared across all linked targets.
52pub struct TargetSyncContext<'a> {
53    pub old_lock: &'a LockFile,
54    pub force: bool,
55    pub collision_hint: CollisionAdoptHint,
56    /// Managed native agent paths to exempt from orphan cleanup (selective mode).
57    pub orphan_preserve_paths: Option<&'a HashMap<String, HashSet<String>>>,
58}
59
60/// Sync all managed targets from .mars/ canonical store.
61///
62/// For each configured target, copies content from `.mars/agents/` and `.mars/skills/`
63/// into the target directory.
64/// Cleans up orphaned items that are no longer in the apply outcomes.
65///
66/// Target sync is non-fatal by default (D9) — errors per-target are recorded but don't
67/// stop other targets from being synced.
68pub fn sync_managed_targets(
69    project_root: &Path,
70    mars_dir: &Path,
71    targets: &[String],
72    outcomes: &[ActionOutcome],
73    ctx: &TargetSyncContext<'_>,
74    diag: &mut DiagnosticCollector,
75) -> Vec<TargetSyncOutcome> {
76    let mut results = Vec::new();
77
78    for target_name in targets {
79        let target_root = project_root.join(target_name);
80        match sync_one_target(mars_dir, &target_root, target_name, outcomes, ctx, diag) {
81            Ok(outcome) => {
82                if !outcome.errors.is_empty() {
83                    for err in &outcome.errors {
84                        diag.warn(
85                            "target-sync-error",
86                            format!("target `{target_name}`: {err}"),
87                        );
88                    }
89                }
90                results.push(outcome);
91            }
92            Err(e) => {
93                diag.warn(
94                    "target-sync-failed",
95                    format!("target `{target_name}` sync failed: {e}"),
96                );
97                results.push(TargetSyncOutcome {
98                    target: target_name.clone(),
99                    items_synced: 0,
100                    items_removed: 0,
101                    errors: vec![e.to_string()],
102                    synced_outputs: Vec::new(),
103                    removed_dest_paths: Vec::new(),
104                });
105            }
106        }
107    }
108
109    results
110}
111
112fn sync_one_target(
113    mars_dir: &Path,
114    target_root: &Path,
115    target_name: &str,
116    outcomes: &[ActionOutcome],
117    ctx: &TargetSyncContext<'_>,
118    diag: &mut DiagnosticCollector,
119) -> Result<TargetSyncOutcome, MarsError> {
120    let old_lock = ctx.old_lock;
121    let force = ctx.force;
122    let collision_hint = ctx.collision_hint;
123    let mut items_synced = 0;
124    let mut items_removed = 0;
125    let mut errors = Vec::new();
126    let mut synced_outputs = Vec::new();
127    let mut removed_dest_paths = Vec::new();
128    let previous_managed_paths = old_lock.output_dest_paths_for_target(target_name);
129
130    std::fs::create_dir_all(target_root)?;
131
132    let mut expected_paths: HashSet<String> = HashSet::new();
133    let target_registry = crate::target::TargetRegistry::new();
134    let target_adapter = target_registry.get(target_name);
135    let native_skill_variant_key = target_adapter
136        .and_then(|adapter| adapter.skill_variant_key())
137        .map(str::to_owned);
138    let target_accepts_canonical_agents = target_adapter
139        .map(|adapter| {
140            adapter
141                .default_dest_path(crate::lock::ItemKind::Agent, "__mars_probe__")
142                .is_some()
143        })
144        .unwrap_or(true);
145
146    for outcome in outcomes {
147        if outcome.item_id.kind == crate::lock::ItemKind::BootstrapDoc {
148            continue;
149        }
150        let dest_rel = outcome.dest_path.as_str();
151        if outcome.item_id.kind == crate::lock::ItemKind::Agent && !target_accepts_canonical_agents
152        {
153            if matches!(outcome.action, ActionTaken::Removed) {
154                let target_path = target_root.join(dest_rel);
155                if remove_target_path_if_managed(
156                    &target_path,
157                    target_name,
158                    dest_rel,
159                    old_lock,
160                    &mut errors,
161                ) {
162                    items_removed += 1;
163                    removed_dest_paths.push(dest_rel.to_string());
164                }
165            }
166            continue;
167        }
168        match &outcome.action {
169            ActionTaken::Removed => {
170                let target_path = target_root.join(dest_rel);
171                if remove_target_path_if_managed(
172                    &target_path,
173                    target_name,
174                    dest_rel,
175                    old_lock,
176                    &mut errors,
177                ) {
178                    items_removed += 1;
179                    removed_dest_paths.push(dest_rel.to_string());
180                }
181            }
182            ActionTaken::Skipped => {
183                expected_paths.insert(dest_rel.to_string());
184                let source = mars_dir.join(dest_rel);
185                let dest = target_root.join(dest_rel);
186                if source.exists() || source.symlink_metadata().is_ok() {
187                    let should_refresh_native_skill = outcome.item_id.kind
188                        == crate::lock::ItemKind::Skill
189                        && native_skill_variant_key.is_some();
190                    let dest_exists = surface_ownership::target_dest_exists(&dest);
191                    let wants_copy = force || !dest_exists || should_refresh_native_skill;
192                    if wants_copy {
193                        if should_copy_to_target(
194                            &dest,
195                            target_name,
196                            dest_rel,
197                            old_lock,
198                            force,
199                            collision_hint,
200                            diag,
201                        ) {
202                            let previous_target_hash = if should_refresh_native_skill && dest_exists
203                            {
204                                crate::hash::compute_hash(&dest, outcome.item_id.kind).ok()
205                            } else {
206                                None
207                            };
208                            match copy_item_to_target(
209                                &source,
210                                &dest,
211                                outcome.item_id.kind,
212                                outcome.item_id.name.as_str(),
213                                native_skill_variant_key.as_deref(),
214                                diag,
215                            ) {
216                                Ok(true) => {
217                                    items_synced += 1;
218                                    record_synced_output(
219                                        &mut synced_outputs,
220                                        &dest,
221                                        dest_rel,
222                                        outcome.item_id.kind,
223                                    );
224                                    if let Some(previous_target_hash) = previous_target_hash
225                                        && let Ok(current_target_hash) =
226                                            crate::hash::compute_hash(&dest, outcome.item_id.kind)
227                                        && previous_target_hash != current_target_hash
228                                    {
229                                        diag.warn(
230                                            "target-native-projection-repaired",
231                                            format!(
232                                                "repaired diverged native projection: {target_name}/{dest_rel}/SKILL.md"
233                                            ),
234                                        );
235                                    }
236                                }
237                                Ok(false) => {}
238                                Err(e) => errors.push(format!("failed to copy {dest_rel}: {e}")),
239                            }
240                        }
241                    } else if native_skill_variant_key.is_none()
242                        && old_lock.contains_output(target_name, dest_rel)
243                        && let Some(expected_checksum) = &outcome.installed_checksum
244                    {
245                        match crate::hash::compute_hash(&dest, outcome.item_id.kind) {
246                            Ok(actual) => {
247                                let actual = ContentHash::from(actual);
248                                if &actual != expected_checksum {
249                                    diag.warn(
250                                        "target-divergent",
251                                        format!(
252                                            "target `{target_name}` item `{}` diverged from `.mars` (preserved local content; run `{cmd1}` or `{cmd2}` to reset)",
253                                            dest_rel,
254                                            cmd1 = managed_cmd("mars sync --force"),
255                                            cmd2 = managed_cmd("mars repair"),
256                                        ),
257                                    );
258                                }
259                            }
260                            Err(e) => {
261                                errors.push(format!("failed to verify {dest_rel} checksum: {e}"))
262                            }
263                        }
264                    } else if dest_exists && !old_lock.contains_output(target_name, dest_rel) {
265                        surface_ownership::warn_unmanaged_collision(
266                            target_name,
267                            dest_rel,
268                            collision_hint,
269                            diag,
270                        );
271                    }
272                }
273            }
274            _ => {
275                expected_paths.insert(dest_rel.to_string());
276                let source = mars_dir.join(dest_rel);
277                let dest = target_root.join(dest_rel);
278                if (source.exists() || source.symlink_metadata().is_ok())
279                    && should_copy_to_target(
280                        &dest,
281                        target_name,
282                        dest_rel,
283                        old_lock,
284                        force,
285                        collision_hint,
286                        diag,
287                    )
288                {
289                    match copy_item_to_target(
290                        &source,
291                        &dest,
292                        outcome.item_id.kind,
293                        outcome.item_id.name.as_str(),
294                        native_skill_variant_key.as_deref(),
295                        diag,
296                    ) {
297                        Ok(true) => {
298                            items_synced += 1;
299                            record_synced_output(
300                                &mut synced_outputs,
301                                &dest,
302                                dest_rel,
303                                outcome.item_id.kind,
304                            );
305                        }
306                        Ok(false) => {}
307                        Err(e) => errors.push(format!("failed to copy {dest_rel}: {e}")),
308                    }
309                }
310            }
311        }
312    }
313
314    if let Some(preserve) = ctx.orphan_preserve_paths
315        && let Some(paths) = preserve.get(target_name)
316    {
317        expected_paths.extend(paths.iter().cloned());
318    }
319
320    let orphan_removed = cleanup_orphans(
321        target_root,
322        &expected_paths,
323        &previous_managed_paths,
324        &mut removed_dest_paths,
325        &mut errors,
326    );
327    items_removed += orphan_removed;
328
329    Ok(TargetSyncOutcome {
330        target: target_name.to_string(),
331        items_synced,
332        items_removed,
333        errors,
334        synced_outputs,
335        removed_dest_paths,
336    })
337}
338
339fn should_copy_to_target(
340    dest: &Path,
341    target_name: &str,
342    dest_rel: &str,
343    old_lock: &LockFile,
344    force: bool,
345    collision_hint: CollisionAdoptHint,
346    diag: &mut DiagnosticCollector,
347) -> bool {
348    let dest_exists = surface_ownership::target_dest_exists(dest);
349    match surface_ownership::copy_decision(old_lock, target_name, dest_rel, dest_exists, force) {
350        SurfaceCopyDecision::Proceed => {
351            if dest_exists && force && !old_lock.contains_output(target_name, dest_rel) {
352                surface_ownership::warn_unmanaged_adopted(
353                    target_name,
354                    dest_rel,
355                    collision_hint,
356                    diag,
357                );
358            }
359            true
360        }
361        SurfaceCopyDecision::SkipUnmanagedCollision => {
362            surface_ownership::warn_unmanaged_collision(
363                target_name,
364                dest_rel,
365                collision_hint,
366                diag,
367            );
368            false
369        }
370    }
371}
372
373fn remove_target_path_if_managed(
374    target_path: &Path,
375    target_name: &str,
376    dest_rel: &str,
377    old_lock: &LockFile,
378    errors: &mut Vec<String>,
379) -> bool {
380    if !surface_ownership::target_dest_exists(target_path) {
381        return false;
382    }
383    if !surface_ownership::may_delete(old_lock, target_name, dest_rel) {
384        return false;
385    }
386    match fs_ops::safe_remove(target_path) {
387        Ok(()) => true,
388        Err(e) => {
389            errors.push(format!("failed to remove {dest_rel}: {e}"));
390            false
391        }
392    }
393}
394
395fn record_synced_output(
396    synced_outputs: &mut Vec<TargetSyncedOutput>,
397    dest: &Path,
398    dest_rel: &str,
399    kind: crate::lock::ItemKind,
400) {
401    if let Ok(checksum) = crate::hash::compute_hash(dest, kind) {
402        synced_outputs.push(TargetSyncedOutput {
403            dest_path: dest_rel.to_string(),
404            installed_checksum: ContentHash::from(checksum),
405        });
406    }
407}
408
409/// Copy an item (file or directory) from .mars/ to a target directory.
410///
411/// Follows symlinks on the source side (D26 — targets get file copies, not symlinks).
412/// Uses atomic operations via the reconcile layer.
413///
414/// Returns `true` when bytes were written to `dest`, `false` when existing content
415/// was already byte-identical and left untouched.
416fn copy_item_to_target(
417    source: &Path,
418    dest: &Path,
419    kind: crate::lock::ItemKind,
420    item_name: &str,
421    native_skill_variant_key: Option<&str>,
422    diag: &mut DiagnosticCollector,
423) -> Result<bool, MarsError> {
424    if kind == crate::lock::ItemKind::Skill && native_skill_variant_key.is_some() {
425        crate::compiler::variants::validate_skill_variants(source, item_name, diag);
426        return crate::compiler::variants::project_skill_for_target(
427            source,
428            dest,
429            native_skill_variant_key,
430            diag,
431            item_name,
432        );
433    }
434
435    // Ensure parent directories exist
436    if let Some(parent) = dest.parent() {
437        std::fs::create_dir_all(parent)?;
438    }
439
440    // Follow symlinks to determine if source is a file or directory
441    let metadata = std::fs::metadata(source)?;
442
443    if metadata.is_dir() {
444        if dest.exists() && fs_ops::directory_trees_content_equal(source, dest)? {
445            return Ok(false);
446        }
447        fs_ops::atomic_copy_dir(source, dest)?;
448    } else if metadata.is_file() {
449        if fs_ops::file_content_equal(source, dest)? {
450            return Ok(false);
451        }
452        fs_ops::atomic_copy_file(source, dest)?;
453    }
454
455    Ok(true)
456}
457
458/// Clean up orphaned items in a target directory.
459///
460/// Uses lock v2 output records (via `previous_managed_paths`) to determine
461/// what was managed in the prior sync, rather than scanning hardcoded
462/// subdirectories. Removes entries that were previously managed but are no
463/// longer expected in the current sync.
464///
465/// Returns the number of items removed.
466fn cleanup_orphans(
467    target_root: &Path,
468    expected: &HashSet<String>,
469    previous_managed_paths: &HashSet<String>,
470    removed_dest_paths: &mut Vec<String>,
471    errors: &mut Vec<String>,
472) -> usize {
473    let mut removed = 0;
474
475    // Lock-driven: iterate paths from the old lock, not hardcoded subdirectories.
476    // Only remove entries that were previously managed and are no longer expected.
477    for managed_path in previous_managed_paths {
478        if expected.contains(managed_path) {
479            continue;
480        }
481
482        let full_path = target_root.join(managed_path);
483
484        // Skip if the path doesn't exist (already removed or never synced to this target).
485        if !full_path.exists() && full_path.symlink_metadata().is_err() {
486            continue;
487        }
488
489        // Skip symlinked paths (legacy link setup — don't touch).
490        if full_path
491            .symlink_metadata()
492            .map(|m| m.file_type().is_symlink())
493            .unwrap_or(false)
494        {
495            continue;
496        }
497
498        if let Err(e) = fs_ops::safe_remove(&full_path) {
499            errors.push(format!("failed to remove orphan {managed_path}: {e}"));
500        } else {
501            removed += 1;
502            removed_dest_paths.push(managed_path.clone());
503        }
504    }
505
506    removed
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512    use crate::diagnostic::DiagnosticCollector;
513    use crate::hash;
514    use crate::lock::{ItemKind, LockFile, LockedItemV2, OutputRecord};
515    use crate::surface_ownership::CollisionAdoptHint;
516    use crate::sync::apply::{ActionOutcome, ActionTaken};
517    use crate::types::{DestPath, ItemName};
518    use tempfile::TempDir;
519
520    fn make_outcome(dest: &str, action: ActionTaken) -> ActionOutcome {
521        ActionOutcome {
522            item_id: crate::lock::ItemId {
523                kind: crate::lock::ItemKind::Agent,
524                name: ItemName::from("test"),
525            },
526            action,
527            dest_path: DestPath::from(dest),
528            source_name: "test-source".into(),
529            source_checksum: None,
530            installed_checksum: None,
531        }
532    }
533
534    fn lock_with_target_outputs(target: &str, outputs: &[(&str, &str)]) -> LockFile {
535        let mut lock = LockFile::empty();
536        for (dest, checksum) in outputs {
537            let name = dest.rsplit('/').next().unwrap_or("item");
538            lock.items.insert(
539                format!("agent/{name}"),
540                LockedItemV2 {
541                    source: "test".into(),
542                    kind: ItemKind::Agent,
543                    version: None,
544                    source_checksum: "sha256:src".into(),
545                    outputs: vec![OutputRecord {
546                        target_root: target.to_string(),
547                        dest_path: (*dest).into(),
548                        installed_checksum: (*checksum).into(),
549                    }],
550                },
551            );
552        }
553        lock
554    }
555
556    fn lock_with_skill_target_outputs(target: &str, outputs: &[(&str, &str)]) -> LockFile {
557        let mut lock = LockFile::empty();
558        for (dest, checksum) in outputs {
559            let name = dest.rsplit('/').next().unwrap_or("item");
560            lock.items.insert(
561                format!("skill/{name}"),
562                LockedItemV2 {
563                    source: "test".into(),
564                    kind: ItemKind::Skill,
565                    version: None,
566                    source_checksum: "sha256:src".into(),
567                    outputs: vec![OutputRecord {
568                        target_root: target.to_string(),
569                        dest_path: (*dest).into(),
570                        installed_checksum: (*checksum).into(),
571                    }],
572                },
573            );
574        }
575        lock
576    }
577
578    fn target_sync_ctx<'a>(old_lock: &'a LockFile, force: bool) -> TargetSyncContext<'a> {
579        TargetSyncContext {
580            old_lock,
581            force,
582            collision_hint: CollisionAdoptHint::SyncForce,
583            orphan_preserve_paths: None,
584        }
585    }
586
587    fn make_skipped_with_checksum(dest: &str, checksum: &str) -> ActionOutcome {
588        let mut outcome = make_outcome(dest, ActionTaken::Skipped);
589        outcome.installed_checksum = Some(checksum.into());
590        outcome
591    }
592
593    #[test]
594    fn sync_copies_installed_items_to_target() {
595        let dir = TempDir::new().unwrap();
596        let mars_dir = dir.path().join(".mars");
597        let target = dir.path().join(".agents");
598
599        // Set up .mars/ content
600        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
601        std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
602
603        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
604        let mut diag = DiagnosticCollector::new();
605
606        let results = sync_managed_targets(
607            dir.path(),
608            &mars_dir,
609            &[".agents".to_string()],
610            &outcomes,
611            &target_sync_ctx(&LockFile::empty(), false),
612            &mut diag,
613        );
614
615        assert_eq!(results.len(), 1);
616        assert_eq!(results[0].items_synced, 1);
617        assert!(results[0].errors.is_empty());
618        assert!(target.join("agents/coder.md").exists());
619        assert_eq!(
620            std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
621            "# Coder"
622        );
623    }
624
625    #[test]
626    fn sync_removes_items_from_target() {
627        let dir = TempDir::new().unwrap();
628        let mars_dir = dir.path().join(".mars");
629        let target = dir.path().join(".agents");
630
631        std::fs::create_dir_all(&mars_dir).unwrap();
632        std::fs::create_dir_all(target.join("agents")).unwrap();
633        std::fs::write(target.join("agents/old.md"), "# Old").unwrap();
634
635        let outcomes = vec![make_outcome("agents/old.md", ActionTaken::Removed)];
636        let mut diag = DiagnosticCollector::new();
637
638        let results = sync_managed_targets(
639            dir.path(),
640            &mars_dir,
641            &[".agents".to_string()],
642            &outcomes,
643            &target_sync_ctx(
644                &lock_with_target_outputs(".agents", &[("agents/old.md", "sha256:old")]),
645                false,
646            ),
647            &mut diag,
648        );
649
650        assert_eq!(results[0].items_removed, 1);
651        assert!(!target.join("agents/old.md").exists());
652    }
653
654    #[test]
655    fn sync_cleans_up_previous_managed_orphans() {
656        let dir = TempDir::new().unwrap();
657        let mars_dir = dir.path().join(".mars");
658        let target = dir.path().join(".agents");
659
660        // Set up .mars/ with one agent
661        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
662        std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
663
664        // Set up target with an extra agent (orphan)
665        std::fs::create_dir_all(target.join("agents")).unwrap();
666        std::fs::write(target.join("agents/orphan.md"), "# Orphan").unwrap();
667
668        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
669        let mut diag = DiagnosticCollector::new();
670
671        let results = sync_managed_targets(
672            dir.path(),
673            &mars_dir,
674            &[".agents".to_string()],
675            &outcomes,
676            &target_sync_ctx(
677                &lock_with_target_outputs(".agents", &[("agents/orphan.md", "sha256:orphan")]),
678                false,
679            ),
680            &mut diag,
681        );
682
683        assert!(target.join("agents/coder.md").exists());
684        assert!(!target.join("agents/orphan.md").exists());
685        assert_eq!(results[0].items_removed, 1);
686    }
687
688    #[test]
689    fn sync_preserves_unmanaged_files_in_target() {
690        let dir = TempDir::new().unwrap();
691        let mars_dir = dir.path().join(".mars");
692        let target = dir.path().join(".agents");
693
694        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
695        std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
696
697        std::fs::create_dir_all(target.join("agents")).unwrap();
698        std::fs::write(target.join("agents/custom.md"), "# User custom").unwrap();
699
700        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
701        let mut diag = DiagnosticCollector::new();
702
703        let results = sync_managed_targets(
704            dir.path(),
705            &mars_dir,
706            &[".agents".to_string()],
707            &outcomes,
708            &target_sync_ctx(&LockFile::empty(), false),
709            &mut diag,
710        );
711
712        assert!(target.join("agents/coder.md").exists());
713        assert!(target.join("agents/custom.md").exists());
714        assert_eq!(results[0].items_removed, 0);
715    }
716
717    #[test]
718    fn sync_removed_agent_outcome_removes_existing_target_agent_without_copying() {
719        let dir = TempDir::new().unwrap();
720        let mars_dir = dir.path().join(".mars");
721        let target = dir.path().join(".agents");
722
723        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
724        std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
725        std::fs::create_dir_all(target.join("agents")).unwrap();
726        std::fs::write(target.join("agents/coder.md"), "# Existing target copy").unwrap();
727
728        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Removed)];
729        let mut diag = DiagnosticCollector::new();
730
731        let results = sync_managed_targets(
732            dir.path(),
733            &mars_dir,
734            &[".agents".to_string()],
735            &outcomes,
736            &target_sync_ctx(
737                &lock_with_target_outputs(".agents", &[("agents/coder.md", "sha256:coder")]),
738                false,
739            ),
740            &mut diag,
741        );
742
743        assert_eq!(results[0].items_synced, 0);
744        assert_eq!(results[0].items_removed, 1);
745        assert!(!target.join("agents/coder.md").exists());
746        assert!(results[0].errors.is_empty());
747    }
748
749    #[test]
750    fn selective_orphan_preserve_keeps_native_agent_without_agent_outcomes() {
751        let dir = TempDir::new().unwrap();
752        let mars_dir = dir.path().join(".mars");
753        let target = dir.path().join(".claude");
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        std::fs::create_dir_all(target.join("agents")).unwrap();
758        std::fs::write(target.join("agents/coder.md"), "# Native").unwrap();
759
760        let old_lock = lock_with_target_outputs(".claude", &[("agents/coder.md", "sha256:native")]);
761        let mut preserve = HashMap::new();
762        preserve.insert(
763            ".claude".to_string(),
764            HashSet::from(["agents/coder.md".to_string()]),
765        );
766        let mut diag = DiagnosticCollector::new();
767
768        let results = sync_managed_targets(
769            dir.path(),
770            &mars_dir,
771            &[".claude".to_string()],
772            &[],
773            &TargetSyncContext {
774                old_lock: &old_lock,
775                force: false,
776                collision_hint: CollisionAdoptHint::SyncForce,
777                orphan_preserve_paths: Some(&preserve),
778            },
779            &mut diag,
780        );
781
782        assert!(target.join("agents/coder.md").exists());
783        assert_eq!(results[0].items_removed, 0);
784        assert!(
785            !results[0]
786                .removed_dest_paths
787                .iter()
788                .any(|path| path == "agents/coder.md"),
789            "selective steady-state must not remove managed native agent before compile"
790        );
791    }
792
793    #[test]
794    fn sync_multiple_targets() {
795        let dir = TempDir::new().unwrap();
796        let mars_dir = dir.path().join(".mars");
797
798        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
799        std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
800
801        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
802        let mut diag = DiagnosticCollector::new();
803
804        let results = sync_managed_targets(
805            dir.path(),
806            &mars_dir,
807            &[".agents".to_string(), ".custom-target".to_string()],
808            &outcomes,
809            &target_sync_ctx(&LockFile::empty(), false),
810            &mut diag,
811        );
812
813        assert_eq!(results.len(), 2);
814        assert!(dir.path().join(".agents/agents/coder.md").exists());
815        assert!(dir.path().join(".custom-target/agents/coder.md").exists());
816    }
817
818    #[test]
819    fn sync_native_targets_skip_canonical_agent_markdown_copies() {
820        let dir = TempDir::new().unwrap();
821        let mars_dir = dir.path().join(".mars");
822
823        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
824        std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
825
826        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
827        let mut diag = DiagnosticCollector::new();
828
829        let results = sync_managed_targets(
830            dir.path(),
831            &mars_dir,
832            &[
833                ".claude".to_string(),
834                ".codex".to_string(),
835                ".opencode".to_string(),
836                ".pi".to_string(),
837            ],
838            &outcomes,
839            &target_sync_ctx(&LockFile::empty(), false),
840            &mut diag,
841        );
842
843        assert_eq!(results.len(), 4);
844        assert!(results.iter().all(|outcome| outcome.items_synced == 0));
845        assert!(!dir.path().join(".claude/agents/coder.md").exists());
846        assert!(!dir.path().join(".codex/agents/coder.md").exists());
847        assert!(!dir.path().join(".opencode/agents/coder.md").exists());
848        assert!(!dir.path().join(".pi/agents/coder.md").exists());
849    }
850
851    #[test]
852    fn sync_unknown_target_still_copies_canonical_agents() {
853        let dir = TempDir::new().unwrap();
854        let mars_dir = dir.path().join(".mars");
855
856        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
857        std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
858
859        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
860        let mut diag = DiagnosticCollector::new();
861
862        let results = sync_managed_targets(
863            dir.path(),
864            &mars_dir,
865            &[".custom-target".to_string()],
866            &outcomes,
867            &target_sync_ctx(&LockFile::empty(), false),
868            &mut diag,
869        );
870
871        assert_eq!(results[0].items_synced, 1);
872        assert!(dir.path().join(".custom-target/agents/coder.md").exists());
873    }
874
875    #[test]
876    fn sync_skill_directory() {
877        let dir = TempDir::new().unwrap();
878        let mars_dir = dir.path().join(".mars");
879        let target = dir.path().join(".agents");
880
881        std::fs::create_dir_all(mars_dir.join("skills/planning")).unwrap();
882        std::fs::write(mars_dir.join("skills/planning/SKILL.md"), "# Planning").unwrap();
883
884        let mut outcome = make_outcome("skills/planning", ActionTaken::Installed);
885        outcome.item_id.kind = crate::lock::ItemKind::Skill;
886        let outcomes = vec![outcome];
887        let mut diag = DiagnosticCollector::new();
888
889        let results = sync_managed_targets(
890            dir.path(),
891            &mars_dir,
892            &[".agents".to_string()],
893            &outcomes,
894            &target_sync_ctx(&LockFile::empty(), false),
895            &mut diag,
896        );
897
898        assert_eq!(results[0].items_synced, 1);
899        assert!(target.join("skills/planning/SKILL.md").exists());
900    }
901
902    #[test]
903    fn sync_projects_skills_for_native_harness_targets() {
904        let dir = TempDir::new().unwrap();
905        let mars_dir = dir.path().join(".mars");
906        let target = dir.path().join(".claude");
907
908        std::fs::create_dir_all(mars_dir.join("skills/planning/resources")).unwrap();
909        std::fs::create_dir_all(mars_dir.join("skills/planning/variants/claude")).unwrap();
910        std::fs::create_dir_all(target.join("skills")).unwrap();
911        std::fs::write(target.join("skills/orphan"), "# Orphan").unwrap();
912        std::fs::write(mars_dir.join("skills/planning/SKILL.md"), "# Base").unwrap();
913        std::fs::write(
914            mars_dir.join("skills/planning/resources/BOOTSTRAP.md"),
915            "# Bootstrap",
916        )
917        .unwrap();
918        std::fs::write(
919            mars_dir.join("skills/planning/variants/claude/SKILL.md"),
920            "# Claude",
921        )
922        .unwrap();
923
924        let mut outcome = make_outcome("skills/planning", ActionTaken::Installed);
925        outcome.item_id.kind = crate::lock::ItemKind::Skill;
926        let outcomes = vec![outcome];
927        let mut diag = DiagnosticCollector::new();
928
929        let results = sync_managed_targets(
930            dir.path(),
931            &mars_dir,
932            &[".claude".to_string()],
933            &outcomes,
934            &target_sync_ctx(
935                &lock_with_skill_target_outputs(
936                    ".claude",
937                    &[
938                        ("skills/planning", "sha256:planning"),
939                        ("skills/orphan", "sha256:orphan"),
940                    ],
941                ),
942                false,
943            ),
944            &mut diag,
945        );
946
947        assert_eq!(results[0].items_synced, 1);
948        assert_eq!(
949            std::fs::read_to_string(target.join("skills/planning/SKILL.md")).unwrap(),
950            "# Claude"
951        );
952        assert_eq!(
953            std::fs::read_to_string(target.join("skills/planning/resources/BOOTSTRAP.md")).unwrap(),
954            "# Bootstrap"
955        );
956        assert!(!target.join("skills/planning/variants").exists());
957        assert!(!target.join("skills/orphan").exists());
958    }
959
960    #[test]
961    fn cleanup_orphans_uses_forward_slash_keys_for_expected_paths() {
962        let dir = TempDir::new().unwrap();
963        let target_root = dir.path().join(".agents");
964        std::fs::create_dir_all(target_root.join("agents")).unwrap();
965        std::fs::write(target_root.join("agents/coder.md"), "# Managed").unwrap();
966        std::fs::write(target_root.join("agents/orphan.md"), "# Orphan").unwrap();
967
968        let mut expected = HashSet::new();
969        expected.insert(
970            DestPath::new(r"agents\coder.md")
971                .unwrap()
972                .as_str()
973                .to_string(),
974        );
975
976        let previous = lock_with_target_outputs(
977            ".agents",
978            &[
979                ("agents/coder.md", "sha256:coder"),
980                ("agents/orphan.md", "sha256:orphan"),
981            ],
982        );
983        let previous_paths = previous.output_dest_paths_for_target(".agents");
984        let mut removed_dest_paths = Vec::new();
985        let removed = cleanup_orphans(
986            &target_root,
987            &expected,
988            &previous_paths,
989            &mut removed_dest_paths,
990            &mut Vec::new(),
991        );
992
993        assert_eq!(removed, 1);
994        assert!(target_root.join("agents/coder.md").exists());
995        assert!(!target_root.join("agents/orphan.md").exists());
996    }
997
998    #[test]
999    fn sync_convergence_on_rerun() {
1000        let dir = TempDir::new().unwrap();
1001        let mars_dir = dir.path().join(".mars");
1002        let target = dir.path().join(".agents");
1003
1004        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
1005        std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
1006
1007        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
1008        let mut diag = DiagnosticCollector::new();
1009
1010        // First run
1011        sync_managed_targets(
1012            dir.path(),
1013            &mars_dir,
1014            &[".agents".to_string()],
1015            &outcomes,
1016            &target_sync_ctx(&LockFile::empty(), false),
1017            &mut diag,
1018        );
1019
1020        // Second run with Skipped action — should converge (file already exists)
1021        let outcomes2 = vec![make_outcome("agents/coder.md", ActionTaken::Skipped)];
1022        let results = sync_managed_targets(
1023            dir.path(),
1024            &mars_dir,
1025            &[".agents".to_string()],
1026            &outcomes2,
1027            &target_sync_ctx(
1028                &lock_with_target_outputs(".agents", &[("agents/coder.md", "sha256:coder")]),
1029                false,
1030            ),
1031            &mut diag,
1032        );
1033
1034        assert!(target.join("agents/coder.md").exists());
1035        // items_synced should be 0 since file already exists
1036        assert_eq!(results[0].items_synced, 0);
1037    }
1038
1039    #[test]
1040    fn sync_force_refreshes_skipped_target_content() {
1041        let dir = TempDir::new().unwrap();
1042        let mars_dir = dir.path().join(".mars");
1043        let target = dir.path().join(".agents");
1044
1045        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
1046        std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
1047
1048        std::fs::create_dir_all(target.join("agents")).unwrap();
1049        std::fs::write(target.join("agents/coder.md"), "# Tampered").unwrap();
1050
1051        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Skipped)];
1052        let mut diag = DiagnosticCollector::new();
1053        let results = sync_managed_targets(
1054            dir.path(),
1055            &mars_dir,
1056            &[".agents".to_string()],
1057            &outcomes,
1058            &target_sync_ctx(
1059                &lock_with_target_outputs(".agents", &[("agents/coder.md", "sha256:coder")]),
1060                true,
1061            ),
1062            &mut diag,
1063        );
1064
1065        assert_eq!(results[0].items_synced, 1);
1066        assert_eq!(
1067            std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
1068            "# Canonical"
1069        );
1070    }
1071
1072    #[test]
1073    fn sync_skipped_recopies_missing_target() {
1074        let dir = TempDir::new().unwrap();
1075        let mars_dir = dir.path().join(".mars");
1076        let target = dir.path().join(".agents");
1077
1078        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
1079        std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
1080
1081        let checksum = hash::hash_bytes(b"# Canonical");
1082        let outcomes = vec![make_skipped_with_checksum("agents/coder.md", &checksum)];
1083        let mut diag = DiagnosticCollector::new();
1084        let results = sync_managed_targets(
1085            dir.path(),
1086            &mars_dir,
1087            &[".agents".to_string()],
1088            &outcomes,
1089            &target_sync_ctx(
1090                &lock_with_target_outputs(".agents", &[("agents/coder.md", "sha256:coder")]),
1091                false,
1092            ),
1093            &mut diag,
1094        );
1095
1096        assert_eq!(results[0].items_synced, 1);
1097        assert!(target.join("agents/coder.md").exists());
1098    }
1099
1100    #[test]
1101    fn sync_skipped_warns_on_divergent_target_and_preserves_local_content() {
1102        let dir = TempDir::new().unwrap();
1103        let mars_dir = dir.path().join(".mars");
1104        let target = dir.path().join(".agents");
1105
1106        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
1107        std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
1108
1109        std::fs::create_dir_all(target.join("agents")).unwrap();
1110        std::fs::write(target.join("agents/coder.md"), "# Locally edited").unwrap();
1111
1112        let checksum = hash::hash_bytes(b"# Canonical");
1113        let outcomes = vec![make_skipped_with_checksum("agents/coder.md", &checksum)];
1114        let mut diag = DiagnosticCollector::new();
1115        let results = sync_managed_targets(
1116            dir.path(),
1117            &mars_dir,
1118            &[".agents".to_string()],
1119            &outcomes,
1120            &target_sync_ctx(
1121                &lock_with_target_outputs(".agents", &[("agents/coder.md", "sha256:coder")]),
1122                false,
1123            ),
1124            &mut diag,
1125        );
1126
1127        assert_eq!(results[0].items_synced, 0);
1128        assert_eq!(
1129            std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
1130            "# Locally edited"
1131        );
1132
1133        let diagnostics = diag.drain();
1134        assert!(
1135            diagnostics
1136                .iter()
1137                .any(|d| d.code == "target-divergent" && d.message.contains("agents/coder.md"))
1138        );
1139    }
1140
1141    #[test]
1142    fn sync_preserves_handwritten_collision_when_lock_only_tracks_mars() {
1143        let dir = TempDir::new().unwrap();
1144        let mars_dir = dir.path().join(".mars");
1145        let target = dir.path().join(".cursor");
1146
1147        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
1148        std::fs::write(mars_dir.join("agents/design-lead.md"), "# Canonical").unwrap();
1149        std::fs::create_dir_all(target.join("agents")).unwrap();
1150        std::fs::write(target.join("agents/cursor-only-test.md"), "# custom").unwrap();
1151        std::fs::write(target.join("agents/design-lead.md"), "# hand-written").unwrap();
1152
1153        let mut lock = LockFile::empty();
1154        lock.items.insert(
1155            "agent/design-lead".to_string(),
1156            LockedItemV2 {
1157                source: "test".into(),
1158                kind: ItemKind::Agent,
1159                version: None,
1160                source_checksum: "sha256:src".into(),
1161                outputs: vec![OutputRecord {
1162                    target_root: ".mars".to_string(),
1163                    dest_path: "agents/design-lead.md".into(),
1164                    installed_checksum: "sha256:mars".into(),
1165                }],
1166            },
1167        );
1168
1169        let outcomes = vec![make_outcome("agents/design-lead.md", ActionTaken::Removed)];
1170        let mut diag = DiagnosticCollector::new();
1171
1172        let results = sync_managed_targets(
1173            dir.path(),
1174            &mars_dir,
1175            &[".cursor".to_string()],
1176            &outcomes,
1177            &target_sync_ctx(&lock, false),
1178            &mut diag,
1179        );
1180
1181        assert_eq!(results[0].items_removed, 0);
1182        assert!(target.join("agents/cursor-only-test.md").exists());
1183        assert!(target.join("agents/design-lead.md").exists());
1184        assert_eq!(
1185            std::fs::read_to_string(target.join("agents/design-lead.md")).unwrap(),
1186            "# hand-written"
1187        );
1188    }
1189
1190    #[test]
1191    fn sync_installed_does_not_overwrite_untracked_collision_in_linked_target() {
1192        let dir = TempDir::new().unwrap();
1193        let mars_dir = dir.path().join(".mars");
1194        let target = dir.path().join(".agents");
1195
1196        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
1197        std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
1198        std::fs::create_dir_all(target.join("agents")).unwrap();
1199        std::fs::write(target.join("agents/coder.md"), "# hand-written").unwrap();
1200
1201        let mut lock = LockFile::empty();
1202        lock.items.insert(
1203            "agent/coder".to_string(),
1204            LockedItemV2 {
1205                source: "test".into(),
1206                kind: ItemKind::Agent,
1207                version: None,
1208                source_checksum: "sha256:src".into(),
1209                outputs: vec![OutputRecord {
1210                    target_root: ".mars".to_string(),
1211                    dest_path: "agents/coder.md".into(),
1212                    installed_checksum: "sha256:mars".into(),
1213                }],
1214            },
1215        );
1216
1217        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
1218        let mut diag = DiagnosticCollector::new();
1219
1220        let results = sync_managed_targets(
1221            dir.path(),
1222            &mars_dir,
1223            &[".agents".to_string()],
1224            &outcomes,
1225            &target_sync_ctx(&lock, false),
1226            &mut diag,
1227        );
1228
1229        assert_eq!(results[0].items_synced, 0);
1230        assert_eq!(
1231            std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
1232            "# hand-written"
1233        );
1234        let diagnostics = diag.drain();
1235        assert!(
1236            diagnostics
1237                .iter()
1238                .any(|d| d.code == "target-unmanaged-collision")
1239        );
1240    }
1241
1242    #[test]
1243    fn sync_force_adopts_untracked_collision_in_linked_target() {
1244        let dir = TempDir::new().unwrap();
1245        let mars_dir = dir.path().join(".mars");
1246        let target = dir.path().join(".agents");
1247
1248        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
1249        std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
1250        std::fs::create_dir_all(target.join("agents")).unwrap();
1251        std::fs::write(target.join("agents/coder.md"), "# hand-written").unwrap();
1252
1253        let mut lock = LockFile::empty();
1254        lock.items.insert(
1255            "agent/coder".to_string(),
1256            LockedItemV2 {
1257                source: "test".into(),
1258                kind: ItemKind::Agent,
1259                version: None,
1260                source_checksum: "sha256:src".into(),
1261                outputs: vec![OutputRecord {
1262                    target_root: ".mars".to_string(),
1263                    dest_path: "agents/coder.md".into(),
1264                    installed_checksum: "sha256:mars".into(),
1265                }],
1266            },
1267        );
1268
1269        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
1270        let mut diag = DiagnosticCollector::new();
1271
1272        let results = sync_managed_targets(
1273            dir.path(),
1274            &mars_dir,
1275            &[".agents".to_string()],
1276            &outcomes,
1277            &target_sync_ctx(&lock, true),
1278            &mut diag,
1279        );
1280
1281        assert_eq!(results[0].items_synced, 1);
1282        assert_eq!(
1283            std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
1284            "# Canonical"
1285        );
1286        assert!(!results[0].synced_outputs.is_empty());
1287        let diagnostics = diag.drain();
1288        assert!(
1289            diagnostics
1290                .iter()
1291                .any(|d| d.code == "target-unmanaged-adopted")
1292        );
1293    }
1294
1295    fn make_skipped_skill_outcome(dest: &str, name: &str) -> ActionOutcome {
1296        ActionOutcome {
1297            item_id: crate::lock::ItemId {
1298                kind: ItemKind::Skill,
1299                name: ItemName::from(name),
1300            },
1301            action: ActionTaken::Skipped,
1302            dest_path: DestPath::from(dest),
1303            source_name: "test-source".into(),
1304            source_checksum: None,
1305            installed_checksum: None,
1306        }
1307    }
1308
1309    fn make_installed_skill_outcome(dest: &str, name: &str) -> ActionOutcome {
1310        ActionOutcome {
1311            item_id: crate::lock::ItemId {
1312                kind: ItemKind::Skill,
1313                name: ItemName::from(name),
1314            },
1315            action: ActionTaken::Installed,
1316            dest_path: DestPath::from(dest),
1317            source_name: "test-source".into(),
1318            source_checksum: None,
1319            installed_checksum: None,
1320        }
1321    }
1322
1323    #[test]
1324    fn sync_skipped_native_skill_projection_skips_byte_identical_rewrite() {
1325        let dir = TempDir::new().unwrap();
1326        let mars_dir = dir.path().join(".mars");
1327        let target = dir.path().join(".claude");
1328        let skill_source = mars_dir.join("skills/planning");
1329        std::fs::create_dir_all(skill_source.join("variants/claude")).unwrap();
1330        std::fs::write(
1331            skill_source.join("SKILL.md"),
1332            "---\nname: planning\ndescription: Base\n---\n# Base\n",
1333        )
1334        .unwrap();
1335        std::fs::write(skill_source.join("variants/claude/SKILL.md"), "# Claude").unwrap();
1336
1337        let outcomes = vec![make_installed_skill_outcome("skills/planning", "planning")];
1338        let mut diag = DiagnosticCollector::new();
1339        sync_managed_targets(
1340            dir.path(),
1341            &mars_dir,
1342            &[".claude".to_string()],
1343            &outcomes,
1344            &target_sync_ctx(&LockFile::empty(), false),
1345            &mut diag,
1346        );
1347
1348        let native_skill = target.join("skills/planning/SKILL.md");
1349        assert!(native_skill.exists());
1350        let expected = std::fs::read_to_string(&native_skill).unwrap();
1351        let before = std::fs::metadata(&native_skill)
1352            .unwrap()
1353            .modified()
1354            .unwrap();
1355
1356        std::thread::sleep(std::time::Duration::from_millis(1100));
1357
1358        let skill_dir = target.join("skills/planning");
1359        let checksum = hash::compute_hash(&skill_dir, ItemKind::Skill).unwrap();
1360        let lock =
1361            lock_with_skill_target_outputs(".claude", &[("skills/planning", checksum.as_str())]);
1362        let outcomes2 = vec![make_skipped_skill_outcome("skills/planning", "planning")];
1363        let results = sync_managed_targets(
1364            dir.path(),
1365            &mars_dir,
1366            &[".claude".to_string()],
1367            &outcomes2,
1368            &target_sync_ctx(&lock, false),
1369            &mut diag,
1370        );
1371
1372        assert_eq!(results[0].items_synced, 0);
1373        let after = std::fs::metadata(&native_skill)
1374            .unwrap()
1375            .modified()
1376            .unwrap();
1377        assert_eq!(
1378            before, after,
1379            "no-op sync must not rewrite native skill output"
1380        );
1381        assert_eq!(std::fs::read_to_string(&native_skill).unwrap(), expected);
1382    }
1383
1384    #[test]
1385    fn sync_changed_native_skill_projection_rewrites_target_output() {
1386        let dir = TempDir::new().unwrap();
1387        let mars_dir = dir.path().join(".mars");
1388        let target = dir.path().join(".claude");
1389        let skill_source = mars_dir.join("skills/planning");
1390        std::fs::create_dir_all(skill_source.join("variants/claude")).unwrap();
1391        std::fs::write(
1392            skill_source.join("SKILL.md"),
1393            "---\nname: planning\ndescription: Base\n---\n# Base\n",
1394        )
1395        .unwrap();
1396        std::fs::write(skill_source.join("variants/claude/SKILL.md"), "# Claude v1").unwrap();
1397
1398        let outcomes = vec![make_installed_skill_outcome("skills/planning", "planning")];
1399        let mut diag = DiagnosticCollector::new();
1400        sync_managed_targets(
1401            dir.path(),
1402            &mars_dir,
1403            &[".claude".to_string()],
1404            &outcomes,
1405            &target_sync_ctx(&LockFile::empty(), false),
1406            &mut diag,
1407        );
1408
1409        let expected = std::fs::read_to_string(target.join("skills/planning/SKILL.md")).unwrap();
1410
1411        std::fs::write(skill_source.join("variants/claude/SKILL.md"), "# Claude v2").unwrap();
1412        let outcomes2 = vec![make_installed_skill_outcome("skills/planning", "planning")];
1413        let results = sync_managed_targets(
1414            dir.path(),
1415            &mars_dir,
1416            &[".claude".to_string()],
1417            &outcomes2,
1418            &target_sync_ctx(
1419                &lock_with_skill_target_outputs(".claude", &[("skills/planning", "sha256:old")]),
1420                false,
1421            ),
1422            &mut diag,
1423        );
1424
1425        assert_eq!(results[0].items_synced, 1);
1426        let updated = std::fs::read_to_string(target.join("skills/planning/SKILL.md")).unwrap();
1427        assert!(updated.contains("# Claude v2"));
1428        assert_ne!(updated, expected);
1429    }
1430}