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 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            // Check disk hash against the expected baseline.
74            // In --force mode, baseline is source_checksum so conflicted files
75            // are treated as local modifications and get overwritten.
76            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                // File was deleted locally — treat as if local changed to "nothing"
94                // In this case, we should reinstall it
95                None
96            };
97
98            match (source_changed, &local_changed) {
99                (false, None) => {
100                    // Neither changed → skip
101                    if hash_path.exists() {
102                        items.push(DiffEntry::Unchanged {
103                            target: target_item.clone(),
104                            locked: locked_item.clone(),
105                        });
106                    } else {
107                        // File was deleted but hashes match lock — reinstall
108                        items.push(DiffEntry::Add {
109                            target: target_item.clone(),
110                        });
111                    }
112                }
113                (true, None) => {
114                    // Source changed, local unchanged → clean update
115                    items.push(DiffEntry::Update {
116                        target: target_item.clone(),
117                        locked: locked_item.clone(),
118                    });
119                }
120                (false, Some(local_hash)) => {
121                    // Local changed, source unchanged → keep local
122                    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                    // Both changed → conflict
130                    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            // Not in lock → new item
139            items.push(DiffEntry::Add {
140                target: target_item.clone(),
141            });
142        }
143    }
144
145    // Find orphans: items in lock but not in target
146    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    /// Create a minimal target item for testing.
186    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    /// Build a v2 `(key, LockedItemV2)` pair for inserting into `LockFile.items`.
219    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        // Write file to disk
278        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        // Write old content to disk (matching lock's installed_checksum)
315        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        // Target has new hash
322        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        // Lock has old hash
330        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        // Write locally modified content to disk
354        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        // Target has same source hash as lock (no upstream change)
361        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        // Lock also has original hash
369        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        // Write locally modified content
393        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        // Target has new source hash (upstream changed)
400        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        // Lock has original hash
408        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        // Empty target — no items wanted
429        let target = TargetState {
430            items: IndexMap::new(),
431        };
432
433        // Lock has an item
434        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        // When mars rewrites frontmatter, source_checksum != installed_checksum.
453        // The disk should match installed_checksum (what mars wrote).
454        // This should NOT be detected as a local modification.
455        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        // Disk has the mars-rewritten content
462        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        // Target has same source hash as before (no upstream change)
469        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        // Lock has different source_checksum and installed_checksum
479        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        // Should be Unchanged because disk matches installed_checksum
493        // and source_hash matches source_checksum
494        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        // Write unchanged file
512        fs::write(agents_dir.join("stable.md"), b"# unchanged").unwrap();
513
514        // Write file with old content (will be updated)
515        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); // Unchanged + Update + Add + Orphan
560
561        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        // Disk matches prior conflicted content from last sync.
598        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}