Skip to main content

mars_agents/sync/
diff.rs

1use std::path::Path;
2
3use crate::error::MarsError;
4use crate::hash;
5use crate::lock::{LockFile, 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
61    // Process each target item
62    for (_dest_key, target_item) in &target.items {
63        if let Some(locked_item) = lock.items.get(&target_item.dest_path) {
64            // Item exists in lock — compare checksums
65            let source_changed = target_item.source_hash != locked_item.source_checksum
66                || rewritten_installed_checksum(target_item)
67                    .is_some_and(|checksum| checksum != locked_item.installed_checksum);
68
69            // Check disk hash against the expected baseline.
70            // In --force mode, baseline is source_checksum so conflicted files
71            // are treated as local modifications and get overwritten.
72            let expected_disk_checksum = if force {
73                &locked_item.source_checksum
74            } else {
75                &locked_item.installed_checksum
76            };
77
78            let disk_path = target_item.dest_path.resolve(root);
79            let local_changed = if disk_path.exists() {
80                let disk_hash = hash::compute_hash(&disk_path, target_item.id.kind)?;
81                let disk_hash = ContentHash::from(disk_hash);
82                if disk_hash != *expected_disk_checksum {
83                    Some(disk_hash)
84                } else {
85                    None
86                }
87            } else {
88                // File was deleted locally — treat as if local changed to "nothing"
89                // In this case, we should reinstall it
90                None
91            };
92
93            match (source_changed, &local_changed) {
94                (false, None) => {
95                    // Neither changed → skip
96                    if disk_path.exists() {
97                        items.push(DiffEntry::Unchanged {
98                            target: target_item.clone(),
99                            locked: locked_item.clone(),
100                        });
101                    } else {
102                        // File was deleted but hashes match lock — reinstall
103                        items.push(DiffEntry::Add {
104                            target: target_item.clone(),
105                        });
106                    }
107                }
108                (true, None) => {
109                    // Source changed, local unchanged → clean update
110                    items.push(DiffEntry::Update {
111                        target: target_item.clone(),
112                        locked: locked_item.clone(),
113                    });
114                }
115                (false, Some(local_hash)) => {
116                    // Local changed, source unchanged → keep local
117                    items.push(DiffEntry::LocalModified {
118                        target: target_item.clone(),
119                        locked: locked_item.clone(),
120                        local_hash: local_hash.clone(),
121                    });
122                }
123                (true, Some(local_hash)) => {
124                    // Both changed → conflict
125                    items.push(DiffEntry::Conflict {
126                        target: target_item.clone(),
127                        locked: locked_item.clone(),
128                        local_hash: local_hash.clone(),
129                    });
130                }
131            }
132        } else {
133            // Not in lock → new item
134            items.push(DiffEntry::Add {
135                target: target_item.clone(),
136            });
137        }
138    }
139
140    // Find orphans: items in lock but not in target
141    for (dest_path, locked_item) in &lock.items {
142        if !target.items.contains_key(dest_path) {
143            items.push(DiffEntry::Orphan {
144                locked: locked_item.clone(),
145            });
146        }
147    }
148
149    Ok(SyncDiff { items })
150}
151
152fn rewritten_installed_checksum(target_item: &TargetItem) -> Option<ContentHash> {
153    target_item
154        .rewritten_content
155        .as_ref()
156        .map(|content| ContentHash::from(hash::hash_bytes(content.as_bytes())))
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use crate::hash;
163    use crate::lock::{ItemId, ItemKind, LockedItem};
164    use crate::types::{ItemName, SourceName};
165    use indexmap::IndexMap;
166    use std::fs;
167    use std::path::PathBuf;
168    use tempfile::TempDir;
169
170    /// Create a minimal target item for testing.
171    fn make_target_item(
172        name: &str,
173        kind: ItemKind,
174        source_hash: &str,
175        source_path: PathBuf,
176    ) -> TargetItem {
177        let dest_path = match kind {
178            ItemKind::Agent => PathBuf::from("agents").join(format!("{name}.md")),
179            ItemKind::Skill => PathBuf::from("skills").join(name),
180        };
181        TargetItem {
182            id: ItemId {
183                kind,
184                name: ItemName::from(name),
185            },
186            source_name: SourceName::from("test-source"),
187            origin: crate::types::SourceOrigin::Dependency(SourceName::from("test-source")),
188            source_id: crate::types::SourceId::Path {
189                canonical: source_path.clone(),
190                subpath: None,
191            },
192            source_path,
193            dest_path: dest_path.to_string_lossy().to_string().into(),
194            source_hash: ContentHash::from(source_hash),
195            is_flat_skill: false,
196            rewritten_content: None,
197        }
198    }
199
200    fn make_locked_item(
201        name: &str,
202        kind: ItemKind,
203        source_checksum: &str,
204        installed_checksum: &str,
205    ) -> LockedItem {
206        let dest_path = match kind {
207            ItemKind::Agent => format!("agents/{name}.md"),
208            ItemKind::Skill => format!("skills/{name}"),
209        };
210        LockedItem {
211            source: SourceName::from("test-source"),
212            kind,
213            version: None,
214            source_checksum: ContentHash::from(source_checksum),
215            installed_checksum: ContentHash::from(installed_checksum),
216            dest_path: dest_path.into(),
217        }
218    }
219
220    #[test]
221    fn new_item_produces_add() {
222        let root = TempDir::new().unwrap();
223        let source_dir = TempDir::new().unwrap();
224        let source_path = source_dir.path().join("agents/coder.md");
225        fs::create_dir_all(source_dir.path().join("agents")).unwrap();
226        fs::write(&source_path, "# new agent").unwrap();
227
228        let hash = hash::hash_bytes(b"# new agent");
229
230        let target_item = make_target_item("coder", ItemKind::Agent, &hash, source_path);
231        let mut target_items = IndexMap::new();
232        target_items.insert("agents/coder.md".into(), target_item);
233        let target = TargetState {
234            items: target_items,
235        };
236
237        let lock = LockFile::empty();
238        let diff = compute(root.path(), &lock, &target, false).unwrap();
239
240        assert_eq!(diff.items.len(), 1);
241        assert!(matches!(&diff.items[0], DiffEntry::Add { .. }));
242    }
243
244    #[test]
245    fn unchanged_item_produces_unchanged() {
246        let root = TempDir::new().unwrap();
247        let content = b"# existing agent";
248        let hash = hash::hash_bytes(content);
249
250        // Write file to disk
251        let agents_dir = root.path().join("agents");
252        fs::create_dir_all(&agents_dir).unwrap();
253        fs::write(agents_dir.join("coder.md"), content).unwrap();
254
255        let source_path = PathBuf::from("/tmp/source/agents/coder.md");
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 locked_item = make_locked_item("coder", ItemKind::Agent, &hash, &hash);
265        let mut lock_items = IndexMap::new();
266        lock_items.insert("agents/coder.md".into(), locked_item);
267        let lock = LockFile {
268            version: 1,
269            dependencies: IndexMap::new(),
270            items: lock_items,
271        };
272
273        let diff = compute(root.path(), &lock, &target, false).unwrap();
274        assert_eq!(diff.items.len(), 1);
275        assert!(matches!(&diff.items[0], DiffEntry::Unchanged { .. }));
276    }
277
278    #[test]
279    fn source_changed_local_unchanged_produces_update() {
280        let root = TempDir::new().unwrap();
281        let old_content = b"# old version";
282        let old_hash = hash::hash_bytes(old_content);
283        let new_hash = hash::hash_bytes(b"# new version");
284
285        // Write old content to disk (matching lock's installed_checksum)
286        let agents_dir = root.path().join("agents");
287        fs::create_dir_all(&agents_dir).unwrap();
288        fs::write(agents_dir.join("coder.md"), old_content).unwrap();
289
290        let source_path = PathBuf::from("/tmp/source/agents/coder.md");
291
292        // Target has new hash
293        let target_item = make_target_item("coder", ItemKind::Agent, &new_hash, source_path);
294        let mut target_items = IndexMap::new();
295        target_items.insert("agents/coder.md".into(), target_item);
296        let target = TargetState {
297            items: target_items,
298        };
299
300        // Lock has old hash
301        let locked_item = make_locked_item("coder", ItemKind::Agent, &old_hash, &old_hash);
302        let mut lock_items = IndexMap::new();
303        lock_items.insert("agents/coder.md".into(), locked_item);
304        let lock = LockFile {
305            version: 1,
306            dependencies: IndexMap::new(),
307            items: lock_items,
308        };
309
310        let diff = compute(root.path(), &lock, &target, false).unwrap();
311        assert_eq!(diff.items.len(), 1);
312        assert!(matches!(&diff.items[0], DiffEntry::Update { .. }));
313    }
314
315    #[test]
316    fn local_changed_source_unchanged_produces_local_modified() {
317        let root = TempDir::new().unwrap();
318        let original_content = b"# original";
319        let original_hash = hash::hash_bytes(original_content);
320        let local_content = b"# locally modified";
321
322        // Write locally modified content to disk
323        let agents_dir = root.path().join("agents");
324        fs::create_dir_all(&agents_dir).unwrap();
325        fs::write(agents_dir.join("coder.md"), local_content).unwrap();
326
327        let source_path = PathBuf::from("/tmp/source/agents/coder.md");
328
329        // Target has same source hash as lock (no upstream change)
330        let target_item = make_target_item("coder", ItemKind::Agent, &original_hash, source_path);
331        let mut target_items = IndexMap::new();
332        target_items.insert("agents/coder.md".into(), target_item);
333        let target = TargetState {
334            items: target_items,
335        };
336
337        // Lock also has original hash
338        let locked_item =
339            make_locked_item("coder", ItemKind::Agent, &original_hash, &original_hash);
340        let mut lock_items = IndexMap::new();
341        lock_items.insert("agents/coder.md".into(), locked_item);
342        let lock = LockFile {
343            version: 1,
344            dependencies: IndexMap::new(),
345            items: lock_items,
346        };
347
348        let diff = compute(root.path(), &lock, &target, false).unwrap();
349        assert_eq!(diff.items.len(), 1);
350        assert!(matches!(&diff.items[0], DiffEntry::LocalModified { .. }));
351    }
352
353    #[test]
354    fn both_changed_produces_conflict() {
355        let root = TempDir::new().unwrap();
356        let original_hash = hash::hash_bytes(b"# original");
357        let new_source_hash = hash::hash_bytes(b"# new upstream");
358        let local_content = b"# locally modified";
359
360        // Write locally modified content
361        let agents_dir = root.path().join("agents");
362        fs::create_dir_all(&agents_dir).unwrap();
363        fs::write(agents_dir.join("coder.md"), local_content).unwrap();
364
365        let source_path = PathBuf::from("/tmp/source/agents/coder.md");
366
367        // Target has new source hash (upstream changed)
368        let target_item = make_target_item("coder", ItemKind::Agent, &new_source_hash, source_path);
369        let mut target_items = IndexMap::new();
370        target_items.insert("agents/coder.md".into(), target_item);
371        let target = TargetState {
372            items: target_items,
373        };
374
375        // Lock has original hash
376        let locked_item =
377            make_locked_item("coder", ItemKind::Agent, &original_hash, &original_hash);
378        let mut lock_items = IndexMap::new();
379        lock_items.insert("agents/coder.md".into(), locked_item);
380        let lock = LockFile {
381            version: 1,
382            dependencies: IndexMap::new(),
383            items: lock_items,
384        };
385
386        let diff = compute(root.path(), &lock, &target, false).unwrap();
387        assert_eq!(diff.items.len(), 1);
388        assert!(matches!(&diff.items[0], DiffEntry::Conflict { .. }));
389    }
390
391    #[test]
392    fn orphan_detected() {
393        let root = TempDir::new().unwrap();
394
395        // Empty target — no items wanted
396        let target = TargetState {
397            items: IndexMap::new(),
398        };
399
400        // Lock has an item
401        let locked_item =
402            make_locked_item("old-agent", ItemKind::Agent, "sha256:aaa", "sha256:aaa");
403        let mut lock_items = IndexMap::new();
404        lock_items.insert("agents/old-agent.md".into(), locked_item);
405        let lock = LockFile {
406            version: 1,
407            dependencies: IndexMap::new(),
408            items: lock_items,
409        };
410
411        let diff = compute(root.path(), &lock, &target, false).unwrap();
412        assert_eq!(diff.items.len(), 1);
413        assert!(matches!(&diff.items[0], DiffEntry::Orphan { .. }));
414    }
415
416    #[test]
417    fn dual_checksum_prevents_false_conflict() {
418        // When mars rewrites frontmatter, source_checksum != installed_checksum.
419        // The disk should match installed_checksum (what mars wrote).
420        // This should NOT be detected as a local modification.
421        let root = TempDir::new().unwrap();
422
423        let source_hash = hash::hash_bytes(b"# original source");
424        let installed_content = b"# rewritten by mars";
425        let installed_hash = hash::hash_bytes(installed_content);
426
427        // Disk has the mars-rewritten content
428        let agents_dir = root.path().join("agents");
429        fs::create_dir_all(&agents_dir).unwrap();
430        fs::write(agents_dir.join("coder.md"), installed_content).unwrap();
431
432        let source_path = PathBuf::from("/tmp/source/agents/coder.md");
433
434        // Target has same source hash as before (no upstream change)
435        let target_item = make_target_item("coder", ItemKind::Agent, &source_hash, source_path);
436        let mut target_items = IndexMap::new();
437        target_items.insert("agents/coder.md".into(), target_item);
438        let target = TargetState {
439            items: target_items,
440        };
441
442        // Lock has different source_checksum and installed_checksum
443        let locked_item = make_locked_item("coder", ItemKind::Agent, &source_hash, &installed_hash);
444        let mut lock_items = IndexMap::new();
445        lock_items.insert("agents/coder.md".into(), locked_item);
446        let lock = LockFile {
447            version: 1,
448            dependencies: IndexMap::new(),
449            items: lock_items,
450        };
451
452        let diff = compute(root.path(), &lock, &target, false).unwrap();
453        assert_eq!(diff.items.len(), 1);
454        // Should be Unchanged because disk matches installed_checksum
455        // and source_hash matches source_checksum
456        assert!(
457            matches!(&diff.items[0], DiffEntry::Unchanged { .. }),
458            "expected Unchanged, got {:?}",
459            diff.items[0]
460        );
461    }
462
463    #[test]
464    fn mixed_diff_entries() {
465        let root = TempDir::new().unwrap();
466        let agents_dir = root.path().join("agents");
467        fs::create_dir_all(&agents_dir).unwrap();
468
469        let hash_a = hash::hash_bytes(b"# unchanged");
470        let hash_b_old = hash::hash_bytes(b"# old version");
471        let hash_b_new = hash::hash_bytes(b"# new version");
472
473        // Write unchanged file
474        fs::write(agents_dir.join("stable.md"), b"# unchanged").unwrap();
475
476        // Write file with old content (will be updated)
477        fs::write(agents_dir.join("updating.md"), b"# old version").unwrap();
478
479        let source_path_a = PathBuf::from("/tmp/source/agents/stable.md");
480        let source_path_b = PathBuf::from("/tmp/source/agents/updating.md");
481        let source_path_c = PathBuf::from("/tmp/source/agents/new.md");
482
483        let mut target_items = IndexMap::new();
484        target_items.insert(
485            "agents/stable.md".into(),
486            make_target_item("stable", ItemKind::Agent, &hash_a, source_path_a),
487        );
488        target_items.insert(
489            "agents/updating.md".into(),
490            make_target_item("updating", ItemKind::Agent, &hash_b_new, source_path_b),
491        );
492        target_items.insert(
493            "agents/new.md".into(),
494            make_target_item(
495                "new",
496                ItemKind::Agent,
497                &hash::hash_bytes(b"# brand new"),
498                source_path_c,
499            ),
500        );
501        let target = TargetState {
502            items: target_items,
503        };
504
505        let mut lock_items = IndexMap::new();
506        lock_items.insert(
507            "agents/stable.md".into(),
508            make_locked_item("stable", ItemKind::Agent, &hash_a, &hash_a),
509        );
510        lock_items.insert(
511            "agents/updating.md".into(),
512            make_locked_item("updating", ItemKind::Agent, &hash_b_old, &hash_b_old),
513        );
514        lock_items.insert(
515            "agents/orphan.md".into(),
516            make_locked_item("orphan", ItemKind::Agent, "sha256:xxx", "sha256:xxx"),
517        );
518        let lock = LockFile {
519            version: 1,
520            dependencies: IndexMap::new(),
521            items: lock_items,
522        };
523
524        let diff = compute(root.path(), &lock, &target, false).unwrap();
525        assert_eq!(diff.items.len(), 4); // Unchanged + Update + Add + Orphan
526
527        let unchanged_count = diff
528            .items
529            .iter()
530            .filter(|d| matches!(d, DiffEntry::Unchanged { .. }))
531            .count();
532        let update_count = diff
533            .items
534            .iter()
535            .filter(|d| matches!(d, DiffEntry::Update { .. }))
536            .count();
537        let add_count = diff
538            .items
539            .iter()
540            .filter(|d| matches!(d, DiffEntry::Add { .. }))
541            .count();
542        let orphan_count = diff
543            .items
544            .iter()
545            .filter(|d| matches!(d, DiffEntry::Orphan { .. }))
546            .count();
547
548        assert_eq!(unchanged_count, 1);
549        assert_eq!(update_count, 1);
550        assert_eq!(add_count, 1);
551        assert_eq!(orphan_count, 1);
552    }
553
554    #[test]
555    fn force_uses_source_checksum_for_local_change_detection() {
556        let root = TempDir::new().unwrap();
557        let upstream_content = b"# upstream";
558        let conflicted_content = b"<<<<<<< local\n# local\n=======\n# upstream\n>>>>>>> upstream\n";
559
560        let source_hash = hash::hash_bytes(upstream_content);
561        let installed_hash = hash::hash_bytes(conflicted_content);
562
563        // Disk matches prior conflicted content from last sync.
564        let agents_dir = root.path().join("agents");
565        fs::create_dir_all(&agents_dir).unwrap();
566        fs::write(agents_dir.join("coder.md"), conflicted_content).unwrap();
567
568        let mut target_items = IndexMap::new();
569        target_items.insert(
570            "agents/coder.md".into(),
571            make_target_item(
572                "coder",
573                ItemKind::Agent,
574                &source_hash,
575                PathBuf::from("/tmp/source/agents/coder.md"),
576            ),
577        );
578        let target = TargetState {
579            items: target_items,
580        };
581
582        let mut lock_items = IndexMap::new();
583        lock_items.insert(
584            "agents/coder.md".into(),
585            LockedItem {
586                source: "test-source".into(),
587                kind: ItemKind::Agent,
588                version: None,
589                source_checksum: source_hash.clone().into(),
590                installed_checksum: installed_hash.into(),
591                dest_path: "agents/coder.md".into(),
592            },
593        );
594        let lock = LockFile {
595            version: 1,
596            dependencies: IndexMap::new(),
597            items: lock_items,
598        };
599
600        let normal = compute(root.path(), &lock, &target, false).unwrap();
601        assert!(matches!(&normal.items[0], DiffEntry::Unchanged { .. }));
602
603        let forced = compute(root.path(), &lock, &target, true).unwrap();
604        assert!(matches!(&forced.items[0], DiffEntry::LocalModified { .. }));
605    }
606
607    #[test]
608    fn rewritten_content_change_produces_update() {
609        let root = TempDir::new().unwrap();
610
611        let source_content = b"---\nskills:\n- planning\n---\n# Agent\n";
612        let source_hash = hash::hash_bytes(source_content);
613        let old_installed_content = b"---\nskills:\n- planning\n---\n# Agent\n";
614        let old_installed_hash = hash::hash_bytes(old_installed_content);
615        let rewritten_content = "---\nskills:\n- strategy\n---\n# Agent\n";
616        let rewritten_hash = hash::hash_bytes(rewritten_content.as_bytes());
617
618        let agents_dir = root.path().join("agents");
619        fs::create_dir_all(&agents_dir).unwrap();
620        fs::write(agents_dir.join("coder.md"), old_installed_content).unwrap();
621
622        let mut target_items = IndexMap::new();
623        target_items.insert(
624            "agents/coder.md".into(),
625            TargetItem {
626                id: ItemId {
627                    kind: ItemKind::Agent,
628                    name: "coder".into(),
629                },
630                source_name: SourceName::from("test-source"),
631                origin: crate::types::SourceOrigin::Dependency(SourceName::from("test-source")),
632                source_id: crate::types::SourceId::Path {
633                    canonical: PathBuf::from("/tmp/source/agents/coder.md"),
634                    subpath: None,
635                },
636                source_path: PathBuf::from("/tmp/source/agents/coder.md"),
637                dest_path: "agents/coder.md".into(),
638                source_hash: source_hash.clone().into(),
639                is_flat_skill: false,
640                rewritten_content: Some(rewritten_content.to_string()),
641            },
642        );
643        let target = TargetState {
644            items: target_items,
645        };
646
647        let mut lock_items = IndexMap::new();
648        lock_items.insert(
649            "agents/coder.md".into(),
650            LockedItem {
651                source: SourceName::from("test-source"),
652                kind: ItemKind::Agent,
653                version: None,
654                source_checksum: source_hash.into(),
655                installed_checksum: old_installed_hash.clone().into(),
656                dest_path: "agents/coder.md".into(),
657            },
658        );
659        let lock = LockFile {
660            version: 1,
661            dependencies: IndexMap::new(),
662            items: lock_items,
663        };
664
665        let diff = compute(root.path(), &lock, &target, false).unwrap();
666        assert_eq!(diff.items.len(), 1);
667        assert!(matches!(&diff.items[0], DiffEntry::Update { .. }));
668
669        assert_ne!(rewritten_hash, old_installed_hash);
670    }
671}