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