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