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            origin: crate::types::SourceOrigin::Dependency(SourceName::from("test-source")),
179            materialization: crate::types::Materialization::Copy,
180            source_id: crate::types::SourceId::Path {
181                canonical: source_path.clone(),
182            },
183            source_path,
184            dest_path: dest_path.into(),
185            source_hash: ContentHash::from(source_hash),
186            is_flat_skill: false,
187            rewritten_content: None,
188        }
189    }
190
191    fn make_locked_item(
192        name: &str,
193        kind: ItemKind,
194        source_checksum: &str,
195        installed_checksum: &str,
196    ) -> LockedItem {
197        let dest_path = match kind {
198            ItemKind::Agent => format!("agents/{name}.md"),
199            ItemKind::Skill => format!("skills/{name}"),
200        };
201        LockedItem {
202            source: SourceName::from("test-source"),
203            kind,
204            version: None,
205            source_checksum: ContentHash::from(source_checksum),
206            installed_checksum: ContentHash::from(installed_checksum),
207            dest_path: dest_path.into(),
208        }
209    }
210
211    #[test]
212    fn new_item_produces_add() {
213        let root = TempDir::new().unwrap();
214        let source_dir = TempDir::new().unwrap();
215        let source_path = source_dir.path().join("agents/coder.md");
216        fs::create_dir_all(source_dir.path().join("agents")).unwrap();
217        fs::write(&source_path, "# new agent").unwrap();
218
219        let hash = hash::hash_bytes(b"# new agent");
220
221        let target_item = make_target_item("coder", ItemKind::Agent, &hash, source_path);
222        let mut target_items = IndexMap::new();
223        target_items.insert("agents/coder.md".into(), target_item);
224        let target = TargetState {
225            items: target_items,
226        };
227
228        let lock = LockFile::empty();
229        let diff = compute(root.path(), &lock, &target, false).unwrap();
230
231        assert_eq!(diff.items.len(), 1);
232        assert!(matches!(&diff.items[0], DiffEntry::Add { .. }));
233    }
234
235    #[test]
236    fn unchanged_item_produces_unchanged() {
237        let root = TempDir::new().unwrap();
238        let content = b"# existing agent";
239        let hash = hash::hash_bytes(content);
240
241        // Write file to disk
242        let agents_dir = root.path().join("agents");
243        fs::create_dir_all(&agents_dir).unwrap();
244        fs::write(agents_dir.join("coder.md"), content).unwrap();
245
246        let source_path = PathBuf::from("/tmp/source/agents/coder.md");
247
248        let target_item = make_target_item("coder", ItemKind::Agent, &hash, source_path);
249        let mut target_items = IndexMap::new();
250        target_items.insert("agents/coder.md".into(), target_item);
251        let target = TargetState {
252            items: target_items,
253        };
254
255        let locked_item = make_locked_item("coder", ItemKind::Agent, &hash, &hash);
256        let mut lock_items = IndexMap::new();
257        lock_items.insert("agents/coder.md".into(), locked_item);
258        let lock = LockFile {
259            version: 1,
260            dependencies: IndexMap::new(),
261            items: lock_items,
262        };
263
264        let diff = compute(root.path(), &lock, &target, false).unwrap();
265        assert_eq!(diff.items.len(), 1);
266        assert!(matches!(&diff.items[0], DiffEntry::Unchanged { .. }));
267    }
268
269    #[test]
270    fn source_changed_local_unchanged_produces_update() {
271        let root = TempDir::new().unwrap();
272        let old_content = b"# old version";
273        let old_hash = hash::hash_bytes(old_content);
274        let new_hash = hash::hash_bytes(b"# new version");
275
276        // Write old content to disk (matching lock's installed_checksum)
277        let agents_dir = root.path().join("agents");
278        fs::create_dir_all(&agents_dir).unwrap();
279        fs::write(agents_dir.join("coder.md"), old_content).unwrap();
280
281        let source_path = PathBuf::from("/tmp/source/agents/coder.md");
282
283        // Target has new hash
284        let target_item = make_target_item("coder", ItemKind::Agent, &new_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        // Lock has old hash
292        let locked_item = make_locked_item("coder", ItemKind::Agent, &old_hash, &old_hash);
293        let mut lock_items = IndexMap::new();
294        lock_items.insert("agents/coder.md".into(), locked_item);
295        let lock = LockFile {
296            version: 1,
297            dependencies: IndexMap::new(),
298            items: lock_items,
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::Update { .. }));
304    }
305
306    #[test]
307    fn local_changed_source_unchanged_produces_local_modified() {
308        let root = TempDir::new().unwrap();
309        let original_content = b"# original";
310        let original_hash = hash::hash_bytes(original_content);
311        let local_content = b"# locally modified";
312
313        // Write locally modified content to disk
314        let agents_dir = root.path().join("agents");
315        fs::create_dir_all(&agents_dir).unwrap();
316        fs::write(agents_dir.join("coder.md"), local_content).unwrap();
317
318        let source_path = PathBuf::from("/tmp/source/agents/coder.md");
319
320        // Target has same source hash as lock (no upstream change)
321        let target_item = make_target_item("coder", ItemKind::Agent, &original_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 also has original hash
329        let locked_item =
330            make_locked_item("coder", ItemKind::Agent, &original_hash, &original_hash);
331        let mut lock_items = IndexMap::new();
332        lock_items.insert("agents/coder.md".into(), locked_item);
333        let lock = LockFile {
334            version: 1,
335            dependencies: IndexMap::new(),
336            items: lock_items,
337        };
338
339        let diff = compute(root.path(), &lock, &target, false).unwrap();
340        assert_eq!(diff.items.len(), 1);
341        assert!(matches!(&diff.items[0], DiffEntry::LocalModified { .. }));
342    }
343
344    #[test]
345    fn both_changed_produces_conflict() {
346        let root = TempDir::new().unwrap();
347        let original_hash = hash::hash_bytes(b"# original");
348        let new_source_hash = hash::hash_bytes(b"# new upstream");
349        let local_content = b"# locally modified";
350
351        // Write locally modified content
352        let agents_dir = root.path().join("agents");
353        fs::create_dir_all(&agents_dir).unwrap();
354        fs::write(agents_dir.join("coder.md"), local_content).unwrap();
355
356        let source_path = PathBuf::from("/tmp/source/agents/coder.md");
357
358        // Target has new source hash (upstream changed)
359        let target_item = make_target_item("coder", ItemKind::Agent, &new_source_hash, source_path);
360        let mut target_items = IndexMap::new();
361        target_items.insert("agents/coder.md".into(), target_item);
362        let target = TargetState {
363            items: target_items,
364        };
365
366        // Lock has original hash
367        let locked_item =
368            make_locked_item("coder", ItemKind::Agent, &original_hash, &original_hash);
369        let mut lock_items = IndexMap::new();
370        lock_items.insert("agents/coder.md".into(), locked_item);
371        let lock = LockFile {
372            version: 1,
373            dependencies: IndexMap::new(),
374            items: lock_items,
375        };
376
377        let diff = compute(root.path(), &lock, &target, false).unwrap();
378        assert_eq!(diff.items.len(), 1);
379        assert!(matches!(&diff.items[0], DiffEntry::Conflict { .. }));
380    }
381
382    #[test]
383    fn orphan_detected() {
384        let root = TempDir::new().unwrap();
385
386        // Empty target — no items wanted
387        let target = TargetState {
388            items: IndexMap::new(),
389        };
390
391        // Lock has an item
392        let locked_item =
393            make_locked_item("old-agent", ItemKind::Agent, "sha256:aaa", "sha256:aaa");
394        let mut lock_items = IndexMap::new();
395        lock_items.insert("agents/old-agent.md".into(), locked_item);
396        let lock = LockFile {
397            version: 1,
398            dependencies: IndexMap::new(),
399            items: lock_items,
400        };
401
402        let diff = compute(root.path(), &lock, &target, false).unwrap();
403        assert_eq!(diff.items.len(), 1);
404        assert!(matches!(&diff.items[0], DiffEntry::Orphan { .. }));
405    }
406
407    #[test]
408    fn dual_checksum_prevents_false_conflict() {
409        // When mars rewrites frontmatter, source_checksum != installed_checksum.
410        // The disk should match installed_checksum (what mars wrote).
411        // This should NOT be detected as a local modification.
412        let root = TempDir::new().unwrap();
413
414        let source_hash = hash::hash_bytes(b"# original source");
415        let installed_content = b"# rewritten by mars";
416        let installed_hash = hash::hash_bytes(installed_content);
417
418        // Disk has the mars-rewritten content
419        let agents_dir = root.path().join("agents");
420        fs::create_dir_all(&agents_dir).unwrap();
421        fs::write(agents_dir.join("coder.md"), installed_content).unwrap();
422
423        let source_path = PathBuf::from("/tmp/source/agents/coder.md");
424
425        // Target has same source hash as before (no upstream change)
426        let target_item = make_target_item("coder", ItemKind::Agent, &source_hash, source_path);
427        let mut target_items = IndexMap::new();
428        target_items.insert("agents/coder.md".into(), target_item);
429        let target = TargetState {
430            items: target_items,
431        };
432
433        // Lock has different source_checksum and installed_checksum
434        let locked_item = make_locked_item("coder", ItemKind::Agent, &source_hash, &installed_hash);
435        let mut lock_items = IndexMap::new();
436        lock_items.insert("agents/coder.md".into(), locked_item);
437        let lock = LockFile {
438            version: 1,
439            dependencies: IndexMap::new(),
440            items: lock_items,
441        };
442
443        let diff = compute(root.path(), &lock, &target, false).unwrap();
444        assert_eq!(diff.items.len(), 1);
445        // Should be Unchanged because disk matches installed_checksum
446        // and source_hash matches source_checksum
447        assert!(
448            matches!(&diff.items[0], DiffEntry::Unchanged { .. }),
449            "expected Unchanged, got {:?}",
450            diff.items[0]
451        );
452    }
453
454    #[test]
455    fn mixed_diff_entries() {
456        let root = TempDir::new().unwrap();
457        let agents_dir = root.path().join("agents");
458        fs::create_dir_all(&agents_dir).unwrap();
459
460        let hash_a = hash::hash_bytes(b"# unchanged");
461        let hash_b_old = hash::hash_bytes(b"# old version");
462        let hash_b_new = hash::hash_bytes(b"# new version");
463
464        // Write unchanged file
465        fs::write(agents_dir.join("stable.md"), b"# unchanged").unwrap();
466
467        // Write file with old content (will be updated)
468        fs::write(agents_dir.join("updating.md"), b"# old version").unwrap();
469
470        let source_path_a = PathBuf::from("/tmp/source/agents/stable.md");
471        let source_path_b = PathBuf::from("/tmp/source/agents/updating.md");
472        let source_path_c = PathBuf::from("/tmp/source/agents/new.md");
473
474        let mut target_items = IndexMap::new();
475        target_items.insert(
476            "agents/stable.md".into(),
477            make_target_item("stable", ItemKind::Agent, &hash_a, source_path_a),
478        );
479        target_items.insert(
480            "agents/updating.md".into(),
481            make_target_item("updating", ItemKind::Agent, &hash_b_new, source_path_b),
482        );
483        target_items.insert(
484            "agents/new.md".into(),
485            make_target_item(
486                "new",
487                ItemKind::Agent,
488                &hash::hash_bytes(b"# brand new"),
489                source_path_c,
490            ),
491        );
492        let target = TargetState {
493            items: target_items,
494        };
495
496        let mut lock_items = IndexMap::new();
497        lock_items.insert(
498            "agents/stable.md".into(),
499            make_locked_item("stable", ItemKind::Agent, &hash_a, &hash_a),
500        );
501        lock_items.insert(
502            "agents/updating.md".into(),
503            make_locked_item("updating", ItemKind::Agent, &hash_b_old, &hash_b_old),
504        );
505        lock_items.insert(
506            "agents/orphan.md".into(),
507            make_locked_item("orphan", ItemKind::Agent, "sha256:xxx", "sha256:xxx"),
508        );
509        let lock = LockFile {
510            version: 1,
511            dependencies: IndexMap::new(),
512            items: lock_items,
513        };
514
515        let diff = compute(root.path(), &lock, &target, false).unwrap();
516        assert_eq!(diff.items.len(), 4); // Unchanged + Update + Add + Orphan
517
518        let unchanged_count = diff
519            .items
520            .iter()
521            .filter(|d| matches!(d, DiffEntry::Unchanged { .. }))
522            .count();
523        let update_count = diff
524            .items
525            .iter()
526            .filter(|d| matches!(d, DiffEntry::Update { .. }))
527            .count();
528        let add_count = diff
529            .items
530            .iter()
531            .filter(|d| matches!(d, DiffEntry::Add { .. }))
532            .count();
533        let orphan_count = diff
534            .items
535            .iter()
536            .filter(|d| matches!(d, DiffEntry::Orphan { .. }))
537            .count();
538
539        assert_eq!(unchanged_count, 1);
540        assert_eq!(update_count, 1);
541        assert_eq!(add_count, 1);
542        assert_eq!(orphan_count, 1);
543    }
544
545    #[test]
546    fn force_uses_source_checksum_for_local_change_detection() {
547        let root = TempDir::new().unwrap();
548        let upstream_content = b"# upstream";
549        let conflicted_content = b"<<<<<<< local\n# local\n=======\n# upstream\n>>>>>>> upstream\n";
550
551        let source_hash = hash::hash_bytes(upstream_content);
552        let installed_hash = hash::hash_bytes(conflicted_content);
553
554        // Disk matches prior conflicted content from last sync.
555        let agents_dir = root.path().join("agents");
556        fs::create_dir_all(&agents_dir).unwrap();
557        fs::write(agents_dir.join("coder.md"), conflicted_content).unwrap();
558
559        let mut target_items = IndexMap::new();
560        target_items.insert(
561            "agents/coder.md".into(),
562            make_target_item(
563                "coder",
564                ItemKind::Agent,
565                &source_hash,
566                PathBuf::from("/tmp/source/agents/coder.md"),
567            ),
568        );
569        let target = TargetState {
570            items: target_items,
571        };
572
573        let mut lock_items = IndexMap::new();
574        lock_items.insert(
575            "agents/coder.md".into(),
576            LockedItem {
577                source: "test-source".into(),
578                kind: ItemKind::Agent,
579                version: None,
580                source_checksum: source_hash.clone().into(),
581                installed_checksum: installed_hash.into(),
582                dest_path: "agents/coder.md".into(),
583            },
584        );
585        let lock = LockFile {
586            version: 1,
587            dependencies: IndexMap::new(),
588            items: lock_items,
589        };
590
591        let normal = compute(root.path(), &lock, &target, false).unwrap();
592        assert!(matches!(&normal.items[0], DiffEntry::Unchanged { .. }));
593
594        let forced = compute(root.path(), &lock, &target, true).unwrap();
595        assert!(matches!(&forced.items[0], DiffEntry::LocalModified { .. }));
596    }
597}