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