Skip to main content

mars_agents/sync/
diff.rs

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/// 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) =
65            lock_index.find_output(CANONICAL_TARGET_ROOT, &target_item.dest_path)
66        {
67            // Item exists in lock — compare checksums
68            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            // Check disk hash against the expected baseline.
73            // In --force mode, baseline is source_checksum so conflicted files
74            // are treated as local modifications and get overwritten.
75            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                // File was deleted locally — treat as if local changed to "nothing"
93                // In this case, we should reinstall it
94                None
95            };
96
97            match (source_changed, &local_changed) {
98                (false, None) => {
99                    // Neither changed → skip
100                    if hash_path.exists() {
101                        items.push(DiffEntry::Unchanged {
102                            target: target_item.clone(),
103                            locked: locked_item.clone(),
104                        });
105                    } else {
106                        // File was deleted but hashes match lock — reinstall
107                        items.push(DiffEntry::Add {
108                            target: target_item.clone(),
109                        });
110                    }
111                }
112                (true, None) => {
113                    // Source changed, local unchanged → clean update
114                    items.push(DiffEntry::Update {
115                        target: target_item.clone(),
116                        locked: locked_item.clone(),
117                    });
118                }
119                (false, Some(local_hash)) => {
120                    // Local changed, source unchanged → keep local
121                    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                    // Both changed → conflict
129                    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            // Not in lock → new item
138            items.push(DiffEntry::Add {
139                target: target_item.clone(),
140            });
141        }
142    }
143
144    // Find orphans: items in lock but not in target
145    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    /// Create a minimal target item for testing.
185    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    /// Build a v2 `(key, LockedItemV2)` pair for inserting into `LockFile.items`.
218    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        // Write file to disk
277        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        };
299
300        let diff = compute(root.path(), &lock, &target, false).unwrap();
301        assert_eq!(diff.items.len(), 1);
302        assert!(matches!(&diff.items[0], DiffEntry::Unchanged { .. }));
303    }
304
305    #[test]
306    fn source_changed_local_unchanged_produces_update() {
307        let root = TempDir::new().unwrap();
308        let old_content = b"# old version";
309        let old_hash = hash::hash_bytes(old_content);
310        let new_hash = hash::hash_bytes(b"# new version");
311
312        // Write old content to disk (matching lock's installed_checksum)
313        let agents_dir = root.path().join("agents");
314        fs::create_dir_all(&agents_dir).unwrap();
315        fs::write(agents_dir.join("coder.md"), old_content).unwrap();
316
317        let source_path = PathBuf::from("/tmp/source/agents/coder.md");
318
319        // Target has new hash
320        let target_item = make_target_item("coder", ItemKind::Agent, &new_hash, source_path);
321        let mut target_items = IndexMap::new();
322        target_items.insert("agents/coder.md".into(), target_item);
323        let target = TargetState {
324            items: target_items,
325        };
326
327        // Lock has old hash
328        let mut lock_items = IndexMap::new();
329        let (k, v) = make_v2_item("coder", ItemKind::Agent, &old_hash, &old_hash);
330        lock_items.insert(k, v);
331        let lock = LockFile {
332            version: 2,
333            dependencies: IndexMap::new(),
334            items: lock_items,
335            config_entries: std::collections::BTreeMap::new(),
336        };
337
338        let diff = compute(root.path(), &lock, &target, false).unwrap();
339        assert_eq!(diff.items.len(), 1);
340        assert!(matches!(&diff.items[0], DiffEntry::Update { .. }));
341    }
342
343    #[test]
344    fn local_changed_source_unchanged_produces_local_modified() {
345        let root = TempDir::new().unwrap();
346        let original_content = b"# original";
347        let original_hash = hash::hash_bytes(original_content);
348        let local_content = b"# locally modified";
349
350        // Write locally modified content to disk
351        let agents_dir = root.path().join("agents");
352        fs::create_dir_all(&agents_dir).unwrap();
353        fs::write(agents_dir.join("coder.md"), local_content).unwrap();
354
355        let source_path = PathBuf::from("/tmp/source/agents/coder.md");
356
357        // Target has same source hash as lock (no upstream change)
358        let target_item = make_target_item("coder", ItemKind::Agent, &original_hash, source_path);
359        let mut target_items = IndexMap::new();
360        target_items.insert("agents/coder.md".into(), target_item);
361        let target = TargetState {
362            items: target_items,
363        };
364
365        // Lock also has original hash
366        let mut lock_items = IndexMap::new();
367        let (k, v) = make_v2_item("coder", ItemKind::Agent, &original_hash, &original_hash);
368        lock_items.insert(k, v);
369        let lock = LockFile {
370            version: 2,
371            dependencies: IndexMap::new(),
372            items: lock_items,
373            config_entries: std::collections::BTreeMap::new(),
374        };
375
376        let diff = compute(root.path(), &lock, &target, false).unwrap();
377        assert_eq!(diff.items.len(), 1);
378        assert!(matches!(&diff.items[0], DiffEntry::LocalModified { .. }));
379    }
380
381    #[test]
382    fn both_changed_produces_conflict() {
383        let root = TempDir::new().unwrap();
384        let original_hash = hash::hash_bytes(b"# original");
385        let new_source_hash = hash::hash_bytes(b"# new upstream");
386        let local_content = b"# locally modified";
387
388        // Write locally modified content
389        let agents_dir = root.path().join("agents");
390        fs::create_dir_all(&agents_dir).unwrap();
391        fs::write(agents_dir.join("coder.md"), local_content).unwrap();
392
393        let source_path = PathBuf::from("/tmp/source/agents/coder.md");
394
395        // Target has new source hash (upstream changed)
396        let target_item = make_target_item("coder", ItemKind::Agent, &new_source_hash, source_path);
397        let mut target_items = IndexMap::new();
398        target_items.insert("agents/coder.md".into(), target_item);
399        let target = TargetState {
400            items: target_items,
401        };
402
403        // Lock has original hash
404        let mut lock_items = IndexMap::new();
405        let (k, v) = make_v2_item("coder", ItemKind::Agent, &original_hash, &original_hash);
406        lock_items.insert(k, v);
407        let lock = LockFile {
408            version: 2,
409            dependencies: IndexMap::new(),
410            items: lock_items,
411            config_entries: std::collections::BTreeMap::new(),
412        };
413
414        let diff = compute(root.path(), &lock, &target, false).unwrap();
415        assert_eq!(diff.items.len(), 1);
416        assert!(matches!(&diff.items[0], DiffEntry::Conflict { .. }));
417    }
418
419    #[test]
420    fn orphan_detected() {
421        let root = TempDir::new().unwrap();
422
423        // Empty target — no items wanted
424        let target = TargetState {
425            items: IndexMap::new(),
426        };
427
428        // Lock has an item
429        let mut lock_items = IndexMap::new();
430        let (k, v) = make_v2_item("old-agent", ItemKind::Agent, "sha256:aaa", "sha256:aaa");
431        lock_items.insert(k, v);
432        let lock = LockFile {
433            version: 2,
434            dependencies: IndexMap::new(),
435            items: lock_items,
436            config_entries: std::collections::BTreeMap::new(),
437        };
438
439        let diff = compute(root.path(), &lock, &target, false).unwrap();
440        assert_eq!(diff.items.len(), 1);
441        assert!(matches!(&diff.items[0], DiffEntry::Orphan { .. }));
442    }
443
444    #[test]
445    fn dual_checksum_prevents_false_conflict() {
446        // When mars rewrites frontmatter, source_checksum != installed_checksum.
447        // The disk should match installed_checksum (what mars wrote).
448        // This should NOT be detected as a local modification.
449        let root = TempDir::new().unwrap();
450
451        let source_hash = hash::hash_bytes(b"# original source");
452        let installed_content = b"# rewritten by mars";
453        let installed_hash = hash::hash_bytes(installed_content);
454
455        // Disk has the mars-rewritten content
456        let agents_dir = root.path().join("agents");
457        fs::create_dir_all(&agents_dir).unwrap();
458        fs::write(agents_dir.join("coder.md"), installed_content).unwrap();
459
460        let source_path = PathBuf::from("/tmp/source/agents/coder.md");
461
462        // Target has same source hash as before (no upstream change)
463        let target_item = make_target_item("coder", ItemKind::Agent, &source_hash, source_path);
464        let mut target_items = IndexMap::new();
465        target_items.insert("agents/coder.md".into(), target_item);
466        let target = TargetState {
467            items: target_items,
468        };
469
470        // Lock has different source_checksum and installed_checksum
471        let mut lock_items = IndexMap::new();
472        let (k, v) = make_v2_item("coder", ItemKind::Agent, &source_hash, &installed_hash);
473        lock_items.insert(k, v);
474        let lock = LockFile {
475            version: 2,
476            dependencies: IndexMap::new(),
477            items: lock_items,
478            config_entries: std::collections::BTreeMap::new(),
479        };
480
481        let diff = compute(root.path(), &lock, &target, false).unwrap();
482        assert_eq!(diff.items.len(), 1);
483        // Should be Unchanged because disk matches installed_checksum
484        // and source_hash matches source_checksum
485        assert!(
486            matches!(&diff.items[0], DiffEntry::Unchanged { .. }),
487            "expected Unchanged, got {:?}",
488            diff.items[0]
489        );
490    }
491
492    #[test]
493    fn mixed_diff_entries() {
494        let root = TempDir::new().unwrap();
495        let agents_dir = root.path().join("agents");
496        fs::create_dir_all(&agents_dir).unwrap();
497
498        let hash_a = hash::hash_bytes(b"# unchanged");
499        let hash_b_old = hash::hash_bytes(b"# old version");
500        let hash_b_new = hash::hash_bytes(b"# new version");
501
502        // Write unchanged file
503        fs::write(agents_dir.join("stable.md"), b"# unchanged").unwrap();
504
505        // Write file with old content (will be updated)
506        fs::write(agents_dir.join("updating.md"), b"# old version").unwrap();
507
508        let source_path_a = PathBuf::from("/tmp/source/agents/stable.md");
509        let source_path_b = PathBuf::from("/tmp/source/agents/updating.md");
510        let source_path_c = PathBuf::from("/tmp/source/agents/new.md");
511
512        let mut target_items = IndexMap::new();
513        target_items.insert(
514            "agents/stable.md".into(),
515            make_target_item("stable", ItemKind::Agent, &hash_a, source_path_a),
516        );
517        target_items.insert(
518            "agents/updating.md".into(),
519            make_target_item("updating", ItemKind::Agent, &hash_b_new, source_path_b),
520        );
521        target_items.insert(
522            "agents/new.md".into(),
523            make_target_item(
524                "new",
525                ItemKind::Agent,
526                &hash::hash_bytes(b"# brand new"),
527                source_path_c,
528            ),
529        );
530        let target = TargetState {
531            items: target_items,
532        };
533
534        let mut lock_items = IndexMap::new();
535        let (k, v) = make_v2_item("stable", ItemKind::Agent, &hash_a, &hash_a);
536        lock_items.insert(k, v);
537        let (k, v) = make_v2_item("updating", ItemKind::Agent, &hash_b_old, &hash_b_old);
538        lock_items.insert(k, v);
539        let (k, v) = make_v2_item("orphan", ItemKind::Agent, "sha256:xxx", "sha256:xxx");
540        lock_items.insert(k, v);
541        let lock = LockFile {
542            version: 2,
543            dependencies: IndexMap::new(),
544            items: lock_items,
545            config_entries: std::collections::BTreeMap::new(),
546        };
547
548        let diff = compute(root.path(), &lock, &target, false).unwrap();
549        assert_eq!(diff.items.len(), 4); // Unchanged + Update + Add + Orphan
550
551        let unchanged_count = diff
552            .items
553            .iter()
554            .filter(|d| matches!(d, DiffEntry::Unchanged { .. }))
555            .count();
556        let update_count = diff
557            .items
558            .iter()
559            .filter(|d| matches!(d, DiffEntry::Update { .. }))
560            .count();
561        let add_count = diff
562            .items
563            .iter()
564            .filter(|d| matches!(d, DiffEntry::Add { .. }))
565            .count();
566        let orphan_count = diff
567            .items
568            .iter()
569            .filter(|d| matches!(d, DiffEntry::Orphan { .. }))
570            .count();
571
572        assert_eq!(unchanged_count, 1);
573        assert_eq!(update_count, 1);
574        assert_eq!(add_count, 1);
575        assert_eq!(orphan_count, 1);
576    }
577
578    #[test]
579    fn force_uses_source_checksum_for_local_change_detection() {
580        let root = TempDir::new().unwrap();
581        let upstream_content = b"# upstream";
582        let conflicted_content = b"<<<<<<< local\n# local\n=======\n# upstream\n>>>>>>> upstream\n";
583
584        let source_hash = hash::hash_bytes(upstream_content);
585        let installed_hash = hash::hash_bytes(conflicted_content);
586
587        // Disk matches prior conflicted content from last sync.
588        let agents_dir = root.path().join("agents");
589        fs::create_dir_all(&agents_dir).unwrap();
590        fs::write(agents_dir.join("coder.md"), conflicted_content).unwrap();
591
592        let mut target_items = IndexMap::new();
593        target_items.insert(
594            "agents/coder.md".into(),
595            make_target_item(
596                "coder",
597                ItemKind::Agent,
598                &source_hash,
599                PathBuf::from("/tmp/source/agents/coder.md"),
600            ),
601        );
602        let target = TargetState {
603            items: target_items,
604        };
605
606        let mut lock_items = IndexMap::new();
607        lock_items.insert(
608            "agent/coder".to_string(),
609            LockedItemV2 {
610                source: "test-source".into(),
611                kind: ItemKind::Agent,
612                version: None,
613                source_checksum: source_hash.clone().into(),
614                outputs: vec![OutputRecord {
615                    target_root: ".mars".to_string(),
616                    dest_path: "agents/coder.md".into(),
617                    installed_checksum: installed_hash.into(),
618                }],
619            },
620        );
621        let lock = LockFile {
622            version: 2,
623            dependencies: IndexMap::new(),
624            items: lock_items,
625            config_entries: std::collections::BTreeMap::new(),
626        };
627
628        let normal = compute(root.path(), &lock, &target, false).unwrap();
629        assert!(matches!(&normal.items[0], DiffEntry::Unchanged { .. }));
630
631        let forced = compute(root.path(), &lock, &target, true).unwrap();
632        assert!(matches!(&forced.items[0], DiffEntry::LocalModified { .. }));
633    }
634
635    #[test]
636    fn canonical_diff_ignores_non_canonical_output_checksum() {
637        let root = TempDir::new().unwrap();
638        let canonical_content = b"# canonical";
639        let canonical_hash = hash::hash_bytes(canonical_content);
640        let pi_hash = hash::hash_bytes(b"# pi rewrite");
641
642        let agents_dir = root.path().join("agents");
643        fs::create_dir_all(&agents_dir).unwrap();
644        fs::write(agents_dir.join("coder.md"), canonical_content).unwrap();
645
646        let mut target_items = IndexMap::new();
647        target_items.insert(
648            "agents/coder.md".into(),
649            make_target_item(
650                "coder",
651                ItemKind::Agent,
652                &canonical_hash,
653                PathBuf::from("/tmp/source/agents/coder.md"),
654            ),
655        );
656        let target = TargetState {
657            items: target_items,
658        };
659
660        let mut lock_items = IndexMap::new();
661        lock_items.insert(
662            "agent/coder".to_string(),
663            LockedItemV2 {
664                source: SourceName::from("test-source"),
665                kind: ItemKind::Agent,
666                version: None,
667                source_checksum: canonical_hash.clone().into(),
668                outputs: vec![
669                    OutputRecord {
670                        target_root: ".mars".to_string(),
671                        dest_path: "agents/coder.md".into(),
672                        installed_checksum: canonical_hash.clone().into(),
673                    },
674                    OutputRecord {
675                        target_root: ".pi".to_string(),
676                        dest_path: "agents/coder.md".into(),
677                        installed_checksum: pi_hash.into(),
678                    },
679                ],
680            },
681        );
682        let lock = LockFile {
683            version: 2,
684            dependencies: IndexMap::new(),
685            items: lock_items,
686            config_entries: std::collections::BTreeMap::new(),
687        };
688
689        let diff = compute(root.path(), &lock, &target, false).unwrap();
690        assert_eq!(diff.items.len(), 1);
691        assert!(
692            matches!(&diff.items[0], DiffEntry::Unchanged { .. }),
693            "expected Unchanged, got {:?}",
694            diff.items[0]
695        );
696    }
697
698    #[test]
699    fn rewritten_content_change_produces_update() {
700        let root = TempDir::new().unwrap();
701
702        let source_content = b"---\nskills:\n- planning\n---\n# Agent\n";
703        let source_hash = hash::hash_bytes(source_content);
704        let old_installed_content = b"---\nskills:\n- planning\n---\n# Agent\n";
705        let old_installed_hash = hash::hash_bytes(old_installed_content);
706        let rewritten_content = "---\nskills:\n- strategy\n---\n# Agent\n";
707        let rewritten_hash = hash::hash_bytes(rewritten_content.as_bytes());
708
709        let agents_dir = root.path().join("agents");
710        fs::create_dir_all(&agents_dir).unwrap();
711        fs::write(agents_dir.join("coder.md"), old_installed_content).unwrap();
712
713        let mut target_items = IndexMap::new();
714        target_items.insert(
715            "agents/coder.md".into(),
716            TargetItem {
717                id: ItemId {
718                    kind: ItemKind::Agent,
719                    name: "coder".into(),
720                },
721                source_name: SourceName::from("test-source"),
722                origin: crate::types::SourceOrigin::Dependency(SourceName::from("test-source")),
723                source_id: crate::types::SourceId::Path {
724                    canonical: PathBuf::from("/tmp/source/agents/coder.md"),
725                    subpath: None,
726                },
727                source_path: PathBuf::from("/tmp/source/agents/coder.md"),
728                dest_path: "agents/coder.md".into(),
729                source_hash: source_hash.clone().into(),
730                is_flat_skill: false,
731                rewritten_content: Some(rewritten_content.to_string()),
732            },
733        );
734        let target = TargetState {
735            items: target_items,
736        };
737
738        let mut lock_items = IndexMap::new();
739        lock_items.insert(
740            "agent/coder".to_string(),
741            LockedItemV2 {
742                source: SourceName::from("test-source"),
743                kind: ItemKind::Agent,
744                version: None,
745                source_checksum: source_hash.into(),
746                outputs: vec![OutputRecord {
747                    target_root: ".mars".to_string(),
748                    dest_path: "agents/coder.md".into(),
749                    installed_checksum: old_installed_hash.clone().into(),
750                }],
751            },
752        );
753        let lock = LockFile {
754            version: 2,
755            dependencies: IndexMap::new(),
756            items: lock_items,
757            config_entries: std::collections::BTreeMap::new(),
758        };
759
760        let diff = compute(root.path(), &lock, &target, false).unwrap();
761        assert_eq!(diff.items.len(), 1);
762        assert!(matches!(&diff.items[0], DiffEntry::Update { .. }));
763
764        assert_ne!(rewritten_hash, old_installed_hash);
765    }
766}