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