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(()) => {
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                                Err(e) => errors.push(format!("failed to copy {dest_rel}: {e}")),
238                            }
239                        }
240                    } else if native_skill_variant_key.is_none()
241                        && old_lock.contains_output(target_name, dest_rel)
242                        && let Some(expected_checksum) = &outcome.installed_checksum
243                    {
244                        match crate::hash::compute_hash(&dest, outcome.item_id.kind) {
245                            Ok(actual) => {
246                                let actual = ContentHash::from(actual);
247                                if &actual != expected_checksum {
248                                    diag.warn(
249                                        "target-divergent",
250                                        format!(
251                                            "target `{target_name}` item `{}` diverged from `.mars` (preserved local content; run `{cmd1}` or `{cmd2}` to reset)",
252                                            dest_rel,
253                                            cmd1 = managed_cmd("mars sync --force"),
254                                            cmd2 = managed_cmd("mars repair"),
255                                        ),
256                                    );
257                                }
258                            }
259                            Err(e) => {
260                                errors.push(format!("failed to verify {dest_rel} checksum: {e}"))
261                            }
262                        }
263                    } else if dest_exists && !old_lock.contains_output(target_name, dest_rel) {
264                        surface_ownership::warn_unmanaged_collision(
265                            target_name,
266                            dest_rel,
267                            collision_hint,
268                            diag,
269                        );
270                    }
271                }
272            }
273            _ => {
274                expected_paths.insert(dest_rel.to_string());
275                let source = mars_dir.join(dest_rel);
276                let dest = target_root.join(dest_rel);
277                if (source.exists() || source.symlink_metadata().is_ok())
278                    && should_copy_to_target(
279                        &dest,
280                        target_name,
281                        dest_rel,
282                        old_lock,
283                        force,
284                        collision_hint,
285                        diag,
286                    )
287                {
288                    match copy_item_to_target(
289                        &source,
290                        &dest,
291                        outcome.item_id.kind,
292                        outcome.item_id.name.as_str(),
293                        native_skill_variant_key.as_deref(),
294                        diag,
295                    ) {
296                        Ok(()) => {
297                            items_synced += 1;
298                            record_synced_output(
299                                &mut synced_outputs,
300                                &dest,
301                                dest_rel,
302                                outcome.item_id.kind,
303                            );
304                        }
305                        Err(e) => errors.push(format!("failed to copy {dest_rel}: {e}")),
306                    }
307                }
308            }
309        }
310    }
311
312    if let Some(preserve) = ctx.orphan_preserve_paths
313        && let Some(paths) = preserve.get(target_name)
314    {
315        expected_paths.extend(paths.iter().cloned());
316    }
317
318    let orphan_removed = cleanup_orphans(
319        target_root,
320        &expected_paths,
321        &previous_managed_paths,
322        &mut removed_dest_paths,
323        &mut errors,
324    );
325    items_removed += orphan_removed;
326
327    Ok(TargetSyncOutcome {
328        target: target_name.to_string(),
329        items_synced,
330        items_removed,
331        errors,
332        synced_outputs,
333        removed_dest_paths,
334    })
335}
336
337fn should_copy_to_target(
338    dest: &Path,
339    target_name: &str,
340    dest_rel: &str,
341    old_lock: &LockFile,
342    force: bool,
343    collision_hint: CollisionAdoptHint,
344    diag: &mut DiagnosticCollector,
345) -> bool {
346    let dest_exists = surface_ownership::target_dest_exists(dest);
347    match surface_ownership::copy_decision(old_lock, target_name, dest_rel, dest_exists, force) {
348        SurfaceCopyDecision::Proceed => {
349            if dest_exists && force && !old_lock.contains_output(target_name, dest_rel) {
350                surface_ownership::warn_unmanaged_adopted(
351                    target_name,
352                    dest_rel,
353                    collision_hint,
354                    diag,
355                );
356            }
357            true
358        }
359        SurfaceCopyDecision::SkipUnmanagedCollision => {
360            surface_ownership::warn_unmanaged_collision(
361                target_name,
362                dest_rel,
363                collision_hint,
364                diag,
365            );
366            false
367        }
368    }
369}
370
371fn remove_target_path_if_managed(
372    target_path: &Path,
373    target_name: &str,
374    dest_rel: &str,
375    old_lock: &LockFile,
376    errors: &mut Vec<String>,
377) -> bool {
378    if !surface_ownership::target_dest_exists(target_path) {
379        return false;
380    }
381    if !surface_ownership::may_delete(old_lock, target_name, dest_rel) {
382        return false;
383    }
384    match fs_ops::safe_remove(target_path) {
385        Ok(()) => true,
386        Err(e) => {
387            errors.push(format!("failed to remove {dest_rel}: {e}"));
388            false
389        }
390    }
391}
392
393fn record_synced_output(
394    synced_outputs: &mut Vec<TargetSyncedOutput>,
395    dest: &Path,
396    dest_rel: &str,
397    kind: crate::lock::ItemKind,
398) {
399    if let Ok(checksum) = crate::hash::compute_hash(dest, kind) {
400        synced_outputs.push(TargetSyncedOutput {
401            dest_path: dest_rel.to_string(),
402            installed_checksum: ContentHash::from(checksum),
403        });
404    }
405}
406
407/// Copy an item (file or directory) from .mars/ to a target directory.
408///
409/// Follows symlinks on the source side (D26 — targets get file copies, not symlinks).
410/// Uses atomic operations via the reconcile layer.
411fn copy_item_to_target(
412    source: &Path,
413    dest: &Path,
414    kind: crate::lock::ItemKind,
415    item_name: &str,
416    native_skill_variant_key: Option<&str>,
417    diag: &mut DiagnosticCollector,
418) -> Result<(), MarsError> {
419    if kind == crate::lock::ItemKind::Skill && native_skill_variant_key.is_some() {
420        crate::compiler::variants::validate_skill_variants(source, item_name, diag);
421        return crate::compiler::variants::project_skill_for_target(
422            source,
423            dest,
424            native_skill_variant_key,
425            diag,
426            item_name,
427        );
428    }
429
430    // Ensure parent directories exist
431    if let Some(parent) = dest.parent() {
432        std::fs::create_dir_all(parent)?;
433    }
434
435    // Follow symlinks to determine if source is a file or directory
436    let metadata = std::fs::metadata(source)?;
437
438    if metadata.is_dir() {
439        fs_ops::atomic_copy_dir(source, dest)?;
440    } else if metadata.is_file() {
441        fs_ops::atomic_copy_file(source, dest)?;
442    }
443
444    Ok(())
445}
446
447/// Clean up orphaned items in a target directory.
448///
449/// Uses lock v2 output records (via `previous_managed_paths`) to determine
450/// what was managed in the prior sync, rather than scanning hardcoded
451/// subdirectories. Removes entries that were previously managed but are no
452/// longer expected in the current sync.
453///
454/// Returns the number of items removed.
455fn cleanup_orphans(
456    target_root: &Path,
457    expected: &HashSet<String>,
458    previous_managed_paths: &HashSet<String>,
459    removed_dest_paths: &mut Vec<String>,
460    errors: &mut Vec<String>,
461) -> usize {
462    let mut removed = 0;
463
464    // Lock-driven: iterate paths from the old lock, not hardcoded subdirectories.
465    // Only remove entries that were previously managed and are no longer expected.
466    for managed_path in previous_managed_paths {
467        if expected.contains(managed_path) {
468            continue;
469        }
470
471        let full_path = target_root.join(managed_path);
472
473        // Skip if the path doesn't exist (already removed or never synced to this target).
474        if !full_path.exists() && full_path.symlink_metadata().is_err() {
475            continue;
476        }
477
478        // Skip symlinked paths (legacy link setup — don't touch).
479        if full_path
480            .symlink_metadata()
481            .map(|m| m.file_type().is_symlink())
482            .unwrap_or(false)
483        {
484            continue;
485        }
486
487        if let Err(e) = fs_ops::safe_remove(&full_path) {
488            errors.push(format!("failed to remove orphan {managed_path}: {e}"));
489        } else {
490            removed += 1;
491            removed_dest_paths.push(managed_path.clone());
492        }
493    }
494
495    removed
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501    use crate::diagnostic::DiagnosticCollector;
502    use crate::hash;
503    use crate::lock::{ItemKind, LockFile, LockedItemV2, OutputRecord};
504    use crate::surface_ownership::CollisionAdoptHint;
505    use crate::sync::apply::{ActionOutcome, ActionTaken};
506    use crate::types::{DestPath, ItemName};
507    use tempfile::TempDir;
508
509    fn make_outcome(dest: &str, action: ActionTaken) -> ActionOutcome {
510        ActionOutcome {
511            item_id: crate::lock::ItemId {
512                kind: crate::lock::ItemKind::Agent,
513                name: ItemName::from("test"),
514            },
515            action,
516            dest_path: DestPath::from(dest),
517            source_name: "test-source".into(),
518            source_checksum: None,
519            installed_checksum: None,
520        }
521    }
522
523    fn lock_with_target_outputs(target: &str, outputs: &[(&str, &str)]) -> LockFile {
524        let mut lock = LockFile::empty();
525        for (dest, checksum) in outputs {
526            let name = dest.rsplit('/').next().unwrap_or("item");
527            lock.items.insert(
528                format!("agent/{name}"),
529                LockedItemV2 {
530                    source: "test".into(),
531                    kind: ItemKind::Agent,
532                    version: None,
533                    source_checksum: "sha256:src".into(),
534                    outputs: vec![OutputRecord {
535                        target_root: target.to_string(),
536                        dest_path: (*dest).into(),
537                        installed_checksum: (*checksum).into(),
538                    }],
539                },
540            );
541        }
542        lock
543    }
544
545    fn lock_with_skill_target_outputs(target: &str, outputs: &[(&str, &str)]) -> LockFile {
546        let mut lock = LockFile::empty();
547        for (dest, checksum) in outputs {
548            let name = dest.rsplit('/').next().unwrap_or("item");
549            lock.items.insert(
550                format!("skill/{name}"),
551                LockedItemV2 {
552                    source: "test".into(),
553                    kind: ItemKind::Skill,
554                    version: None,
555                    source_checksum: "sha256:src".into(),
556                    outputs: vec![OutputRecord {
557                        target_root: target.to_string(),
558                        dest_path: (*dest).into(),
559                        installed_checksum: (*checksum).into(),
560                    }],
561                },
562            );
563        }
564        lock
565    }
566
567    fn target_sync_ctx<'a>(old_lock: &'a LockFile, force: bool) -> TargetSyncContext<'a> {
568        TargetSyncContext {
569            old_lock,
570            force,
571            collision_hint: CollisionAdoptHint::SyncForce,
572            orphan_preserve_paths: None,
573        }
574    }
575
576    fn make_skipped_with_checksum(dest: &str, checksum: &str) -> ActionOutcome {
577        let mut outcome = make_outcome(dest, ActionTaken::Skipped);
578        outcome.installed_checksum = Some(checksum.into());
579        outcome
580    }
581
582    #[test]
583    fn sync_copies_installed_items_to_target() {
584        let dir = TempDir::new().unwrap();
585        let mars_dir = dir.path().join(".mars");
586        let target = dir.path().join(".agents");
587
588        // Set up .mars/ content
589        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
590        std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
591
592        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
593        let mut diag = DiagnosticCollector::new();
594
595        let results = sync_managed_targets(
596            dir.path(),
597            &mars_dir,
598            &[".agents".to_string()],
599            &outcomes,
600            &target_sync_ctx(&LockFile::empty(), false),
601            &mut diag,
602        );
603
604        assert_eq!(results.len(), 1);
605        assert_eq!(results[0].items_synced, 1);
606        assert!(results[0].errors.is_empty());
607        assert!(target.join("agents/coder.md").exists());
608        assert_eq!(
609            std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
610            "# Coder"
611        );
612    }
613
614    #[test]
615    fn sync_removes_items_from_target() {
616        let dir = TempDir::new().unwrap();
617        let mars_dir = dir.path().join(".mars");
618        let target = dir.path().join(".agents");
619
620        std::fs::create_dir_all(&mars_dir).unwrap();
621        std::fs::create_dir_all(target.join("agents")).unwrap();
622        std::fs::write(target.join("agents/old.md"), "# Old").unwrap();
623
624        let outcomes = vec![make_outcome("agents/old.md", ActionTaken::Removed)];
625        let mut diag = DiagnosticCollector::new();
626
627        let results = sync_managed_targets(
628            dir.path(),
629            &mars_dir,
630            &[".agents".to_string()],
631            &outcomes,
632            &target_sync_ctx(
633                &lock_with_target_outputs(".agents", &[("agents/old.md", "sha256:old")]),
634                false,
635            ),
636            &mut diag,
637        );
638
639        assert_eq!(results[0].items_removed, 1);
640        assert!(!target.join("agents/old.md").exists());
641    }
642
643    #[test]
644    fn sync_cleans_up_previous_managed_orphans() {
645        let dir = TempDir::new().unwrap();
646        let mars_dir = dir.path().join(".mars");
647        let target = dir.path().join(".agents");
648
649        // Set up .mars/ with one agent
650        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
651        std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
652
653        // Set up target with an extra agent (orphan)
654        std::fs::create_dir_all(target.join("agents")).unwrap();
655        std::fs::write(target.join("agents/orphan.md"), "# Orphan").unwrap();
656
657        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
658        let mut diag = DiagnosticCollector::new();
659
660        let results = sync_managed_targets(
661            dir.path(),
662            &mars_dir,
663            &[".agents".to_string()],
664            &outcomes,
665            &target_sync_ctx(
666                &lock_with_target_outputs(".agents", &[("agents/orphan.md", "sha256:orphan")]),
667                false,
668            ),
669            &mut diag,
670        );
671
672        assert!(target.join("agents/coder.md").exists());
673        assert!(!target.join("agents/orphan.md").exists());
674        assert_eq!(results[0].items_removed, 1);
675    }
676
677    #[test]
678    fn sync_preserves_unmanaged_files_in_target() {
679        let dir = TempDir::new().unwrap();
680        let mars_dir = dir.path().join(".mars");
681        let target = dir.path().join(".agents");
682
683        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
684        std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
685
686        std::fs::create_dir_all(target.join("agents")).unwrap();
687        std::fs::write(target.join("agents/custom.md"), "# User custom").unwrap();
688
689        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
690        let mut diag = DiagnosticCollector::new();
691
692        let results = sync_managed_targets(
693            dir.path(),
694            &mars_dir,
695            &[".agents".to_string()],
696            &outcomes,
697            &target_sync_ctx(&LockFile::empty(), false),
698            &mut diag,
699        );
700
701        assert!(target.join("agents/coder.md").exists());
702        assert!(target.join("agents/custom.md").exists());
703        assert_eq!(results[0].items_removed, 0);
704    }
705
706    #[test]
707    fn sync_removed_agent_outcome_removes_existing_target_agent_without_copying() {
708        let dir = TempDir::new().unwrap();
709        let mars_dir = dir.path().join(".mars");
710        let target = dir.path().join(".agents");
711
712        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
713        std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
714        std::fs::create_dir_all(target.join("agents")).unwrap();
715        std::fs::write(target.join("agents/coder.md"), "# Existing target copy").unwrap();
716
717        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Removed)];
718        let mut diag = DiagnosticCollector::new();
719
720        let results = sync_managed_targets(
721            dir.path(),
722            &mars_dir,
723            &[".agents".to_string()],
724            &outcomes,
725            &target_sync_ctx(
726                &lock_with_target_outputs(".agents", &[("agents/coder.md", "sha256:coder")]),
727                false,
728            ),
729            &mut diag,
730        );
731
732        assert_eq!(results[0].items_synced, 0);
733        assert_eq!(results[0].items_removed, 1);
734        assert!(!target.join("agents/coder.md").exists());
735        assert!(results[0].errors.is_empty());
736    }
737
738    #[test]
739    fn selective_orphan_preserve_keeps_native_agent_without_agent_outcomes() {
740        let dir = TempDir::new().unwrap();
741        let mars_dir = dir.path().join(".mars");
742        let target = dir.path().join(".claude");
743
744        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
745        std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
746        std::fs::create_dir_all(target.join("agents")).unwrap();
747        std::fs::write(target.join("agents/coder.md"), "# Native").unwrap();
748
749        let old_lock = lock_with_target_outputs(".claude", &[("agents/coder.md", "sha256:native")]);
750        let mut preserve = HashMap::new();
751        preserve.insert(
752            ".claude".to_string(),
753            HashSet::from(["agents/coder.md".to_string()]),
754        );
755        let mut diag = DiagnosticCollector::new();
756
757        let results = sync_managed_targets(
758            dir.path(),
759            &mars_dir,
760            &[".claude".to_string()],
761            &[],
762            &TargetSyncContext {
763                old_lock: &old_lock,
764                force: false,
765                collision_hint: CollisionAdoptHint::SyncForce,
766                orphan_preserve_paths: Some(&preserve),
767            },
768            &mut diag,
769        );
770
771        assert!(target.join("agents/coder.md").exists());
772        assert_eq!(results[0].items_removed, 0);
773        assert!(
774            !results[0]
775                .removed_dest_paths
776                .iter()
777                .any(|path| path == "agents/coder.md"),
778            "selective steady-state must not remove managed native agent before compile"
779        );
780    }
781
782    #[test]
783    fn sync_multiple_targets() {
784        let dir = TempDir::new().unwrap();
785        let mars_dir = dir.path().join(".mars");
786
787        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
788        std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
789
790        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
791        let mut diag = DiagnosticCollector::new();
792
793        let results = sync_managed_targets(
794            dir.path(),
795            &mars_dir,
796            &[".agents".to_string(), ".custom-target".to_string()],
797            &outcomes,
798            &target_sync_ctx(&LockFile::empty(), false),
799            &mut diag,
800        );
801
802        assert_eq!(results.len(), 2);
803        assert!(dir.path().join(".agents/agents/coder.md").exists());
804        assert!(dir.path().join(".custom-target/agents/coder.md").exists());
805    }
806
807    #[test]
808    fn sync_native_targets_skip_canonical_agent_markdown_copies() {
809        let dir = TempDir::new().unwrap();
810        let mars_dir = dir.path().join(".mars");
811
812        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
813        std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
814
815        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
816        let mut diag = DiagnosticCollector::new();
817
818        let results = sync_managed_targets(
819            dir.path(),
820            &mars_dir,
821            &[
822                ".claude".to_string(),
823                ".codex".to_string(),
824                ".opencode".to_string(),
825                ".pi".to_string(),
826            ],
827            &outcomes,
828            &target_sync_ctx(&LockFile::empty(), false),
829            &mut diag,
830        );
831
832        assert_eq!(results.len(), 4);
833        assert!(results.iter().all(|outcome| outcome.items_synced == 0));
834        assert!(!dir.path().join(".claude/agents/coder.md").exists());
835        assert!(!dir.path().join(".codex/agents/coder.md").exists());
836        assert!(!dir.path().join(".opencode/agents/coder.md").exists());
837        assert!(!dir.path().join(".pi/agents/coder.md").exists());
838    }
839
840    #[test]
841    fn sync_unknown_target_still_copies_canonical_agents() {
842        let dir = TempDir::new().unwrap();
843        let mars_dir = dir.path().join(".mars");
844
845        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
846        std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
847
848        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
849        let mut diag = DiagnosticCollector::new();
850
851        let results = sync_managed_targets(
852            dir.path(),
853            &mars_dir,
854            &[".custom-target".to_string()],
855            &outcomes,
856            &target_sync_ctx(&LockFile::empty(), false),
857            &mut diag,
858        );
859
860        assert_eq!(results[0].items_synced, 1);
861        assert!(dir.path().join(".custom-target/agents/coder.md").exists());
862    }
863
864    #[test]
865    fn sync_skill_directory() {
866        let dir = TempDir::new().unwrap();
867        let mars_dir = dir.path().join(".mars");
868        let target = dir.path().join(".agents");
869
870        std::fs::create_dir_all(mars_dir.join("skills/planning")).unwrap();
871        std::fs::write(mars_dir.join("skills/planning/SKILL.md"), "# Planning").unwrap();
872
873        let mut outcome = make_outcome("skills/planning", ActionTaken::Installed);
874        outcome.item_id.kind = crate::lock::ItemKind::Skill;
875        let outcomes = vec![outcome];
876        let mut diag = DiagnosticCollector::new();
877
878        let results = sync_managed_targets(
879            dir.path(),
880            &mars_dir,
881            &[".agents".to_string()],
882            &outcomes,
883            &target_sync_ctx(&LockFile::empty(), false),
884            &mut diag,
885        );
886
887        assert_eq!(results[0].items_synced, 1);
888        assert!(target.join("skills/planning/SKILL.md").exists());
889    }
890
891    #[test]
892    fn sync_projects_skills_for_native_harness_targets() {
893        let dir = TempDir::new().unwrap();
894        let mars_dir = dir.path().join(".mars");
895        let target = dir.path().join(".claude");
896
897        std::fs::create_dir_all(mars_dir.join("skills/planning/resources")).unwrap();
898        std::fs::create_dir_all(mars_dir.join("skills/planning/variants/claude")).unwrap();
899        std::fs::create_dir_all(target.join("skills")).unwrap();
900        std::fs::write(target.join("skills/orphan"), "# Orphan").unwrap();
901        std::fs::write(mars_dir.join("skills/planning/SKILL.md"), "# Base").unwrap();
902        std::fs::write(
903            mars_dir.join("skills/planning/resources/BOOTSTRAP.md"),
904            "# Bootstrap",
905        )
906        .unwrap();
907        std::fs::write(
908            mars_dir.join("skills/planning/variants/claude/SKILL.md"),
909            "# Claude",
910        )
911        .unwrap();
912
913        let mut outcome = make_outcome("skills/planning", ActionTaken::Installed);
914        outcome.item_id.kind = crate::lock::ItemKind::Skill;
915        let outcomes = vec![outcome];
916        let mut diag = DiagnosticCollector::new();
917
918        let results = sync_managed_targets(
919            dir.path(),
920            &mars_dir,
921            &[".claude".to_string()],
922            &outcomes,
923            &target_sync_ctx(
924                &lock_with_skill_target_outputs(
925                    ".claude",
926                    &[
927                        ("skills/planning", "sha256:planning"),
928                        ("skills/orphan", "sha256:orphan"),
929                    ],
930                ),
931                false,
932            ),
933            &mut diag,
934        );
935
936        assert_eq!(results[0].items_synced, 1);
937        assert_eq!(
938            std::fs::read_to_string(target.join("skills/planning/SKILL.md")).unwrap(),
939            "# Claude"
940        );
941        assert_eq!(
942            std::fs::read_to_string(target.join("skills/planning/resources/BOOTSTRAP.md")).unwrap(),
943            "# Bootstrap"
944        );
945        assert!(!target.join("skills/planning/variants").exists());
946        assert!(!target.join("skills/orphan").exists());
947    }
948
949    #[test]
950    fn cleanup_orphans_uses_forward_slash_keys_for_expected_paths() {
951        let dir = TempDir::new().unwrap();
952        let target_root = dir.path().join(".agents");
953        std::fs::create_dir_all(target_root.join("agents")).unwrap();
954        std::fs::write(target_root.join("agents/coder.md"), "# Managed").unwrap();
955        std::fs::write(target_root.join("agents/orphan.md"), "# Orphan").unwrap();
956
957        let mut expected = HashSet::new();
958        expected.insert(
959            DestPath::new(r"agents\coder.md")
960                .unwrap()
961                .as_str()
962                .to_string(),
963        );
964
965        let previous = lock_with_target_outputs(
966            ".agents",
967            &[
968                ("agents/coder.md", "sha256:coder"),
969                ("agents/orphan.md", "sha256:orphan"),
970            ],
971        );
972        let previous_paths = previous.output_dest_paths_for_target(".agents");
973        let mut removed_dest_paths = Vec::new();
974        let removed = cleanup_orphans(
975            &target_root,
976            &expected,
977            &previous_paths,
978            &mut removed_dest_paths,
979            &mut Vec::new(),
980        );
981
982        assert_eq!(removed, 1);
983        assert!(target_root.join("agents/coder.md").exists());
984        assert!(!target_root.join("agents/orphan.md").exists());
985    }
986
987    #[test]
988    fn sync_convergence_on_rerun() {
989        let dir = TempDir::new().unwrap();
990        let mars_dir = dir.path().join(".mars");
991        let target = dir.path().join(".agents");
992
993        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
994        std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
995
996        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
997        let mut diag = DiagnosticCollector::new();
998
999        // First run
1000        sync_managed_targets(
1001            dir.path(),
1002            &mars_dir,
1003            &[".agents".to_string()],
1004            &outcomes,
1005            &target_sync_ctx(&LockFile::empty(), false),
1006            &mut diag,
1007        );
1008
1009        // Second run with Skipped action — should converge (file already exists)
1010        let outcomes2 = vec![make_outcome("agents/coder.md", ActionTaken::Skipped)];
1011        let results = sync_managed_targets(
1012            dir.path(),
1013            &mars_dir,
1014            &[".agents".to_string()],
1015            &outcomes2,
1016            &target_sync_ctx(
1017                &lock_with_target_outputs(".agents", &[("agents/coder.md", "sha256:coder")]),
1018                false,
1019            ),
1020            &mut diag,
1021        );
1022
1023        assert!(target.join("agents/coder.md").exists());
1024        // items_synced should be 0 since file already exists
1025        assert_eq!(results[0].items_synced, 0);
1026    }
1027
1028    #[test]
1029    fn sync_force_refreshes_skipped_target_content() {
1030        let dir = TempDir::new().unwrap();
1031        let mars_dir = dir.path().join(".mars");
1032        let target = dir.path().join(".agents");
1033
1034        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
1035        std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
1036
1037        std::fs::create_dir_all(target.join("agents")).unwrap();
1038        std::fs::write(target.join("agents/coder.md"), "# Tampered").unwrap();
1039
1040        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Skipped)];
1041        let mut diag = DiagnosticCollector::new();
1042        let results = sync_managed_targets(
1043            dir.path(),
1044            &mars_dir,
1045            &[".agents".to_string()],
1046            &outcomes,
1047            &target_sync_ctx(
1048                &lock_with_target_outputs(".agents", &[("agents/coder.md", "sha256:coder")]),
1049                true,
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("agents/coder.md")).unwrap(),
1057            "# Canonical"
1058        );
1059    }
1060
1061    #[test]
1062    fn sync_skipped_recopies_missing_target() {
1063        let dir = TempDir::new().unwrap();
1064        let mars_dir = dir.path().join(".mars");
1065        let target = dir.path().join(".agents");
1066
1067        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
1068        std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
1069
1070        let checksum = hash::hash_bytes(b"# Canonical");
1071        let outcomes = vec![make_skipped_with_checksum("agents/coder.md", &checksum)];
1072        let mut diag = DiagnosticCollector::new();
1073        let results = sync_managed_targets(
1074            dir.path(),
1075            &mars_dir,
1076            &[".agents".to_string()],
1077            &outcomes,
1078            &target_sync_ctx(
1079                &lock_with_target_outputs(".agents", &[("agents/coder.md", "sha256:coder")]),
1080                false,
1081            ),
1082            &mut diag,
1083        );
1084
1085        assert_eq!(results[0].items_synced, 1);
1086        assert!(target.join("agents/coder.md").exists());
1087    }
1088
1089    #[test]
1090    fn sync_skipped_warns_on_divergent_target_and_preserves_local_content() {
1091        let dir = TempDir::new().unwrap();
1092        let mars_dir = dir.path().join(".mars");
1093        let target = dir.path().join(".agents");
1094
1095        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
1096        std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
1097
1098        std::fs::create_dir_all(target.join("agents")).unwrap();
1099        std::fs::write(target.join("agents/coder.md"), "# Locally edited").unwrap();
1100
1101        let checksum = hash::hash_bytes(b"# Canonical");
1102        let outcomes = vec![make_skipped_with_checksum("agents/coder.md", &checksum)];
1103        let mut diag = DiagnosticCollector::new();
1104        let results = sync_managed_targets(
1105            dir.path(),
1106            &mars_dir,
1107            &[".agents".to_string()],
1108            &outcomes,
1109            &target_sync_ctx(
1110                &lock_with_target_outputs(".agents", &[("agents/coder.md", "sha256:coder")]),
1111                false,
1112            ),
1113            &mut diag,
1114        );
1115
1116        assert_eq!(results[0].items_synced, 0);
1117        assert_eq!(
1118            std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
1119            "# Locally edited"
1120        );
1121
1122        let diagnostics = diag.drain();
1123        assert!(
1124            diagnostics
1125                .iter()
1126                .any(|d| d.code == "target-divergent" && d.message.contains("agents/coder.md"))
1127        );
1128    }
1129
1130    #[test]
1131    fn sync_preserves_handwritten_collision_when_lock_only_tracks_mars() {
1132        let dir = TempDir::new().unwrap();
1133        let mars_dir = dir.path().join(".mars");
1134        let target = dir.path().join(".cursor");
1135
1136        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
1137        std::fs::write(mars_dir.join("agents/design-lead.md"), "# Canonical").unwrap();
1138        std::fs::create_dir_all(target.join("agents")).unwrap();
1139        std::fs::write(target.join("agents/cursor-only-test.md"), "# custom").unwrap();
1140        std::fs::write(target.join("agents/design-lead.md"), "# hand-written").unwrap();
1141
1142        let mut lock = LockFile::empty();
1143        lock.items.insert(
1144            "agent/design-lead".to_string(),
1145            LockedItemV2 {
1146                source: "test".into(),
1147                kind: ItemKind::Agent,
1148                version: None,
1149                source_checksum: "sha256:src".into(),
1150                outputs: vec![OutputRecord {
1151                    target_root: ".mars".to_string(),
1152                    dest_path: "agents/design-lead.md".into(),
1153                    installed_checksum: "sha256:mars".into(),
1154                }],
1155            },
1156        );
1157
1158        let outcomes = vec![make_outcome("agents/design-lead.md", ActionTaken::Removed)];
1159        let mut diag = DiagnosticCollector::new();
1160
1161        let results = sync_managed_targets(
1162            dir.path(),
1163            &mars_dir,
1164            &[".cursor".to_string()],
1165            &outcomes,
1166            &target_sync_ctx(&lock, false),
1167            &mut diag,
1168        );
1169
1170        assert_eq!(results[0].items_removed, 0);
1171        assert!(target.join("agents/cursor-only-test.md").exists());
1172        assert!(target.join("agents/design-lead.md").exists());
1173        assert_eq!(
1174            std::fs::read_to_string(target.join("agents/design-lead.md")).unwrap(),
1175            "# hand-written"
1176        );
1177    }
1178
1179    #[test]
1180    fn sync_installed_does_not_overwrite_untracked_collision_in_linked_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        std::fs::create_dir_all(target.join("agents")).unwrap();
1188        std::fs::write(target.join("agents/coder.md"), "# hand-written").unwrap();
1189
1190        let mut lock = LockFile::empty();
1191        lock.items.insert(
1192            "agent/coder".to_string(),
1193            LockedItemV2 {
1194                source: "test".into(),
1195                kind: ItemKind::Agent,
1196                version: None,
1197                source_checksum: "sha256:src".into(),
1198                outputs: vec![OutputRecord {
1199                    target_root: ".mars".to_string(),
1200                    dest_path: "agents/coder.md".into(),
1201                    installed_checksum: "sha256:mars".into(),
1202                }],
1203            },
1204        );
1205
1206        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
1207        let mut diag = DiagnosticCollector::new();
1208
1209        let results = sync_managed_targets(
1210            dir.path(),
1211            &mars_dir,
1212            &[".agents".to_string()],
1213            &outcomes,
1214            &target_sync_ctx(&lock, false),
1215            &mut diag,
1216        );
1217
1218        assert_eq!(results[0].items_synced, 0);
1219        assert_eq!(
1220            std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
1221            "# hand-written"
1222        );
1223        let diagnostics = diag.drain();
1224        assert!(
1225            diagnostics
1226                .iter()
1227                .any(|d| d.code == "target-unmanaged-collision")
1228        );
1229    }
1230
1231    #[test]
1232    fn sync_force_adopts_untracked_collision_in_linked_target() {
1233        let dir = TempDir::new().unwrap();
1234        let mars_dir = dir.path().join(".mars");
1235        let target = dir.path().join(".agents");
1236
1237        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
1238        std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
1239        std::fs::create_dir_all(target.join("agents")).unwrap();
1240        std::fs::write(target.join("agents/coder.md"), "# hand-written").unwrap();
1241
1242        let mut lock = LockFile::empty();
1243        lock.items.insert(
1244            "agent/coder".to_string(),
1245            LockedItemV2 {
1246                source: "test".into(),
1247                kind: ItemKind::Agent,
1248                version: None,
1249                source_checksum: "sha256:src".into(),
1250                outputs: vec![OutputRecord {
1251                    target_root: ".mars".to_string(),
1252                    dest_path: "agents/coder.md".into(),
1253                    installed_checksum: "sha256:mars".into(),
1254                }],
1255            },
1256        );
1257
1258        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
1259        let mut diag = DiagnosticCollector::new();
1260
1261        let results = sync_managed_targets(
1262            dir.path(),
1263            &mars_dir,
1264            &[".agents".to_string()],
1265            &outcomes,
1266            &target_sync_ctx(&lock, true),
1267            &mut diag,
1268        );
1269
1270        assert_eq!(results[0].items_synced, 1);
1271        assert_eq!(
1272            std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
1273            "# Canonical"
1274        );
1275        assert!(!results[0].synced_outputs.is_empty());
1276        let diagnostics = diag.drain();
1277        assert!(
1278            diagnostics
1279                .iter()
1280                .any(|d| d.code == "target-unmanaged-adopted")
1281        );
1282    }
1283}