Skip to main content

mars_agents/sync/
diff.rs

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/// The diff between current disk state and desired target state.
10#[derive(Debug, Clone)]
11pub struct SyncDiff {
12    pub items: Vec<DiffEntry>,
13}
14
15/// A single diff entry — one of six cases from the merge matrix.
16#[derive(Debug, Clone)]
17pub enum DiffEntry {
18    /// New item not in lock or on disk.
19    Add { target: TargetItem },
20    /// Source changed, local unchanged → clean update.
21    Update {
22        target: TargetItem,
23        locked: LockedItem,
24    },
25    /// Source unchanged, local unchanged → skip.
26    Unchanged {
27        target: TargetItem,
28        locked: LockedItem,
29    },
30    /// Source changed AND local changed → needs merge.
31    Conflict {
32        target: TargetItem,
33        locked: LockedItem,
34        local_hash: ContentHash,
35    },
36    /// In lock but not in target → should be removed.
37    Orphan { locked: LockedItem },
38    /// Local modification, source unchanged → keep local.
39    LocalModified {
40        target: TargetItem,
41        locked: LockedItem,
42        local_hash: ContentHash,
43    },
44}
45
46/// Compute the diff between current disk state + lock and target state.
47///
48/// Uses dual checksums from the lock file:
49/// - `source_checksum`: what the source provided
50/// - `installed_checksum`: what mars wrote to disk
51///
52/// Compares current disk hash against lock checksums to determine the diff entry variant.
53pub 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    // Process each target item
63    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            // Item exists in lock — compare checksums
66            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            // Check disk hash against the expected baseline.
71            // In --force mode, baseline is source_checksum so conflicted files
72            // are treated as local modifications and get overwritten.
73            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                // File was deleted locally — treat as if local changed to "nothing"
91                // In this case, we should reinstall it
92                None
93            };
94
95            match (source_changed, &local_changed) {
96                (false, None) => {
97                    // Neither changed → skip
98                    if hash_path.exists() {
99                        items.push(DiffEntry::Unchanged {
100                            target: target_item.clone(),
101                            locked: locked_item.clone(),
102                        });
103                    } else {
104                        // File was deleted but hashes match lock — reinstall
105                        items.push(DiffEntry::Add {
106                            target: target_item.clone(),
107                        });
108                    }
109                }
110                (true, None) => {
111                    // Source changed, local unchanged → clean update
112                    items.push(DiffEntry::Update {
113                        target: target_item.clone(),
114                        locked: locked_item.clone(),
115                    });
116                }
117                (false, Some(local_hash)) => {
118                    // Local changed, source unchanged → keep local
119                    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                    // Both changed → conflict
127                    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            // Not in lock → new item
136            items.push(DiffEntry::Add {
137                target: target_item.clone(),
138            });
139        }
140    }
141
142    // Find orphans: items in lock but not in target
143    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    /// Create a minimal target item for testing.
183    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    /// Build a v2 `(key, LockedItemV2)` pair for inserting into `LockFile.items`.
216    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        // Write file to disk
275        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        // Write old content to disk (matching lock's installed_checksum)
311        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        // Target has new hash
318        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        // Lock has old hash
326        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        // Write locally modified content to disk
349        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        // Target has same source hash as lock (no upstream change)
356        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        // Lock also has original hash
364        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        // Write locally modified content
387        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        // Target has new source hash (upstream changed)
394        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        // Lock has original hash
402        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        // Empty target — no items wanted
422        let target = TargetState {
423            items: IndexMap::new(),
424        };
425
426        // Lock has an item
427        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        // When mars rewrites frontmatter, source_checksum != installed_checksum.
445        // The disk should match installed_checksum (what mars wrote).
446        // This should NOT be detected as a local modification.
447        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        // Disk has the mars-rewritten content
454        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        // Target has same source hash as before (no upstream change)
461        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        // Lock has different source_checksum and installed_checksum
469        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        // Should be Unchanged because disk matches installed_checksum
482        // and source_hash matches source_checksum
483        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        // Write unchanged file
501        fs::write(agents_dir.join("stable.md"), b"# unchanged").unwrap();
502
503        // Write file with old content (will be updated)
504        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); // Unchanged + Update + Add + Orphan
548
549        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        // Disk matches prior conflicted content from last sync.
586        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}