use std::collections::HashSet;
use std::path::{Path, PathBuf};
use crate::diagnostic::DiagnosticCollector;
use crate::error::MarsError;
use crate::reconcile::fs_ops;
use crate::sync::apply::{ActionOutcome, ActionTaken};
#[derive(Debug, Clone)]
pub struct ManagedTarget {
pub path: String,
}
#[derive(Debug, Clone)]
pub struct TargetSyncOutcome {
pub target: String,
pub items_synced: usize,
pub items_removed: usize,
pub errors: Vec<String>,
}
pub fn sync_managed_targets(
project_root: &Path,
mars_dir: &Path,
targets: &[String],
outcomes: &[ActionOutcome],
previous_managed_paths: &HashSet<PathBuf>,
force: bool,
diag: &mut DiagnosticCollector,
) -> Vec<TargetSyncOutcome> {
let mut results = Vec::new();
for target_name in targets {
let target_root = project_root.join(target_name);
match sync_one_target(
mars_dir,
&target_root,
target_name,
outcomes,
previous_managed_paths,
force,
) {
Ok(outcome) => {
if !outcome.errors.is_empty() {
for err in &outcome.errors {
diag.warn(
"target-sync-error",
format!("target `{target_name}`: {err}"),
);
}
}
results.push(outcome);
}
Err(e) => {
diag.warn(
"target-sync-failed",
format!("target `{target_name}` sync failed: {e}"),
);
results.push(TargetSyncOutcome {
target: target_name.clone(),
items_synced: 0,
items_removed: 0,
errors: vec![e.to_string()],
});
}
}
}
results
}
fn sync_one_target(
mars_dir: &Path,
target_root: &Path,
target_name: &str,
outcomes: &[ActionOutcome],
previous_managed_paths: &HashSet<PathBuf>,
force: bool,
) -> Result<TargetSyncOutcome, MarsError> {
let mut items_synced = 0;
let mut items_removed = 0;
let mut errors = Vec::new();
std::fs::create_dir_all(target_root)?;
let mut expected_paths: HashSet<PathBuf> = HashSet::new();
for outcome in outcomes {
let dest_rel = outcome.dest_path.as_path();
match &outcome.action {
ActionTaken::Removed => {
let target_path = target_root.join(dest_rel);
if target_path.exists() || target_path.symlink_metadata().is_ok() {
if let Err(e) = fs_ops::safe_remove(&target_path) {
errors.push(format!("failed to remove {}: {e}", dest_rel.display()));
} else {
items_removed += 1;
}
}
}
ActionTaken::Skipped => {
expected_paths.insert(dest_rel.to_path_buf());
let source = mars_dir.join(dest_rel);
let dest = target_root.join(dest_rel);
if source.exists() && (force || !dest.exists()) {
match copy_item_to_target(&source, &dest) {
Ok(()) => items_synced += 1,
Err(e) => {
errors.push(format!("failed to copy {}: {e}", dest_rel.display()))
}
}
}
}
_ => {
expected_paths.insert(dest_rel.to_path_buf());
let source = mars_dir.join(dest_rel);
let dest = target_root.join(dest_rel);
if source.exists() || source.symlink_metadata().is_ok() {
match copy_item_to_target(&source, &dest) {
Ok(()) => items_synced += 1,
Err(e) => {
errors.push(format!("failed to copy {}: {e}", dest_rel.display()))
}
}
}
}
}
}
let orphan_removed = cleanup_orphans(
target_root,
&expected_paths,
previous_managed_paths,
&mut errors,
);
items_removed += orphan_removed;
Ok(TargetSyncOutcome {
target: target_name.to_string(),
items_synced,
items_removed,
errors,
})
}
fn copy_item_to_target(source: &Path, dest: &Path) -> Result<(), MarsError> {
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
let metadata = std::fs::metadata(source)?;
if metadata.is_dir() {
fs_ops::atomic_copy_dir(source, dest)?;
} else if metadata.is_file() {
fs_ops::atomic_copy_file(source, dest)?;
}
Ok(())
}
fn cleanup_orphans(
target_root: &Path,
expected: &HashSet<PathBuf>,
previous_managed_paths: &HashSet<PathBuf>,
errors: &mut Vec<String>,
) -> usize {
let mut removed = 0;
for subdir in ["agents", "skills"] {
let scan_dir = target_root.join(subdir);
if !scan_dir.exists() {
continue;
}
if scan_dir.symlink_metadata().is_ok()
&& scan_dir
.symlink_metadata()
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
continue;
}
let entries = match std::fs::read_dir(&scan_dir) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.flatten() {
let file_name = entry.file_name();
let name_str = file_name.to_string_lossy();
if name_str.starts_with('.') {
continue;
}
let rel_path = PathBuf::from(subdir).join(&file_name);
if previous_managed_paths.contains(&rel_path) && !expected.contains(&rel_path) {
let full_path = entry.path();
if let Err(e) = fs_ops::safe_remove(&full_path) {
errors.push(format!(
"failed to remove orphan {}: {e}",
rel_path.display()
));
} else {
removed += 1;
}
}
}
}
removed
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diagnostic::DiagnosticCollector;
use crate::sync::apply::{ActionOutcome, ActionTaken};
use crate::types::{DestPath, ItemName};
use tempfile::TempDir;
fn make_outcome(dest: &str, action: ActionTaken) -> ActionOutcome {
ActionOutcome {
item_id: crate::lock::ItemId {
kind: crate::lock::ItemKind::Agent,
name: ItemName::from("test"),
},
action,
dest_path: DestPath::from(dest),
source_name: "test-source".into(),
source_checksum: None,
installed_checksum: None,
}
}
fn managed_paths(paths: &[&str]) -> HashSet<PathBuf> {
paths
.iter()
.map(|p| PathBuf::from(*p))
.collect::<HashSet<PathBuf>>()
}
#[test]
fn sync_copies_installed_items_to_target() {
let dir = TempDir::new().unwrap();
let mars_dir = dir.path().join(".mars");
let target = dir.path().join(".agents");
std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
let mut diag = DiagnosticCollector::new();
let results = sync_managed_targets(
dir.path(),
&mars_dir,
&[".agents".to_string()],
&outcomes,
&managed_paths(&[]),
false,
&mut diag,
);
assert_eq!(results.len(), 1);
assert_eq!(results[0].items_synced, 1);
assert!(results[0].errors.is_empty());
assert!(target.join("agents/coder.md").exists());
assert_eq!(
std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
"# Coder"
);
}
#[test]
fn sync_removes_items_from_target() {
let dir = TempDir::new().unwrap();
let mars_dir = dir.path().join(".mars");
let target = dir.path().join(".agents");
std::fs::create_dir_all(&mars_dir).unwrap();
std::fs::create_dir_all(target.join("agents")).unwrap();
std::fs::write(target.join("agents/old.md"), "# Old").unwrap();
let outcomes = vec![make_outcome("agents/old.md", ActionTaken::Removed)];
let mut diag = DiagnosticCollector::new();
let results = sync_managed_targets(
dir.path(),
&mars_dir,
&[".agents".to_string()],
&outcomes,
&managed_paths(&["agents/old.md"]),
false,
&mut diag,
);
assert_eq!(results[0].items_removed, 1);
assert!(!target.join("agents/old.md").exists());
}
#[test]
fn sync_cleans_up_previous_managed_orphans() {
let dir = TempDir::new().unwrap();
let mars_dir = dir.path().join(".mars");
let target = dir.path().join(".agents");
std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
std::fs::create_dir_all(target.join("agents")).unwrap();
std::fs::write(target.join("agents/orphan.md"), "# Orphan").unwrap();
let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
let mut diag = DiagnosticCollector::new();
let results = sync_managed_targets(
dir.path(),
&mars_dir,
&[".agents".to_string()],
&outcomes,
&managed_paths(&["agents/orphan.md"]),
false,
&mut diag,
);
assert!(target.join("agents/coder.md").exists());
assert!(!target.join("agents/orphan.md").exists());
assert_eq!(results[0].items_removed, 1);
}
#[test]
fn sync_preserves_unmanaged_files_in_target() {
let dir = TempDir::new().unwrap();
let mars_dir = dir.path().join(".mars");
let target = dir.path().join(".agents");
std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
std::fs::create_dir_all(target.join("agents")).unwrap();
std::fs::write(target.join("agents/custom.md"), "# User custom").unwrap();
let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
let mut diag = DiagnosticCollector::new();
let results = sync_managed_targets(
dir.path(),
&mars_dir,
&[".agents".to_string()],
&outcomes,
&managed_paths(&[]),
false,
&mut diag,
);
assert!(target.join("agents/coder.md").exists());
assert!(target.join("agents/custom.md").exists());
assert_eq!(results[0].items_removed, 0);
}
#[test]
fn sync_multiple_targets() {
let dir = TempDir::new().unwrap();
let mars_dir = dir.path().join(".mars");
std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
let mut diag = DiagnosticCollector::new();
let results = sync_managed_targets(
dir.path(),
&mars_dir,
&[".agents".to_string(), ".claude".to_string()],
&outcomes,
&managed_paths(&[]),
false,
&mut diag,
);
assert_eq!(results.len(), 2);
assert!(dir.path().join(".agents/agents/coder.md").exists());
assert!(dir.path().join(".claude/agents/coder.md").exists());
}
#[test]
fn sync_follows_symlinks_in_mars_dir() {
let dir = TempDir::new().unwrap();
let mars_dir = dir.path().join(".mars");
let target = dir.path().join(".agents");
let real_dir = dir.path().join("local-agents");
std::fs::create_dir_all(&real_dir).unwrap();
std::fs::write(real_dir.join("local.md"), "# Local").unwrap();
std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(real_dir.join("local.md"), mars_dir.join("agents/local.md"))
.unwrap();
let outcomes = vec![make_outcome("agents/local.md", ActionTaken::Symlinked)];
let mut diag = DiagnosticCollector::new();
let results = sync_managed_targets(
dir.path(),
&mars_dir,
&[".agents".to_string()],
&outcomes,
&managed_paths(&[]),
false,
&mut diag,
);
assert_eq!(results[0].items_synced, 1);
let dest = target.join("agents/local.md");
assert!(dest.exists());
assert!(
!dest.symlink_metadata().unwrap().file_type().is_symlink(),
"target should have a regular file copy, not a symlink"
);
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Local");
}
#[test]
fn sync_skill_directory() {
let dir = TempDir::new().unwrap();
let mars_dir = dir.path().join(".mars");
let target = dir.path().join(".agents");
std::fs::create_dir_all(mars_dir.join("skills/planning")).unwrap();
std::fs::write(mars_dir.join("skills/planning/SKILL.md"), "# Planning").unwrap();
let mut outcome = make_outcome("skills/planning", ActionTaken::Installed);
outcome.item_id.kind = crate::lock::ItemKind::Skill;
let outcomes = vec![outcome];
let mut diag = DiagnosticCollector::new();
let results = sync_managed_targets(
dir.path(),
&mars_dir,
&[".agents".to_string()],
&outcomes,
&managed_paths(&[]),
false,
&mut diag,
);
assert_eq!(results[0].items_synced, 1);
assert!(target.join("skills/planning/SKILL.md").exists());
}
#[test]
fn sync_convergence_on_rerun() {
let dir = TempDir::new().unwrap();
let mars_dir = dir.path().join(".mars");
let target = dir.path().join(".agents");
std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
let mut diag = DiagnosticCollector::new();
sync_managed_targets(
dir.path(),
&mars_dir,
&[".agents".to_string()],
&outcomes,
&managed_paths(&[]),
false,
&mut diag,
);
let outcomes2 = vec![make_outcome("agents/coder.md", ActionTaken::Skipped)];
let results = sync_managed_targets(
dir.path(),
&mars_dir,
&[".agents".to_string()],
&outcomes2,
&managed_paths(&["agents/coder.md"]),
false,
&mut diag,
);
assert!(target.join("agents/coder.md").exists());
assert_eq!(results[0].items_synced, 0);
}
#[test]
fn sync_force_refreshes_skipped_target_content() {
let dir = TempDir::new().unwrap();
let mars_dir = dir.path().join(".mars");
let target = dir.path().join(".agents");
std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
std::fs::create_dir_all(target.join("agents")).unwrap();
std::fs::write(target.join("agents/coder.md"), "# Tampered").unwrap();
let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Skipped)];
let mut diag = DiagnosticCollector::new();
let results = sync_managed_targets(
dir.path(),
&mars_dir,
&[".agents".to_string()],
&outcomes,
&managed_paths(&["agents/coder.md"]),
true,
&mut diag,
);
assert_eq!(results[0].items_synced, 1);
assert_eq!(
std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
"# Canonical"
);
}
}