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