1use std::path::Path;
2
3use crate::error::MarsError;
4use crate::hash;
5use crate::lock::{LockFile, LockedItem};
6use crate::sync::target::{TargetItem, TargetState};
7use crate::types::ContentHash;
8
9#[derive(Debug, Clone)]
11pub struct SyncDiff {
12 pub items: Vec<DiffEntry>,
13}
14
15#[derive(Debug, Clone)]
17pub enum DiffEntry {
18 Add { target: TargetItem },
20 Update {
22 target: TargetItem,
23 locked: LockedItem,
24 },
25 Unchanged {
27 target: TargetItem,
28 locked: LockedItem,
29 },
30 Conflict {
32 target: TargetItem,
33 locked: LockedItem,
34 local_hash: ContentHash,
35 },
36 Orphan { locked: LockedItem },
38 LocalModified {
40 target: TargetItem,
41 locked: LockedItem,
42 local_hash: ContentHash,
43 },
44}
45
46pub fn compute(
54 root: &Path,
55 lock: &LockFile,
56 target: &TargetState,
57 force: bool,
58) -> Result<SyncDiff, MarsError> {
59 let mut items = Vec::new();
60
61 for (_dest_key, target_item) in &target.items {
63 if let Some(locked_item) = lock.items.get(&target_item.dest_path) {
64 let source_changed = target_item.source_hash != locked_item.source_checksum
66 || rewritten_installed_checksum(target_item)
67 .is_some_and(|checksum| checksum != locked_item.installed_checksum);
68
69 let expected_disk_checksum = if force {
73 &locked_item.source_checksum
74 } else {
75 &locked_item.installed_checksum
76 };
77
78 let disk_path = root.join(&target_item.dest_path);
79 let local_changed = if disk_path.exists() {
80 let disk_hash = hash::compute_hash(&disk_path, target_item.id.kind)?;
81 let disk_hash = ContentHash::from(disk_hash);
82 if disk_hash != *expected_disk_checksum {
83 Some(disk_hash)
84 } else {
85 None
86 }
87 } else {
88 None
91 };
92
93 match (source_changed, &local_changed) {
94 (false, None) => {
95 if disk_path.exists() {
97 items.push(DiffEntry::Unchanged {
98 target: target_item.clone(),
99 locked: locked_item.clone(),
100 });
101 } else {
102 items.push(DiffEntry::Add {
104 target: target_item.clone(),
105 });
106 }
107 }
108 (true, None) => {
109 items.push(DiffEntry::Update {
111 target: target_item.clone(),
112 locked: locked_item.clone(),
113 });
114 }
115 (false, Some(local_hash)) => {
116 items.push(DiffEntry::LocalModified {
118 target: target_item.clone(),
119 locked: locked_item.clone(),
120 local_hash: local_hash.clone(),
121 });
122 }
123 (true, Some(local_hash)) => {
124 items.push(DiffEntry::Conflict {
126 target: target_item.clone(),
127 locked: locked_item.clone(),
128 local_hash: local_hash.clone(),
129 });
130 }
131 }
132 } else {
133 items.push(DiffEntry::Add {
135 target: target_item.clone(),
136 });
137 }
138 }
139
140 for (dest_path, locked_item) in &lock.items {
142 if !target.items.contains_key(dest_path) {
143 items.push(DiffEntry::Orphan {
144 locked: locked_item.clone(),
145 });
146 }
147 }
148
149 Ok(SyncDiff { items })
150}
151
152fn rewritten_installed_checksum(target_item: &TargetItem) -> Option<ContentHash> {
153 target_item
154 .rewritten_content
155 .as_ref()
156 .map(|content| ContentHash::from(hash::hash_bytes(content.as_bytes())))
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162 use crate::hash;
163 use crate::lock::{ItemId, ItemKind, LockedItem};
164 use crate::types::{ItemName, SourceName};
165 use indexmap::IndexMap;
166 use std::fs;
167 use std::path::PathBuf;
168 use tempfile::TempDir;
169
170 fn make_target_item(
172 name: &str,
173 kind: ItemKind,
174 source_hash: &str,
175 source_path: PathBuf,
176 ) -> TargetItem {
177 let dest_path = match kind {
178 ItemKind::Agent => PathBuf::from("agents").join(format!("{name}.md")),
179 ItemKind::Skill => PathBuf::from("skills").join(name),
180 };
181 TargetItem {
182 id: ItemId {
183 kind,
184 name: ItemName::from(name),
185 },
186 source_name: SourceName::from("test-source"),
187 origin: crate::types::SourceOrigin::Dependency(SourceName::from("test-source")),
188 source_id: crate::types::SourceId::Path {
189 canonical: source_path.clone(),
190 subpath: None,
191 },
192 source_path,
193 dest_path: dest_path.into(),
194 source_hash: ContentHash::from(source_hash),
195 is_flat_skill: false,
196 rewritten_content: None,
197 }
198 }
199
200 fn make_locked_item(
201 name: &str,
202 kind: ItemKind,
203 source_checksum: &str,
204 installed_checksum: &str,
205 ) -> LockedItem {
206 let dest_path = match kind {
207 ItemKind::Agent => format!("agents/{name}.md"),
208 ItemKind::Skill => format!("skills/{name}"),
209 };
210 LockedItem {
211 source: SourceName::from("test-source"),
212 kind,
213 version: None,
214 source_checksum: ContentHash::from(source_checksum),
215 installed_checksum: ContentHash::from(installed_checksum),
216 dest_path: dest_path.into(),
217 }
218 }
219
220 #[test]
221 fn new_item_produces_add() {
222 let root = TempDir::new().unwrap();
223 let source_dir = TempDir::new().unwrap();
224 let source_path = source_dir.path().join("agents/coder.md");
225 fs::create_dir_all(source_dir.path().join("agents")).unwrap();
226 fs::write(&source_path, "# new agent").unwrap();
227
228 let hash = hash::hash_bytes(b"# new agent");
229
230 let target_item = make_target_item("coder", ItemKind::Agent, &hash, source_path);
231 let mut target_items = IndexMap::new();
232 target_items.insert("agents/coder.md".into(), target_item);
233 let target = TargetState {
234 items: target_items,
235 };
236
237 let lock = LockFile::empty();
238 let diff = compute(root.path(), &lock, &target, false).unwrap();
239
240 assert_eq!(diff.items.len(), 1);
241 assert!(matches!(&diff.items[0], DiffEntry::Add { .. }));
242 }
243
244 #[test]
245 fn unchanged_item_produces_unchanged() {
246 let root = TempDir::new().unwrap();
247 let content = b"# existing agent";
248 let hash = hash::hash_bytes(content);
249
250 let agents_dir = root.path().join("agents");
252 fs::create_dir_all(&agents_dir).unwrap();
253 fs::write(agents_dir.join("coder.md"), content).unwrap();
254
255 let source_path = PathBuf::from("/tmp/source/agents/coder.md");
256
257 let target_item = make_target_item("coder", ItemKind::Agent, &hash, source_path);
258 let mut target_items = IndexMap::new();
259 target_items.insert("agents/coder.md".into(), target_item);
260 let target = TargetState {
261 items: target_items,
262 };
263
264 let locked_item = make_locked_item("coder", ItemKind::Agent, &hash, &hash);
265 let mut lock_items = IndexMap::new();
266 lock_items.insert("agents/coder.md".into(), locked_item);
267 let lock = LockFile {
268 version: 1,
269 dependencies: IndexMap::new(),
270 items: lock_items,
271 };
272
273 let diff = compute(root.path(), &lock, &target, false).unwrap();
274 assert_eq!(diff.items.len(), 1);
275 assert!(matches!(&diff.items[0], DiffEntry::Unchanged { .. }));
276 }
277
278 #[test]
279 fn source_changed_local_unchanged_produces_update() {
280 let root = TempDir::new().unwrap();
281 let old_content = b"# old version";
282 let old_hash = hash::hash_bytes(old_content);
283 let new_hash = hash::hash_bytes(b"# new version");
284
285 let agents_dir = root.path().join("agents");
287 fs::create_dir_all(&agents_dir).unwrap();
288 fs::write(agents_dir.join("coder.md"), old_content).unwrap();
289
290 let source_path = PathBuf::from("/tmp/source/agents/coder.md");
291
292 let target_item = make_target_item("coder", ItemKind::Agent, &new_hash, source_path);
294 let mut target_items = IndexMap::new();
295 target_items.insert("agents/coder.md".into(), target_item);
296 let target = TargetState {
297 items: target_items,
298 };
299
300 let locked_item = make_locked_item("coder", ItemKind::Agent, &old_hash, &old_hash);
302 let mut lock_items = IndexMap::new();
303 lock_items.insert("agents/coder.md".into(), locked_item);
304 let lock = LockFile {
305 version: 1,
306 dependencies: IndexMap::new(),
307 items: lock_items,
308 };
309
310 let diff = compute(root.path(), &lock, &target, false).unwrap();
311 assert_eq!(diff.items.len(), 1);
312 assert!(matches!(&diff.items[0], DiffEntry::Update { .. }));
313 }
314
315 #[test]
316 fn local_changed_source_unchanged_produces_local_modified() {
317 let root = TempDir::new().unwrap();
318 let original_content = b"# original";
319 let original_hash = hash::hash_bytes(original_content);
320 let local_content = b"# locally modified";
321
322 let agents_dir = root.path().join("agents");
324 fs::create_dir_all(&agents_dir).unwrap();
325 fs::write(agents_dir.join("coder.md"), local_content).unwrap();
326
327 let source_path = PathBuf::from("/tmp/source/agents/coder.md");
328
329 let target_item = make_target_item("coder", ItemKind::Agent, &original_hash, source_path);
331 let mut target_items = IndexMap::new();
332 target_items.insert("agents/coder.md".into(), target_item);
333 let target = TargetState {
334 items: target_items,
335 };
336
337 let locked_item =
339 make_locked_item("coder", ItemKind::Agent, &original_hash, &original_hash);
340 let mut lock_items = IndexMap::new();
341 lock_items.insert("agents/coder.md".into(), locked_item);
342 let lock = LockFile {
343 version: 1,
344 dependencies: IndexMap::new(),
345 items: lock_items,
346 };
347
348 let diff = compute(root.path(), &lock, &target, false).unwrap();
349 assert_eq!(diff.items.len(), 1);
350 assert!(matches!(&diff.items[0], DiffEntry::LocalModified { .. }));
351 }
352
353 #[test]
354 fn both_changed_produces_conflict() {
355 let root = TempDir::new().unwrap();
356 let original_hash = hash::hash_bytes(b"# original");
357 let new_source_hash = hash::hash_bytes(b"# new upstream");
358 let local_content = b"# locally modified";
359
360 let agents_dir = root.path().join("agents");
362 fs::create_dir_all(&agents_dir).unwrap();
363 fs::write(agents_dir.join("coder.md"), local_content).unwrap();
364
365 let source_path = PathBuf::from("/tmp/source/agents/coder.md");
366
367 let target_item = make_target_item("coder", ItemKind::Agent, &new_source_hash, source_path);
369 let mut target_items = IndexMap::new();
370 target_items.insert("agents/coder.md".into(), target_item);
371 let target = TargetState {
372 items: target_items,
373 };
374
375 let locked_item =
377 make_locked_item("coder", ItemKind::Agent, &original_hash, &original_hash);
378 let mut lock_items = IndexMap::new();
379 lock_items.insert("agents/coder.md".into(), locked_item);
380 let lock = LockFile {
381 version: 1,
382 dependencies: IndexMap::new(),
383 items: lock_items,
384 };
385
386 let diff = compute(root.path(), &lock, &target, false).unwrap();
387 assert_eq!(diff.items.len(), 1);
388 assert!(matches!(&diff.items[0], DiffEntry::Conflict { .. }));
389 }
390
391 #[test]
392 fn orphan_detected() {
393 let root = TempDir::new().unwrap();
394
395 let target = TargetState {
397 items: IndexMap::new(),
398 };
399
400 let locked_item =
402 make_locked_item("old-agent", ItemKind::Agent, "sha256:aaa", "sha256:aaa");
403 let mut lock_items = IndexMap::new();
404 lock_items.insert("agents/old-agent.md".into(), locked_item);
405 let lock = LockFile {
406 version: 1,
407 dependencies: IndexMap::new(),
408 items: lock_items,
409 };
410
411 let diff = compute(root.path(), &lock, &target, false).unwrap();
412 assert_eq!(diff.items.len(), 1);
413 assert!(matches!(&diff.items[0], DiffEntry::Orphan { .. }));
414 }
415
416 #[test]
417 fn dual_checksum_prevents_false_conflict() {
418 let root = TempDir::new().unwrap();
422
423 let source_hash = hash::hash_bytes(b"# original source");
424 let installed_content = b"# rewritten by mars";
425 let installed_hash = hash::hash_bytes(installed_content);
426
427 let agents_dir = root.path().join("agents");
429 fs::create_dir_all(&agents_dir).unwrap();
430 fs::write(agents_dir.join("coder.md"), installed_content).unwrap();
431
432 let source_path = PathBuf::from("/tmp/source/agents/coder.md");
433
434 let target_item = make_target_item("coder", ItemKind::Agent, &source_hash, source_path);
436 let mut target_items = IndexMap::new();
437 target_items.insert("agents/coder.md".into(), target_item);
438 let target = TargetState {
439 items: target_items,
440 };
441
442 let locked_item = make_locked_item("coder", ItemKind::Agent, &source_hash, &installed_hash);
444 let mut lock_items = IndexMap::new();
445 lock_items.insert("agents/coder.md".into(), locked_item);
446 let lock = LockFile {
447 version: 1,
448 dependencies: IndexMap::new(),
449 items: lock_items,
450 };
451
452 let diff = compute(root.path(), &lock, &target, false).unwrap();
453 assert_eq!(diff.items.len(), 1);
454 assert!(
457 matches!(&diff.items[0], DiffEntry::Unchanged { .. }),
458 "expected Unchanged, got {:?}",
459 diff.items[0]
460 );
461 }
462
463 #[test]
464 fn mixed_diff_entries() {
465 let root = TempDir::new().unwrap();
466 let agents_dir = root.path().join("agents");
467 fs::create_dir_all(&agents_dir).unwrap();
468
469 let hash_a = hash::hash_bytes(b"# unchanged");
470 let hash_b_old = hash::hash_bytes(b"# old version");
471 let hash_b_new = hash::hash_bytes(b"# new version");
472
473 fs::write(agents_dir.join("stable.md"), b"# unchanged").unwrap();
475
476 fs::write(agents_dir.join("updating.md"), b"# old version").unwrap();
478
479 let source_path_a = PathBuf::from("/tmp/source/agents/stable.md");
480 let source_path_b = PathBuf::from("/tmp/source/agents/updating.md");
481 let source_path_c = PathBuf::from("/tmp/source/agents/new.md");
482
483 let mut target_items = IndexMap::new();
484 target_items.insert(
485 "agents/stable.md".into(),
486 make_target_item("stable", ItemKind::Agent, &hash_a, source_path_a),
487 );
488 target_items.insert(
489 "agents/updating.md".into(),
490 make_target_item("updating", ItemKind::Agent, &hash_b_new, source_path_b),
491 );
492 target_items.insert(
493 "agents/new.md".into(),
494 make_target_item(
495 "new",
496 ItemKind::Agent,
497 &hash::hash_bytes(b"# brand new"),
498 source_path_c,
499 ),
500 );
501 let target = TargetState {
502 items: target_items,
503 };
504
505 let mut lock_items = IndexMap::new();
506 lock_items.insert(
507 "agents/stable.md".into(),
508 make_locked_item("stable", ItemKind::Agent, &hash_a, &hash_a),
509 );
510 lock_items.insert(
511 "agents/updating.md".into(),
512 make_locked_item("updating", ItemKind::Agent, &hash_b_old, &hash_b_old),
513 );
514 lock_items.insert(
515 "agents/orphan.md".into(),
516 make_locked_item("orphan", ItemKind::Agent, "sha256:xxx", "sha256:xxx"),
517 );
518 let lock = LockFile {
519 version: 1,
520 dependencies: IndexMap::new(),
521 items: lock_items,
522 };
523
524 let diff = compute(root.path(), &lock, &target, false).unwrap();
525 assert_eq!(diff.items.len(), 4); let unchanged_count = diff
528 .items
529 .iter()
530 .filter(|d| matches!(d, DiffEntry::Unchanged { .. }))
531 .count();
532 let update_count = diff
533 .items
534 .iter()
535 .filter(|d| matches!(d, DiffEntry::Update { .. }))
536 .count();
537 let add_count = diff
538 .items
539 .iter()
540 .filter(|d| matches!(d, DiffEntry::Add { .. }))
541 .count();
542 let orphan_count = diff
543 .items
544 .iter()
545 .filter(|d| matches!(d, DiffEntry::Orphan { .. }))
546 .count();
547
548 assert_eq!(unchanged_count, 1);
549 assert_eq!(update_count, 1);
550 assert_eq!(add_count, 1);
551 assert_eq!(orphan_count, 1);
552 }
553
554 #[test]
555 fn force_uses_source_checksum_for_local_change_detection() {
556 let root = TempDir::new().unwrap();
557 let upstream_content = b"# upstream";
558 let conflicted_content = b"<<<<<<< local\n# local\n=======\n# upstream\n>>>>>>> upstream\n";
559
560 let source_hash = hash::hash_bytes(upstream_content);
561 let installed_hash = hash::hash_bytes(conflicted_content);
562
563 let agents_dir = root.path().join("agents");
565 fs::create_dir_all(&agents_dir).unwrap();
566 fs::write(agents_dir.join("coder.md"), conflicted_content).unwrap();
567
568 let mut target_items = IndexMap::new();
569 target_items.insert(
570 "agents/coder.md".into(),
571 make_target_item(
572 "coder",
573 ItemKind::Agent,
574 &source_hash,
575 PathBuf::from("/tmp/source/agents/coder.md"),
576 ),
577 );
578 let target = TargetState {
579 items: target_items,
580 };
581
582 let mut lock_items = IndexMap::new();
583 lock_items.insert(
584 "agents/coder.md".into(),
585 LockedItem {
586 source: "test-source".into(),
587 kind: ItemKind::Agent,
588 version: None,
589 source_checksum: source_hash.clone().into(),
590 installed_checksum: installed_hash.into(),
591 dest_path: "agents/coder.md".into(),
592 },
593 );
594 let lock = LockFile {
595 version: 1,
596 dependencies: IndexMap::new(),
597 items: lock_items,
598 };
599
600 let normal = compute(root.path(), &lock, &target, false).unwrap();
601 assert!(matches!(&normal.items[0], DiffEntry::Unchanged { .. }));
602
603 let forced = compute(root.path(), &lock, &target, true).unwrap();
604 assert!(matches!(&forced.items[0], DiffEntry::LocalModified { .. }));
605 }
606
607 #[test]
608 fn rewritten_content_change_produces_update() {
609 let root = TempDir::new().unwrap();
610
611 let source_content = b"---\nskills:\n- planning\n---\n# Agent\n";
612 let source_hash = hash::hash_bytes(source_content);
613 let old_installed_content = b"---\nskills:\n- planning\n---\n# Agent\n";
614 let old_installed_hash = hash::hash_bytes(old_installed_content);
615 let rewritten_content = "---\nskills:\n- strategy\n---\n# Agent\n";
616 let rewritten_hash = hash::hash_bytes(rewritten_content.as_bytes());
617
618 let agents_dir = root.path().join("agents");
619 fs::create_dir_all(&agents_dir).unwrap();
620 fs::write(agents_dir.join("coder.md"), old_installed_content).unwrap();
621
622 let mut target_items = IndexMap::new();
623 target_items.insert(
624 "agents/coder.md".into(),
625 TargetItem {
626 id: ItemId {
627 kind: ItemKind::Agent,
628 name: "coder".into(),
629 },
630 source_name: SourceName::from("test-source"),
631 origin: crate::types::SourceOrigin::Dependency(SourceName::from("test-source")),
632 source_id: crate::types::SourceId::Path {
633 canonical: PathBuf::from("/tmp/source/agents/coder.md"),
634 subpath: None,
635 },
636 source_path: PathBuf::from("/tmp/source/agents/coder.md"),
637 dest_path: "agents/coder.md".into(),
638 source_hash: source_hash.clone().into(),
639 is_flat_skill: false,
640 rewritten_content: Some(rewritten_content.to_string()),
641 },
642 );
643 let target = TargetState {
644 items: target_items,
645 };
646
647 let mut lock_items = IndexMap::new();
648 lock_items.insert(
649 "agents/coder.md".into(),
650 LockedItem {
651 source: SourceName::from("test-source"),
652 kind: ItemKind::Agent,
653 version: None,
654 source_checksum: source_hash.into(),
655 installed_checksum: old_installed_hash.clone().into(),
656 dest_path: "agents/coder.md".into(),
657 },
658 );
659 let lock = LockFile {
660 version: 1,
661 dependencies: IndexMap::new(),
662 items: lock_items,
663 };
664
665 let diff = compute(root.path(), &lock, &target, false).unwrap();
666 assert_eq!(diff.items.len(), 1);
667 assert!(matches!(&diff.items[0], DiffEntry::Update { .. }));
668
669 assert_ne!(rewritten_hash, old_installed_hash);
670 }
671}