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 target directories (`.agents/`, `.claude/`, etc.).
5//!
6//! All targets are managed outputs — they get copies (not symlinks) of .mars/ content.
7//! Symlinks in .mars/ (from local packages) are followed, so targets always get file copies (D26).
8
9use std::collections::HashSet;
10use std::path::{Path, PathBuf};
11
12use crate::diagnostic::DiagnosticCollector;
13use crate::error::MarsError;
14use crate::reconcile::fs_ops;
15use crate::sync::apply::{ActionOutcome, ActionTaken};
16
17/// A directory that mars manages — materialized from .mars/.
18#[derive(Debug, Clone)]
19pub struct ManagedTarget {
20    /// Target directory path relative to project root (e.g. ".claude", ".agents").
21    pub path: String,
22}
23
24/// Result of syncing content to a single target directory.
25#[derive(Debug, Clone)]
26pub struct TargetSyncOutcome {
27    /// Target directory name (e.g. ".claude").
28    pub target: String,
29    /// Number of items successfully synced.
30    pub items_synced: usize,
31    /// Number of items removed (orphan cleanup).
32    pub items_removed: usize,
33    /// Non-fatal errors encountered during sync.
34    pub errors: Vec<String>,
35}
36
37/// Sync all managed targets from .mars/ canonical store.
38///
39/// For each configured target, copies content from `.mars/agents/` and `.mars/skills/`
40/// into the target directory, following symlinks (local package items are symlinks in .mars/).
41/// Cleans up orphaned items that are no longer in the apply outcomes.
42///
43/// Target sync is non-fatal by default (D9) — errors per-target are recorded but don't
44/// stop other targets from being synced.
45pub fn sync_managed_targets(
46    project_root: &Path,
47    mars_dir: &Path,
48    targets: &[String],
49    outcomes: &[ActionOutcome],
50    previous_managed_paths: &HashSet<PathBuf>,
51    force: bool,
52    diag: &mut DiagnosticCollector,
53) -> Vec<TargetSyncOutcome> {
54    let mut results = Vec::new();
55
56    for target_name in targets {
57        let target_root = project_root.join(target_name);
58        match sync_one_target(
59            mars_dir,
60            &target_root,
61            target_name,
62            outcomes,
63            previous_managed_paths,
64            force,
65        ) {
66            Ok(outcome) => {
67                if !outcome.errors.is_empty() {
68                    for err in &outcome.errors {
69                        diag.warn(
70                            "target-sync-error",
71                            format!("target `{target_name}`: {err}"),
72                        );
73                    }
74                }
75                results.push(outcome);
76            }
77            Err(e) => {
78                diag.warn(
79                    "target-sync-failed",
80                    format!("target `{target_name}` sync failed: {e}"),
81                );
82                results.push(TargetSyncOutcome {
83                    target: target_name.clone(),
84                    items_synced: 0,
85                    items_removed: 0,
86                    errors: vec![e.to_string()],
87                });
88            }
89        }
90    }
91
92    results
93}
94
95/// Sync a single target directory from .mars/ canonical store.
96fn sync_one_target(
97    mars_dir: &Path,
98    target_root: &Path,
99    target_name: &str,
100    outcomes: &[ActionOutcome],
101    previous_managed_paths: &HashSet<PathBuf>,
102    force: bool,
103) -> Result<TargetSyncOutcome, MarsError> {
104    let mut items_synced = 0;
105    let mut items_removed = 0;
106    let mut errors = Vec::new();
107
108    // Ensure target directory exists
109    std::fs::create_dir_all(target_root)?;
110
111    // Track expected paths for orphan cleanup
112    let mut expected_paths: HashSet<PathBuf> = HashSet::new();
113
114    for outcome in outcomes {
115        let dest_rel = outcome.dest_path.as_path();
116
117        match &outcome.action {
118            ActionTaken::Removed => {
119                // Remove from target too
120                let target_path = target_root.join(dest_rel);
121                if target_path.exists() || target_path.symlink_metadata().is_ok() {
122                    if let Err(e) = fs_ops::safe_remove(&target_path) {
123                        errors.push(format!("failed to remove {}: {e}", dest_rel.display()));
124                    } else {
125                        items_removed += 1;
126                    }
127                }
128            }
129            ActionTaken::Skipped => {
130                // Item is unchanged in .mars/ — still expected in target
131                expected_paths.insert(dest_rel.to_path_buf());
132                // Ensure it exists in target (idempotent convergence).
133                // In --force mode, always refresh from .mars/ even if target exists.
134                let source = mars_dir.join(dest_rel);
135                let dest = target_root.join(dest_rel);
136                if source.exists() && (force || !dest.exists()) {
137                    match copy_item_to_target(&source, &dest) {
138                        Ok(()) => items_synced += 1,
139                        Err(e) => {
140                            errors.push(format!("failed to copy {}: {e}", dest_rel.display()))
141                        }
142                    }
143                }
144            }
145            _ => {
146                // Installed, Updated, Merged, Conflicted, Kept, Symlinked
147                // All of these mean content exists in .mars/ and should be copied to target
148                expected_paths.insert(dest_rel.to_path_buf());
149                let source = mars_dir.join(dest_rel);
150                let dest = target_root.join(dest_rel);
151                if source.exists() || source.symlink_metadata().is_ok() {
152                    match copy_item_to_target(&source, &dest) {
153                        Ok(()) => items_synced += 1,
154                        Err(e) => {
155                            errors.push(format!("failed to copy {}: {e}", dest_rel.display()))
156                        }
157                    }
158                }
159            }
160        }
161    }
162
163    // Orphan cleanup: scan target for items not in expected set
164    let orphan_removed = cleanup_orphans(
165        target_root,
166        &expected_paths,
167        previous_managed_paths,
168        &mut errors,
169    );
170    items_removed += orphan_removed;
171
172    Ok(TargetSyncOutcome {
173        target: target_name.to_string(),
174        items_synced,
175        items_removed,
176        errors,
177    })
178}
179
180/// Copy an item (file or directory) from .mars/ to a target directory.
181///
182/// Follows symlinks on the source side (D26 — targets get file copies, not symlinks).
183/// Uses atomic operations via the reconcile layer.
184fn copy_item_to_target(source: &Path, dest: &Path) -> Result<(), MarsError> {
185    // Ensure parent directories exist
186    if let Some(parent) = dest.parent() {
187        std::fs::create_dir_all(parent)?;
188    }
189
190    // Follow symlinks to determine if source is a file or directory
191    let metadata = std::fs::metadata(source)?;
192
193    if metadata.is_dir() {
194        fs_ops::atomic_copy_dir(source, dest)?;
195    } else if metadata.is_file() {
196        fs_ops::atomic_copy_file(source, dest)?;
197    }
198
199    Ok(())
200}
201
202/// Clean up orphaned items in a target directory.
203///
204/// Scans `agents/` and `skills/` subdirectories in the target. Removes
205/// entries only if they were previously managed by mars (present in old
206/// lock ownership) and are no longer expected in the current sync.
207/// Returns the number of items removed.
208fn cleanup_orphans(
209    target_root: &Path,
210    expected: &HashSet<PathBuf>,
211    previous_managed_paths: &HashSet<PathBuf>,
212    errors: &mut Vec<String>,
213) -> usize {
214    let mut removed = 0;
215
216    for subdir in ["agents", "skills"] {
217        let scan_dir = target_root.join(subdir);
218        if !scan_dir.exists() {
219            continue;
220        }
221
222        // Skip if it's a symlink (legacy link setup — don't touch)
223        if scan_dir.symlink_metadata().is_ok()
224            && scan_dir
225                .symlink_metadata()
226                .map(|m| m.file_type().is_symlink())
227                .unwrap_or(false)
228        {
229            continue;
230        }
231
232        let entries = match std::fs::read_dir(&scan_dir) {
233            Ok(e) => e,
234            Err(_) => continue,
235        };
236
237        for entry in entries.flatten() {
238            let file_name = entry.file_name();
239            let name_str = file_name.to_string_lossy();
240
241            // Skip hidden files (like .mars-tmp-*)
242            if name_str.starts_with('.') {
243                continue;
244            }
245
246            let rel_path = PathBuf::from(subdir).join(&file_name);
247            if previous_managed_paths.contains(&rel_path) && !expected.contains(&rel_path) {
248                let full_path = entry.path();
249                if let Err(e) = fs_ops::safe_remove(&full_path) {
250                    errors.push(format!(
251                        "failed to remove orphan {}: {e}",
252                        rel_path.display()
253                    ));
254                } else {
255                    removed += 1;
256                }
257            }
258        }
259    }
260
261    removed
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use crate::diagnostic::DiagnosticCollector;
268    use crate::sync::apply::{ActionOutcome, ActionTaken};
269    use crate::types::{DestPath, ItemName};
270    use tempfile::TempDir;
271
272    fn make_outcome(dest: &str, action: ActionTaken) -> ActionOutcome {
273        ActionOutcome {
274            item_id: crate::lock::ItemId {
275                kind: crate::lock::ItemKind::Agent,
276                name: ItemName::from("test"),
277            },
278            action,
279            dest_path: DestPath::from(dest),
280            source_name: "test-source".into(),
281            source_checksum: None,
282            installed_checksum: None,
283        }
284    }
285
286    fn managed_paths(paths: &[&str]) -> HashSet<PathBuf> {
287        paths
288            .iter()
289            .map(|p| PathBuf::from(*p))
290            .collect::<HashSet<PathBuf>>()
291    }
292
293    #[test]
294    fn sync_copies_installed_items_to_target() {
295        let dir = TempDir::new().unwrap();
296        let mars_dir = dir.path().join(".mars");
297        let target = dir.path().join(".agents");
298
299        // Set up .mars/ content
300        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
301        std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
302
303        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
304        let mut diag = DiagnosticCollector::new();
305
306        let results = sync_managed_targets(
307            dir.path(),
308            &mars_dir,
309            &[".agents".to_string()],
310            &outcomes,
311            &managed_paths(&[]),
312            false,
313            &mut diag,
314        );
315
316        assert_eq!(results.len(), 1);
317        assert_eq!(results[0].items_synced, 1);
318        assert!(results[0].errors.is_empty());
319        assert!(target.join("agents/coder.md").exists());
320        assert_eq!(
321            std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
322            "# Coder"
323        );
324    }
325
326    #[test]
327    fn sync_removes_items_from_target() {
328        let dir = TempDir::new().unwrap();
329        let mars_dir = dir.path().join(".mars");
330        let target = dir.path().join(".agents");
331
332        std::fs::create_dir_all(&mars_dir).unwrap();
333        std::fs::create_dir_all(target.join("agents")).unwrap();
334        std::fs::write(target.join("agents/old.md"), "# Old").unwrap();
335
336        let outcomes = vec![make_outcome("agents/old.md", ActionTaken::Removed)];
337        let mut diag = DiagnosticCollector::new();
338
339        let results = sync_managed_targets(
340            dir.path(),
341            &mars_dir,
342            &[".agents".to_string()],
343            &outcomes,
344            &managed_paths(&["agents/old.md"]),
345            false,
346            &mut diag,
347        );
348
349        assert_eq!(results[0].items_removed, 1);
350        assert!(!target.join("agents/old.md").exists());
351    }
352
353    #[test]
354    fn sync_cleans_up_previous_managed_orphans() {
355        let dir = TempDir::new().unwrap();
356        let mars_dir = dir.path().join(".mars");
357        let target = dir.path().join(".agents");
358
359        // Set up .mars/ with one agent
360        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
361        std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
362
363        // Set up target with an extra agent (orphan)
364        std::fs::create_dir_all(target.join("agents")).unwrap();
365        std::fs::write(target.join("agents/orphan.md"), "# Orphan").unwrap();
366
367        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
368        let mut diag = DiagnosticCollector::new();
369
370        let results = sync_managed_targets(
371            dir.path(),
372            &mars_dir,
373            &[".agents".to_string()],
374            &outcomes,
375            &managed_paths(&["agents/orphan.md"]),
376            false,
377            &mut diag,
378        );
379
380        assert!(target.join("agents/coder.md").exists());
381        assert!(!target.join("agents/orphan.md").exists());
382        assert_eq!(results[0].items_removed, 1);
383    }
384
385    #[test]
386    fn sync_preserves_unmanaged_files_in_target() {
387        let dir = TempDir::new().unwrap();
388        let mars_dir = dir.path().join(".mars");
389        let target = dir.path().join(".agents");
390
391        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
392        std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
393
394        std::fs::create_dir_all(target.join("agents")).unwrap();
395        std::fs::write(target.join("agents/custom.md"), "# User custom").unwrap();
396
397        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
398        let mut diag = DiagnosticCollector::new();
399
400        let results = sync_managed_targets(
401            dir.path(),
402            &mars_dir,
403            &[".agents".to_string()],
404            &outcomes,
405            &managed_paths(&[]),
406            false,
407            &mut diag,
408        );
409
410        assert!(target.join("agents/coder.md").exists());
411        assert!(target.join("agents/custom.md").exists());
412        assert_eq!(results[0].items_removed, 0);
413    }
414
415    #[test]
416    fn sync_multiple_targets() {
417        let dir = TempDir::new().unwrap();
418        let mars_dir = dir.path().join(".mars");
419
420        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
421        std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
422
423        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
424        let mut diag = DiagnosticCollector::new();
425
426        let results = sync_managed_targets(
427            dir.path(),
428            &mars_dir,
429            &[".agents".to_string(), ".claude".to_string()],
430            &outcomes,
431            &managed_paths(&[]),
432            false,
433            &mut diag,
434        );
435
436        assert_eq!(results.len(), 2);
437        assert!(dir.path().join(".agents/agents/coder.md").exists());
438        assert!(dir.path().join(".claude/agents/coder.md").exists());
439    }
440
441    #[test]
442    fn sync_follows_symlinks_in_mars_dir() {
443        let dir = TempDir::new().unwrap();
444        let mars_dir = dir.path().join(".mars");
445        let target = dir.path().join(".agents");
446
447        // Create a real file and symlink to it in .mars/
448        let real_dir = dir.path().join("local-agents");
449        std::fs::create_dir_all(&real_dir).unwrap();
450        std::fs::write(real_dir.join("local.md"), "# Local").unwrap();
451
452        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
453        #[cfg(unix)]
454        std::os::unix::fs::symlink(real_dir.join("local.md"), mars_dir.join("agents/local.md"))
455            .unwrap();
456
457        let outcomes = vec![make_outcome("agents/local.md", ActionTaken::Symlinked)];
458        let mut diag = DiagnosticCollector::new();
459
460        let results = sync_managed_targets(
461            dir.path(),
462            &mars_dir,
463            &[".agents".to_string()],
464            &outcomes,
465            &managed_paths(&[]),
466            false,
467            &mut diag,
468        );
469
470        assert_eq!(results[0].items_synced, 1);
471        let dest = target.join("agents/local.md");
472        assert!(dest.exists());
473        // Should be a regular file, not a symlink (D26)
474        assert!(
475            !dest.symlink_metadata().unwrap().file_type().is_symlink(),
476            "target should have a regular file copy, not a symlink"
477        );
478        assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Local");
479    }
480
481    #[test]
482    fn sync_skill_directory() {
483        let dir = TempDir::new().unwrap();
484        let mars_dir = dir.path().join(".mars");
485        let target = dir.path().join(".agents");
486
487        std::fs::create_dir_all(mars_dir.join("skills/planning")).unwrap();
488        std::fs::write(mars_dir.join("skills/planning/SKILL.md"), "# Planning").unwrap();
489
490        let mut outcome = make_outcome("skills/planning", ActionTaken::Installed);
491        outcome.item_id.kind = crate::lock::ItemKind::Skill;
492        let outcomes = vec![outcome];
493        let mut diag = DiagnosticCollector::new();
494
495        let results = sync_managed_targets(
496            dir.path(),
497            &mars_dir,
498            &[".agents".to_string()],
499            &outcomes,
500            &managed_paths(&[]),
501            false,
502            &mut diag,
503        );
504
505        assert_eq!(results[0].items_synced, 1);
506        assert!(target.join("skills/planning/SKILL.md").exists());
507    }
508
509    #[test]
510    fn sync_convergence_on_rerun() {
511        let dir = TempDir::new().unwrap();
512        let mars_dir = dir.path().join(".mars");
513        let target = dir.path().join(".agents");
514
515        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
516        std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
517
518        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
519        let mut diag = DiagnosticCollector::new();
520
521        // First run
522        sync_managed_targets(
523            dir.path(),
524            &mars_dir,
525            &[".agents".to_string()],
526            &outcomes,
527            &managed_paths(&[]),
528            false,
529            &mut diag,
530        );
531
532        // Second run with Skipped action — should converge (file already exists)
533        let outcomes2 = vec![make_outcome("agents/coder.md", ActionTaken::Skipped)];
534        let results = sync_managed_targets(
535            dir.path(),
536            &mars_dir,
537            &[".agents".to_string()],
538            &outcomes2,
539            &managed_paths(&["agents/coder.md"]),
540            false,
541            &mut diag,
542        );
543
544        assert!(target.join("agents/coder.md").exists());
545        // items_synced should be 0 since file already exists
546        assert_eq!(results[0].items_synced, 0);
547    }
548
549    #[test]
550    fn sync_force_refreshes_skipped_target_content() {
551        let dir = TempDir::new().unwrap();
552        let mars_dir = dir.path().join(".mars");
553        let target = dir.path().join(".agents");
554
555        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
556        std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
557
558        std::fs::create_dir_all(target.join("agents")).unwrap();
559        std::fs::write(target.join("agents/coder.md"), "# Tampered").unwrap();
560
561        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Skipped)];
562        let mut diag = DiagnosticCollector::new();
563        let results = sync_managed_targets(
564            dir.path(),
565            &mars_dir,
566            &[".agents".to_string()],
567            &outcomes,
568            &managed_paths(&["agents/coder.md"]),
569            true,
570            &mut diag,
571        );
572
573        assert_eq!(results[0].items_synced, 1);
574        assert_eq!(
575            std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
576            "# Canonical"
577        );
578    }
579}