Skip to main content

mars_agents/target_sync/
mod.rs

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