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