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 { target: TargetItem },
22    /// Source unchanged, local unchanged → skip.
23    Unchanged {
24        target: TargetItem,
25        locked: LockedItem,
26    },
27    /// Source changed AND local changed → needs merge.
28    Conflict { target: TargetItem },
29    /// In lock but not in target → should be removed.
30    Orphan { locked: LockedItem },
31    /// Local modification, source unchanged → keep local.
32    LocalModified { target: TargetItem },
33}
34
35/// Compute the diff between current disk state + lock and target state.
36///
37/// Uses dual checksums from the lock file:
38/// - `source_checksum`: what the source provided
39/// - `installed_checksum`: what mars wrote to disk
40///
41/// Compares current disk hash against lock checksums to determine the diff entry variant.
42pub 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    // Process each target item
52    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            // Item exists in lock — compare checksums
57            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            // Check disk hash against the expected baseline.
63            // In --force mode, baseline is source_checksum so conflicted files
64            // are treated as local modifications and get overwritten.
65            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                // File was deleted locally — treat as if local changed to "nothing"
83                // In this case, we should reinstall it
84                None
85            };
86
87            match (source_changed, &local_changed) {
88                (false, None) => {
89                    // Neither changed → skip
90                    if hash_path.exists() {
91                        items.push(DiffEntry::Unchanged {
92                            target: target_item.clone(),
93                            locked: locked_item.clone(),
94                        });
95                    } else {
96                        // File was deleted but hashes match lock — reinstall
97                        items.push(DiffEntry::Add {
98                            target: target_item.clone(),
99                        });
100                    }
101                }
102                (true, None) => {
103                    // Source changed, local unchanged → clean update
104                    items.push(DiffEntry::Update {
105                        target: target_item.clone(),
106                    });
107                }
108                (false, Some(_local_hash)) => {
109                    // Local changed, source unchanged → keep local
110                    items.push(DiffEntry::LocalModified {
111                        target: target_item.clone(),
112                    });
113                }
114                (true, Some(_local_hash)) => {
115                    // Both changed → conflict
116                    items.push(DiffEntry::Conflict {
117                        target: target_item.clone(),
118                    });
119                }
120            }
121        } else {
122            // Not in lock → new item
123            items.push(DiffEntry::Add {
124                target: target_item.clone(),
125            });
126        }
127    }
128
129    // Find orphans: items in lock but not in target
130    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    /// Create a minimal target item for testing.
170    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    /// Build a v2 `(key, LockedItemV2)` pair for inserting into `LockFile.items`.
198    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        // Write file to disk
257        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        // Write old content to disk (matching lock's installed_checksum)
294        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        // Target has new hash
301        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        // Lock has old hash
309        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        // Write locally modified content to disk
333        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        // Target has same source hash as lock (no upstream change)
340        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        // Lock also has original hash
348        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        // Write locally modified content
372        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        // Target has new source hash (upstream changed)
379        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        // Lock has original hash
387        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        // Empty target — no items wanted
408        let target = TargetState {
409            items: IndexMap::new(),
410        };
411
412        // Lock has an item
413        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        // When mars rewrites frontmatter, source_checksum != installed_checksum.
432        // The disk should match installed_checksum (what mars wrote).
433        // This should NOT be detected as a local modification.
434        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        // Disk has the mars-rewritten content
441        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        // Target has same source hash as before (no upstream change)
448        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        // Lock has different source_checksum and installed_checksum
458        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        // Should be Unchanged because disk matches installed_checksum
472        // and source_hash matches source_checksum
473        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        // Write unchanged file
491        fs::write(agents_dir.join("stable.md"), b"# unchanged").unwrap();
492
493        // Write file with old content (will be updated)
494        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); // Unchanged + Update + Add + Orphan
539
540        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        // Disk matches prior conflicted content from last sync.
577        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}