use std::path::Path;
use crate::error::MarsError;
use crate::hash;
use crate::lock::{LockFile, LockedItem};
use crate::sync::target::{TargetItem, TargetState};
use crate::types::ContentHash;
#[derive(Debug, Clone)]
pub struct SyncDiff {
pub items: Vec<DiffEntry>,
}
#[derive(Debug, Clone)]
pub enum DiffEntry {
Add { target: TargetItem },
Update {
target: TargetItem,
locked: LockedItem,
},
Unchanged {
target: TargetItem,
locked: LockedItem,
},
Conflict {
target: TargetItem,
locked: LockedItem,
local_hash: ContentHash,
},
Orphan { locked: LockedItem },
LocalModified {
target: TargetItem,
locked: LockedItem,
local_hash: ContentHash,
},
}
pub fn compute(
root: &Path,
lock: &LockFile,
target: &TargetState,
force: bool,
) -> Result<SyncDiff, MarsError> {
let mut items = Vec::new();
for (_dest_key, target_item) in &target.items {
if let Some(locked_item) = lock.items.get(&target_item.dest_path) {
let source_changed = target_item.source_hash != locked_item.source_checksum;
let expected_disk_checksum = if force {
&locked_item.source_checksum
} else {
&locked_item.installed_checksum
};
let disk_path = root.join(&target_item.dest_path);
let local_changed = if disk_path.exists() {
let disk_hash = hash::compute_hash(&disk_path, target_item.id.kind)?;
let disk_hash = ContentHash::from(disk_hash);
if disk_hash != *expected_disk_checksum {
Some(disk_hash)
} else {
None
}
} else {
None
};
match (source_changed, &local_changed) {
(false, None) => {
if disk_path.exists() {
items.push(DiffEntry::Unchanged {
target: target_item.clone(),
locked: locked_item.clone(),
});
} else {
items.push(DiffEntry::Add {
target: target_item.clone(),
});
}
}
(true, None) => {
items.push(DiffEntry::Update {
target: target_item.clone(),
locked: locked_item.clone(),
});
}
(false, Some(local_hash)) => {
items.push(DiffEntry::LocalModified {
target: target_item.clone(),
locked: locked_item.clone(),
local_hash: local_hash.clone(),
});
}
(true, Some(local_hash)) => {
items.push(DiffEntry::Conflict {
target: target_item.clone(),
locked: locked_item.clone(),
local_hash: local_hash.clone(),
});
}
}
} else {
items.push(DiffEntry::Add {
target: target_item.clone(),
});
}
}
for (dest_path, locked_item) in &lock.items {
if !target.items.contains_key(dest_path) {
items.push(DiffEntry::Orphan {
locked: locked_item.clone(),
});
}
}
Ok(SyncDiff { items })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hash;
use crate::lock::{ItemId, ItemKind, LockedItem};
use crate::types::{ItemName, SourceName};
use indexmap::IndexMap;
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
fn make_target_item(
name: &str,
kind: ItemKind,
source_hash: &str,
source_path: PathBuf,
) -> TargetItem {
let dest_path = match kind {
ItemKind::Agent => PathBuf::from("agents").join(format!("{name}.md")),
ItemKind::Skill => PathBuf::from("skills").join(name),
};
TargetItem {
id: ItemId {
kind,
name: ItemName::from(name),
},
source_name: SourceName::from("test-source"),
source_id: crate::types::SourceId::Path {
canonical: source_path.clone(),
},
source_path,
dest_path: dest_path.into(),
source_hash: ContentHash::from(source_hash),
is_flat_skill: false,
rewritten_content: None,
}
}
fn make_locked_item(
name: &str,
kind: ItemKind,
source_checksum: &str,
installed_checksum: &str,
) -> LockedItem {
let dest_path = match kind {
ItemKind::Agent => format!("agents/{name}.md"),
ItemKind::Skill => format!("skills/{name}"),
};
LockedItem {
source: SourceName::from("test-source"),
kind,
version: None,
source_checksum: ContentHash::from(source_checksum),
installed_checksum: ContentHash::from(installed_checksum),
dest_path: dest_path.into(),
}
}
#[test]
fn new_item_produces_add() {
let root = TempDir::new().unwrap();
let source_dir = TempDir::new().unwrap();
let source_path = source_dir.path().join("agents/coder.md");
fs::create_dir_all(source_dir.path().join("agents")).unwrap();
fs::write(&source_path, "# new agent").unwrap();
let hash = hash::hash_bytes(b"# new agent");
let target_item = make_target_item("coder", ItemKind::Agent, &hash, source_path);
let mut target_items = IndexMap::new();
target_items.insert("agents/coder.md".into(), target_item);
let target = TargetState {
items: target_items,
};
let lock = LockFile::empty();
let diff = compute(root.path(), &lock, &target, false).unwrap();
assert_eq!(diff.items.len(), 1);
assert!(matches!(&diff.items[0], DiffEntry::Add { .. }));
}
#[test]
fn unchanged_item_produces_unchanged() {
let root = TempDir::new().unwrap();
let content = b"# existing agent";
let hash = hash::hash_bytes(content);
let agents_dir = root.path().join("agents");
fs::create_dir_all(&agents_dir).unwrap();
fs::write(agents_dir.join("coder.md"), content).unwrap();
let source_path = PathBuf::from("/tmp/source/agents/coder.md");
let target_item = make_target_item("coder", ItemKind::Agent, &hash, source_path);
let mut target_items = IndexMap::new();
target_items.insert("agents/coder.md".into(), target_item);
let target = TargetState {
items: target_items,
};
let locked_item = make_locked_item("coder", ItemKind::Agent, &hash, &hash);
let mut lock_items = IndexMap::new();
lock_items.insert("agents/coder.md".into(), locked_item);
let lock = LockFile {
version: 1,
dependencies: IndexMap::new(),
items: lock_items,
};
let diff = compute(root.path(), &lock, &target, false).unwrap();
assert_eq!(diff.items.len(), 1);
assert!(matches!(&diff.items[0], DiffEntry::Unchanged { .. }));
}
#[test]
fn source_changed_local_unchanged_produces_update() {
let root = TempDir::new().unwrap();
let old_content = b"# old version";
let old_hash = hash::hash_bytes(old_content);
let new_hash = hash::hash_bytes(b"# new version");
let agents_dir = root.path().join("agents");
fs::create_dir_all(&agents_dir).unwrap();
fs::write(agents_dir.join("coder.md"), old_content).unwrap();
let source_path = PathBuf::from("/tmp/source/agents/coder.md");
let target_item = make_target_item("coder", ItemKind::Agent, &new_hash, source_path);
let mut target_items = IndexMap::new();
target_items.insert("agents/coder.md".into(), target_item);
let target = TargetState {
items: target_items,
};
let locked_item = make_locked_item("coder", ItemKind::Agent, &old_hash, &old_hash);
let mut lock_items = IndexMap::new();
lock_items.insert("agents/coder.md".into(), locked_item);
let lock = LockFile {
version: 1,
dependencies: IndexMap::new(),
items: lock_items,
};
let diff = compute(root.path(), &lock, &target, false).unwrap();
assert_eq!(diff.items.len(), 1);
assert!(matches!(&diff.items[0], DiffEntry::Update { .. }));
}
#[test]
fn local_changed_source_unchanged_produces_local_modified() {
let root = TempDir::new().unwrap();
let original_content = b"# original";
let original_hash = hash::hash_bytes(original_content);
let local_content = b"# locally modified";
let agents_dir = root.path().join("agents");
fs::create_dir_all(&agents_dir).unwrap();
fs::write(agents_dir.join("coder.md"), local_content).unwrap();
let source_path = PathBuf::from("/tmp/source/agents/coder.md");
let target_item = make_target_item("coder", ItemKind::Agent, &original_hash, source_path);
let mut target_items = IndexMap::new();
target_items.insert("agents/coder.md".into(), target_item);
let target = TargetState {
items: target_items,
};
let locked_item =
make_locked_item("coder", ItemKind::Agent, &original_hash, &original_hash);
let mut lock_items = IndexMap::new();
lock_items.insert("agents/coder.md".into(), locked_item);
let lock = LockFile {
version: 1,
dependencies: IndexMap::new(),
items: lock_items,
};
let diff = compute(root.path(), &lock, &target, false).unwrap();
assert_eq!(diff.items.len(), 1);
assert!(matches!(&diff.items[0], DiffEntry::LocalModified { .. }));
}
#[test]
fn both_changed_produces_conflict() {
let root = TempDir::new().unwrap();
let original_hash = hash::hash_bytes(b"# original");
let new_source_hash = hash::hash_bytes(b"# new upstream");
let local_content = b"# locally modified";
let agents_dir = root.path().join("agents");
fs::create_dir_all(&agents_dir).unwrap();
fs::write(agents_dir.join("coder.md"), local_content).unwrap();
let source_path = PathBuf::from("/tmp/source/agents/coder.md");
let target_item = make_target_item("coder", ItemKind::Agent, &new_source_hash, source_path);
let mut target_items = IndexMap::new();
target_items.insert("agents/coder.md".into(), target_item);
let target = TargetState {
items: target_items,
};
let locked_item =
make_locked_item("coder", ItemKind::Agent, &original_hash, &original_hash);
let mut lock_items = IndexMap::new();
lock_items.insert("agents/coder.md".into(), locked_item);
let lock = LockFile {
version: 1,
dependencies: IndexMap::new(),
items: lock_items,
};
let diff = compute(root.path(), &lock, &target, false).unwrap();
assert_eq!(diff.items.len(), 1);
assert!(matches!(&diff.items[0], DiffEntry::Conflict { .. }));
}
#[test]
fn orphan_detected() {
let root = TempDir::new().unwrap();
let target = TargetState {
items: IndexMap::new(),
};
let locked_item =
make_locked_item("old-agent", ItemKind::Agent, "sha256:aaa", "sha256:aaa");
let mut lock_items = IndexMap::new();
lock_items.insert("agents/old-agent.md".into(), locked_item);
let lock = LockFile {
version: 1,
dependencies: IndexMap::new(),
items: lock_items,
};
let diff = compute(root.path(), &lock, &target, false).unwrap();
assert_eq!(diff.items.len(), 1);
assert!(matches!(&diff.items[0], DiffEntry::Orphan { .. }));
}
#[test]
fn dual_checksum_prevents_false_conflict() {
let root = TempDir::new().unwrap();
let source_hash = hash::hash_bytes(b"# original source");
let installed_content = b"# rewritten by mars";
let installed_hash = hash::hash_bytes(installed_content);
let agents_dir = root.path().join("agents");
fs::create_dir_all(&agents_dir).unwrap();
fs::write(agents_dir.join("coder.md"), installed_content).unwrap();
let source_path = PathBuf::from("/tmp/source/agents/coder.md");
let target_item = make_target_item("coder", ItemKind::Agent, &source_hash, source_path);
let mut target_items = IndexMap::new();
target_items.insert("agents/coder.md".into(), target_item);
let target = TargetState {
items: target_items,
};
let locked_item = make_locked_item("coder", ItemKind::Agent, &source_hash, &installed_hash);
let mut lock_items = IndexMap::new();
lock_items.insert("agents/coder.md".into(), locked_item);
let lock = LockFile {
version: 1,
dependencies: IndexMap::new(),
items: lock_items,
};
let diff = compute(root.path(), &lock, &target, false).unwrap();
assert_eq!(diff.items.len(), 1);
assert!(
matches!(&diff.items[0], DiffEntry::Unchanged { .. }),
"expected Unchanged, got {:?}",
diff.items[0]
);
}
#[test]
fn mixed_diff_entries() {
let root = TempDir::new().unwrap();
let agents_dir = root.path().join("agents");
fs::create_dir_all(&agents_dir).unwrap();
let hash_a = hash::hash_bytes(b"# unchanged");
let hash_b_old = hash::hash_bytes(b"# old version");
let hash_b_new = hash::hash_bytes(b"# new version");
fs::write(agents_dir.join("stable.md"), b"# unchanged").unwrap();
fs::write(agents_dir.join("updating.md"), b"# old version").unwrap();
let source_path_a = PathBuf::from("/tmp/source/agents/stable.md");
let source_path_b = PathBuf::from("/tmp/source/agents/updating.md");
let source_path_c = PathBuf::from("/tmp/source/agents/new.md");
let mut target_items = IndexMap::new();
target_items.insert(
"agents/stable.md".into(),
make_target_item("stable", ItemKind::Agent, &hash_a, source_path_a),
);
target_items.insert(
"agents/updating.md".into(),
make_target_item("updating", ItemKind::Agent, &hash_b_new, source_path_b),
);
target_items.insert(
"agents/new.md".into(),
make_target_item(
"new",
ItemKind::Agent,
&hash::hash_bytes(b"# brand new"),
source_path_c,
),
);
let target = TargetState {
items: target_items,
};
let mut lock_items = IndexMap::new();
lock_items.insert(
"agents/stable.md".into(),
make_locked_item("stable", ItemKind::Agent, &hash_a, &hash_a),
);
lock_items.insert(
"agents/updating.md".into(),
make_locked_item("updating", ItemKind::Agent, &hash_b_old, &hash_b_old),
);
lock_items.insert(
"agents/orphan.md".into(),
make_locked_item("orphan", ItemKind::Agent, "sha256:xxx", "sha256:xxx"),
);
let lock = LockFile {
version: 1,
dependencies: IndexMap::new(),
items: lock_items,
};
let diff = compute(root.path(), &lock, &target, false).unwrap();
assert_eq!(diff.items.len(), 4);
let unchanged_count = diff
.items
.iter()
.filter(|d| matches!(d, DiffEntry::Unchanged { .. }))
.count();
let update_count = diff
.items
.iter()
.filter(|d| matches!(d, DiffEntry::Update { .. }))
.count();
let add_count = diff
.items
.iter()
.filter(|d| matches!(d, DiffEntry::Add { .. }))
.count();
let orphan_count = diff
.items
.iter()
.filter(|d| matches!(d, DiffEntry::Orphan { .. }))
.count();
assert_eq!(unchanged_count, 1);
assert_eq!(update_count, 1);
assert_eq!(add_count, 1);
assert_eq!(orphan_count, 1);
}
#[test]
fn force_uses_source_checksum_for_local_change_detection() {
let root = TempDir::new().unwrap();
let upstream_content = b"# upstream";
let conflicted_content = b"<<<<<<< local\n# local\n=======\n# upstream\n>>>>>>> upstream\n";
let source_hash = hash::hash_bytes(upstream_content);
let installed_hash = hash::hash_bytes(conflicted_content);
let agents_dir = root.path().join("agents");
fs::create_dir_all(&agents_dir).unwrap();
fs::write(agents_dir.join("coder.md"), conflicted_content).unwrap();
let mut target_items = IndexMap::new();
target_items.insert(
"agents/coder.md".into(),
make_target_item(
"coder",
ItemKind::Agent,
&source_hash,
PathBuf::from("/tmp/source/agents/coder.md"),
),
);
let target = TargetState {
items: target_items,
};
let mut lock_items = IndexMap::new();
lock_items.insert(
"agents/coder.md".into(),
LockedItem {
source: "test-source".into(),
kind: ItemKind::Agent,
version: None,
source_checksum: source_hash.clone().into(),
installed_checksum: installed_hash.into(),
dest_path: "agents/coder.md".into(),
},
);
let lock = LockFile {
version: 1,
dependencies: IndexMap::new(),
items: lock_items,
};
let normal = compute(root.path(), &lock, &target, false).unwrap();
assert!(matches!(&normal.items[0], DiffEntry::Unchanged { .. }));
let forced = compute(root.path(), &lock, &target, true).unwrap();
assert!(matches!(&forced.items[0], DiffEntry::LocalModified { .. }));
}
}