1use std::path::Path;
2
3use crate::error::MarsError;
4use crate::hash;
5use crate::lock::{LockFile, LockIndex, 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 let lock_index = LockIndex::new(lock);
61
62 for (_dest_key, target_item) in &target.items {
64 if let Some(locked_item) = lock_index.find_by_dest_path(&target_item.dest_path) {
65 let source_changed = target_item.source_hash != locked_item.source_checksum
67 || rewritten_installed_checksum(target_item)
68 .is_some_and(|checksum| checksum != locked_item.installed_checksum);
69
70 let expected_disk_checksum = if force {
74 &locked_item.source_checksum
75 } else {
76 &locked_item.installed_checksum
77 };
78
79 let disk_path = target_item.dest_path.resolve(root);
80 let hash_path = hash_path_for_kind(&disk_path, target_item.id.kind);
81 let local_changed = if hash_path.exists() {
82 let disk_hash = hash::compute_hash(&hash_path, target_item.id.kind)?;
83 let disk_hash = ContentHash::from(disk_hash);
84 if disk_hash != *expected_disk_checksum {
85 Some(disk_hash)
86 } else {
87 None
88 }
89 } else {
90 None
93 };
94
95 match (source_changed, &local_changed) {
96 (false, None) => {
97 if hash_path.exists() {
99 items.push(DiffEntry::Unchanged {
100 target: target_item.clone(),
101 locked: locked_item.clone(),
102 });
103 } else {
104 items.push(DiffEntry::Add {
106 target: target_item.clone(),
107 });
108 }
109 }
110 (true, None) => {
111 items.push(DiffEntry::Update {
113 target: target_item.clone(),
114 locked: locked_item.clone(),
115 });
116 }
117 (false, Some(local_hash)) => {
118 items.push(DiffEntry::LocalModified {
120 target: target_item.clone(),
121 locked: locked_item.clone(),
122 local_hash: local_hash.clone(),
123 });
124 }
125 (true, Some(local_hash)) => {
126 items.push(DiffEntry::Conflict {
128 target: target_item.clone(),
129 locked: locked_item.clone(),
130 local_hash: local_hash.clone(),
131 });
132 }
133 }
134 } else {
135 items.push(DiffEntry::Add {
137 target: target_item.clone(),
138 });
139 }
140 }
141
142 for (dest_path, locked_item) in lock.flat_items() {
144 if !target.items.contains_key(&dest_path) {
145 items.push(DiffEntry::Orphan {
146 locked: locked_item,
147 });
148 }
149 }
150
151 Ok(SyncDiff { items })
152}
153
154fn rewritten_installed_checksum(target_item: &TargetItem) -> Option<ContentHash> {
155 target_item
156 .rewritten_content
157 .as_ref()
158 .map(|content| ContentHash::from(hash::hash_bytes(content.as_bytes())))
159}
160
161fn hash_path_for_kind(path: &Path, kind: crate::lock::ItemKind) -> std::path::PathBuf {
162 if kind == crate::lock::ItemKind::BootstrapDoc {
163 path.parent()
164 .map(Path::to_path_buf)
165 .unwrap_or_else(|| path.to_path_buf())
166 } else {
167 path.to_path_buf()
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174 use crate::hash;
175 use crate::lock::{ItemId, ItemKind, LockedItemV2, OutputRecord};
176 use crate::types::{ItemName, SourceName};
177 use indexmap::IndexMap;
178 use std::fs;
179 use std::path::PathBuf;
180 use tempfile::TempDir;
181
182 fn make_target_item(
184 name: &str,
185 kind: ItemKind,
186 source_hash: &str,
187 source_path: PathBuf,
188 ) -> TargetItem {
189 let dest_path = match kind {
190 ItemKind::Agent => PathBuf::from("agents").join(format!("{name}.md")),
191 ItemKind::Skill => PathBuf::from("skills").join(name),
192 ItemKind::Hook => PathBuf::from("hooks").join(name),
193 ItemKind::McpServer => PathBuf::from("mcp").join(name),
194 ItemKind::BootstrapDoc => PathBuf::from("bootstrap").join(name).join("BOOTSTRAP.md"),
195 };
196 TargetItem {
197 id: ItemId {
198 kind,
199 name: ItemName::from(name),
200 },
201 source_name: SourceName::from("test-source"),
202 origin: crate::types::SourceOrigin::Dependency(SourceName::from("test-source")),
203 source_id: crate::types::SourceId::Path {
204 canonical: source_path.clone(),
205 subpath: None,
206 },
207 source_path,
208 dest_path: dest_path.to_string_lossy().to_string().into(),
209 source_hash: ContentHash::from(source_hash),
210 is_flat_skill: false,
211 rewritten_content: None,
212 }
213 }
214
215 fn make_v2_item(
217 name: &str,
218 kind: ItemKind,
219 source_checksum: &str,
220 installed_checksum: &str,
221 ) -> (String, LockedItemV2) {
222 let dest_path = match kind {
223 ItemKind::Agent => format!("agents/{name}.md"),
224 ItemKind::Skill => format!("skills/{name}"),
225 ItemKind::Hook => format!("hooks/{name}"),
226 ItemKind::McpServer => format!("mcp/{name}"),
227 ItemKind::BootstrapDoc => format!("bootstrap/{name}/BOOTSTRAP.md"),
228 };
229 let key = format!("{kind}/{name}");
230 let item = LockedItemV2 {
231 source: SourceName::from("test-source"),
232 kind,
233 version: None,
234 source_checksum: ContentHash::from(source_checksum),
235 outputs: vec![OutputRecord {
236 target_root: ".mars".to_string(),
237 dest_path: dest_path.into(),
238 installed_checksum: ContentHash::from(installed_checksum),
239 }],
240 };
241 (key, item)
242 }
243
244 #[test]
245 fn new_item_produces_add() {
246 let root = TempDir::new().unwrap();
247 let source_dir = TempDir::new().unwrap();
248 let source_path = source_dir.path().join("agents/coder.md");
249 fs::create_dir_all(source_dir.path().join("agents")).unwrap();
250 fs::write(&source_path, "# new agent").unwrap();
251
252 let hash = hash::hash_bytes(b"# new agent");
253
254 let target_item = make_target_item("coder", ItemKind::Agent, &hash, source_path);
255 let mut target_items = IndexMap::new();
256 target_items.insert("agents/coder.md".into(), target_item);
257 let target = TargetState {
258 items: target_items,
259 };
260
261 let lock = LockFile::empty();
262 let diff = compute(root.path(), &lock, &target, false).unwrap();
263
264 assert_eq!(diff.items.len(), 1);
265 assert!(matches!(&diff.items[0], DiffEntry::Add { .. }));
266 }
267
268 #[test]
269 fn unchanged_item_produces_unchanged() {
270 let root = TempDir::new().unwrap();
271 let content = b"# existing agent";
272 let hash = hash::hash_bytes(content);
273
274 let agents_dir = root.path().join("agents");
276 fs::create_dir_all(&agents_dir).unwrap();
277 fs::write(agents_dir.join("coder.md"), content).unwrap();
278
279 let source_path = PathBuf::from("/tmp/source/agents/coder.md");
280
281 let target_item = make_target_item("coder", ItemKind::Agent, &hash, source_path);
282 let mut target_items = IndexMap::new();
283 target_items.insert("agents/coder.md".into(), target_item);
284 let target = TargetState {
285 items: target_items,
286 };
287
288 let mut lock_items = IndexMap::new();
289 let (k, v) = make_v2_item("coder", ItemKind::Agent, &hash, &hash);
290 lock_items.insert(k, v);
291 let lock = LockFile {
292 version: 2,
293 dependencies: IndexMap::new(),
294 items: lock_items,
295 config_entries: std::collections::BTreeMap::new(),
296 };
297
298 let diff = compute(root.path(), &lock, &target, false).unwrap();
299 assert_eq!(diff.items.len(), 1);
300 assert!(matches!(&diff.items[0], DiffEntry::Unchanged { .. }));
301 }
302
303 #[test]
304 fn source_changed_local_unchanged_produces_update() {
305 let root = TempDir::new().unwrap();
306 let old_content = b"# old version";
307 let old_hash = hash::hash_bytes(old_content);
308 let new_hash = hash::hash_bytes(b"# new version");
309
310 let agents_dir = root.path().join("agents");
312 fs::create_dir_all(&agents_dir).unwrap();
313 fs::write(agents_dir.join("coder.md"), old_content).unwrap();
314
315 let source_path = PathBuf::from("/tmp/source/agents/coder.md");
316
317 let target_item = make_target_item("coder", ItemKind::Agent, &new_hash, source_path);
319 let mut target_items = IndexMap::new();
320 target_items.insert("agents/coder.md".into(), target_item);
321 let target = TargetState {
322 items: target_items,
323 };
324
325 let mut lock_items = IndexMap::new();
327 let (k, v) = make_v2_item("coder", ItemKind::Agent, &old_hash, &old_hash);
328 lock_items.insert(k, v);
329 let lock = LockFile {
330 version: 2,
331 dependencies: IndexMap::new(),
332 items: lock_items,
333 config_entries: std::collections::BTreeMap::new(),
334 };
335
336 let diff = compute(root.path(), &lock, &target, false).unwrap();
337 assert_eq!(diff.items.len(), 1);
338 assert!(matches!(&diff.items[0], DiffEntry::Update { .. }));
339 }
340
341 #[test]
342 fn local_changed_source_unchanged_produces_local_modified() {
343 let root = TempDir::new().unwrap();
344 let original_content = b"# original";
345 let original_hash = hash::hash_bytes(original_content);
346 let local_content = b"# locally modified";
347
348 let agents_dir = root.path().join("agents");
350 fs::create_dir_all(&agents_dir).unwrap();
351 fs::write(agents_dir.join("coder.md"), local_content).unwrap();
352
353 let source_path = PathBuf::from("/tmp/source/agents/coder.md");
354
355 let target_item = make_target_item("coder", ItemKind::Agent, &original_hash, source_path);
357 let mut target_items = IndexMap::new();
358 target_items.insert("agents/coder.md".into(), target_item);
359 let target = TargetState {
360 items: target_items,
361 };
362
363 let mut lock_items = IndexMap::new();
365 let (k, v) = make_v2_item("coder", ItemKind::Agent, &original_hash, &original_hash);
366 lock_items.insert(k, v);
367 let lock = LockFile {
368 version: 2,
369 dependencies: IndexMap::new(),
370 items: lock_items,
371 config_entries: std::collections::BTreeMap::new(),
372 };
373
374 let diff = compute(root.path(), &lock, &target, false).unwrap();
375 assert_eq!(diff.items.len(), 1);
376 assert!(matches!(&diff.items[0], DiffEntry::LocalModified { .. }));
377 }
378
379 #[test]
380 fn both_changed_produces_conflict() {
381 let root = TempDir::new().unwrap();
382 let original_hash = hash::hash_bytes(b"# original");
383 let new_source_hash = hash::hash_bytes(b"# new upstream");
384 let local_content = b"# locally modified";
385
386 let agents_dir = root.path().join("agents");
388 fs::create_dir_all(&agents_dir).unwrap();
389 fs::write(agents_dir.join("coder.md"), local_content).unwrap();
390
391 let source_path = PathBuf::from("/tmp/source/agents/coder.md");
392
393 let target_item = make_target_item("coder", ItemKind::Agent, &new_source_hash, source_path);
395 let mut target_items = IndexMap::new();
396 target_items.insert("agents/coder.md".into(), target_item);
397 let target = TargetState {
398 items: target_items,
399 };
400
401 let mut lock_items = IndexMap::new();
403 let (k, v) = make_v2_item("coder", ItemKind::Agent, &original_hash, &original_hash);
404 lock_items.insert(k, v);
405 let lock = LockFile {
406 version: 2,
407 dependencies: IndexMap::new(),
408 items: lock_items,
409 config_entries: std::collections::BTreeMap::new(),
410 };
411
412 let diff = compute(root.path(), &lock, &target, false).unwrap();
413 assert_eq!(diff.items.len(), 1);
414 assert!(matches!(&diff.items[0], DiffEntry::Conflict { .. }));
415 }
416
417 #[test]
418 fn orphan_detected() {
419 let root = TempDir::new().unwrap();
420
421 let target = TargetState {
423 items: IndexMap::new(),
424 };
425
426 let mut lock_items = IndexMap::new();
428 let (k, v) = make_v2_item("old-agent", ItemKind::Agent, "sha256:aaa", "sha256:aaa");
429 lock_items.insert(k, v);
430 let lock = LockFile {
431 version: 2,
432 dependencies: IndexMap::new(),
433 items: lock_items,
434 config_entries: std::collections::BTreeMap::new(),
435 };
436
437 let diff = compute(root.path(), &lock, &target, false).unwrap();
438 assert_eq!(diff.items.len(), 1);
439 assert!(matches!(&diff.items[0], DiffEntry::Orphan { .. }));
440 }
441
442 #[test]
443 fn dual_checksum_prevents_false_conflict() {
444 let root = TempDir::new().unwrap();
448
449 let source_hash = hash::hash_bytes(b"# original source");
450 let installed_content = b"# rewritten by mars";
451 let installed_hash = hash::hash_bytes(installed_content);
452
453 let agents_dir = root.path().join("agents");
455 fs::create_dir_all(&agents_dir).unwrap();
456 fs::write(agents_dir.join("coder.md"), installed_content).unwrap();
457
458 let source_path = PathBuf::from("/tmp/source/agents/coder.md");
459
460 let target_item = make_target_item("coder", ItemKind::Agent, &source_hash, source_path);
462 let mut target_items = IndexMap::new();
463 target_items.insert("agents/coder.md".into(), target_item);
464 let target = TargetState {
465 items: target_items,
466 };
467
468 let mut lock_items = IndexMap::new();
470 let (k, v) = make_v2_item("coder", ItemKind::Agent, &source_hash, &installed_hash);
471 lock_items.insert(k, v);
472 let lock = LockFile {
473 version: 2,
474 dependencies: IndexMap::new(),
475 items: lock_items,
476 config_entries: std::collections::BTreeMap::new(),
477 };
478
479 let diff = compute(root.path(), &lock, &target, false).unwrap();
480 assert_eq!(diff.items.len(), 1);
481 assert!(
484 matches!(&diff.items[0], DiffEntry::Unchanged { .. }),
485 "expected Unchanged, got {:?}",
486 diff.items[0]
487 );
488 }
489
490 #[test]
491 fn mixed_diff_entries() {
492 let root = TempDir::new().unwrap();
493 let agents_dir = root.path().join("agents");
494 fs::create_dir_all(&agents_dir).unwrap();
495
496 let hash_a = hash::hash_bytes(b"# unchanged");
497 let hash_b_old = hash::hash_bytes(b"# old version");
498 let hash_b_new = hash::hash_bytes(b"# new version");
499
500 fs::write(agents_dir.join("stable.md"), b"# unchanged").unwrap();
502
503 fs::write(agents_dir.join("updating.md"), b"# old version").unwrap();
505
506 let source_path_a = PathBuf::from("/tmp/source/agents/stable.md");
507 let source_path_b = PathBuf::from("/tmp/source/agents/updating.md");
508 let source_path_c = PathBuf::from("/tmp/source/agents/new.md");
509
510 let mut target_items = IndexMap::new();
511 target_items.insert(
512 "agents/stable.md".into(),
513 make_target_item("stable", ItemKind::Agent, &hash_a, source_path_a),
514 );
515 target_items.insert(
516 "agents/updating.md".into(),
517 make_target_item("updating", ItemKind::Agent, &hash_b_new, source_path_b),
518 );
519 target_items.insert(
520 "agents/new.md".into(),
521 make_target_item(
522 "new",
523 ItemKind::Agent,
524 &hash::hash_bytes(b"# brand new"),
525 source_path_c,
526 ),
527 );
528 let target = TargetState {
529 items: target_items,
530 };
531
532 let mut lock_items = IndexMap::new();
533 let (k, v) = make_v2_item("stable", ItemKind::Agent, &hash_a, &hash_a);
534 lock_items.insert(k, v);
535 let (k, v) = make_v2_item("updating", ItemKind::Agent, &hash_b_old, &hash_b_old);
536 lock_items.insert(k, v);
537 let (k, v) = make_v2_item("orphan", ItemKind::Agent, "sha256:xxx", "sha256:xxx");
538 lock_items.insert(k, v);
539 let lock = LockFile {
540 version: 2,
541 dependencies: IndexMap::new(),
542 items: lock_items,
543 config_entries: std::collections::BTreeMap::new(),
544 };
545
546 let diff = compute(root.path(), &lock, &target, false).unwrap();
547 assert_eq!(diff.items.len(), 4); let unchanged_count = diff
550 .items
551 .iter()
552 .filter(|d| matches!(d, DiffEntry::Unchanged { .. }))
553 .count();
554 let update_count = diff
555 .items
556 .iter()
557 .filter(|d| matches!(d, DiffEntry::Update { .. }))
558 .count();
559 let add_count = diff
560 .items
561 .iter()
562 .filter(|d| matches!(d, DiffEntry::Add { .. }))
563 .count();
564 let orphan_count = diff
565 .items
566 .iter()
567 .filter(|d| matches!(d, DiffEntry::Orphan { .. }))
568 .count();
569
570 assert_eq!(unchanged_count, 1);
571 assert_eq!(update_count, 1);
572 assert_eq!(add_count, 1);
573 assert_eq!(orphan_count, 1);
574 }
575
576 #[test]
577 fn force_uses_source_checksum_for_local_change_detection() {
578 let root = TempDir::new().unwrap();
579 let upstream_content = b"# upstream";
580 let conflicted_content = b"<<<<<<< local\n# local\n=======\n# upstream\n>>>>>>> upstream\n";
581
582 let source_hash = hash::hash_bytes(upstream_content);
583 let installed_hash = hash::hash_bytes(conflicted_content);
584
585 let agents_dir = root.path().join("agents");
587 fs::create_dir_all(&agents_dir).unwrap();
588 fs::write(agents_dir.join("coder.md"), conflicted_content).unwrap();
589
590 let mut target_items = IndexMap::new();
591 target_items.insert(
592 "agents/coder.md".into(),
593 make_target_item(
594 "coder",
595 ItemKind::Agent,
596 &source_hash,
597 PathBuf::from("/tmp/source/agents/coder.md"),
598 ),
599 );
600 let target = TargetState {
601 items: target_items,
602 };
603
604 let mut lock_items = IndexMap::new();
605 lock_items.insert(
606 "agent/coder".to_string(),
607 LockedItemV2 {
608 source: "test-source".into(),
609 kind: ItemKind::Agent,
610 version: None,
611 source_checksum: source_hash.clone().into(),
612 outputs: vec![OutputRecord {
613 target_root: ".mars".to_string(),
614 dest_path: "agents/coder.md".into(),
615 installed_checksum: installed_hash.into(),
616 }],
617 },
618 );
619 let lock = LockFile {
620 version: 2,
621 dependencies: IndexMap::new(),
622 items: lock_items,
623 config_entries: std::collections::BTreeMap::new(),
624 };
625
626 let normal = compute(root.path(), &lock, &target, false).unwrap();
627 assert!(matches!(&normal.items[0], DiffEntry::Unchanged { .. }));
628
629 let forced = compute(root.path(), &lock, &target, true).unwrap();
630 assert!(matches!(&forced.items[0], DiffEntry::LocalModified { .. }));
631 }
632
633 #[test]
634 fn rewritten_content_change_produces_update() {
635 let root = TempDir::new().unwrap();
636
637 let source_content = b"---\nskills:\n- planning\n---\n# Agent\n";
638 let source_hash = hash::hash_bytes(source_content);
639 let old_installed_content = b"---\nskills:\n- planning\n---\n# Agent\n";
640 let old_installed_hash = hash::hash_bytes(old_installed_content);
641 let rewritten_content = "---\nskills:\n- strategy\n---\n# Agent\n";
642 let rewritten_hash = hash::hash_bytes(rewritten_content.as_bytes());
643
644 let agents_dir = root.path().join("agents");
645 fs::create_dir_all(&agents_dir).unwrap();
646 fs::write(agents_dir.join("coder.md"), old_installed_content).unwrap();
647
648 let mut target_items = IndexMap::new();
649 target_items.insert(
650 "agents/coder.md".into(),
651 TargetItem {
652 id: ItemId {
653 kind: ItemKind::Agent,
654 name: "coder".into(),
655 },
656 source_name: SourceName::from("test-source"),
657 origin: crate::types::SourceOrigin::Dependency(SourceName::from("test-source")),
658 source_id: crate::types::SourceId::Path {
659 canonical: PathBuf::from("/tmp/source/agents/coder.md"),
660 subpath: None,
661 },
662 source_path: PathBuf::from("/tmp/source/agents/coder.md"),
663 dest_path: "agents/coder.md".into(),
664 source_hash: source_hash.clone().into(),
665 is_flat_skill: false,
666 rewritten_content: Some(rewritten_content.to_string()),
667 },
668 );
669 let target = TargetState {
670 items: target_items,
671 };
672
673 let mut lock_items = IndexMap::new();
674 lock_items.insert(
675 "agent/coder".to_string(),
676 LockedItemV2 {
677 source: SourceName::from("test-source"),
678 kind: ItemKind::Agent,
679 version: None,
680 source_checksum: source_hash.into(),
681 outputs: vec![OutputRecord {
682 target_root: ".mars".to_string(),
683 dest_path: "agents/coder.md".into(),
684 installed_checksum: old_installed_hash.clone().into(),
685 }],
686 },
687 );
688 let lock = LockFile {
689 version: 2,
690 dependencies: IndexMap::new(),
691 items: lock_items,
692 config_entries: std::collections::BTreeMap::new(),
693 };
694
695 let diff = compute(root.path(), &lock, &target, false).unwrap();
696 assert_eq!(diff.items.len(), 1);
697 assert!(matches!(&diff.items[0], DiffEntry::Update { .. }));
698
699 assert_ne!(rewritten_hash, old_installed_hash);
700 }
701}