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#[derive(Debug, Clone)]
11pub struct SyncDiff {
12 pub items: Vec<DiffEntry>,
13}
14
15#[derive(Debug, Clone)]
17pub enum DiffEntry {
18 Add { target: TargetItem },
20 Update {
22 target: TargetItem,
23 locked: LockedItem,
24 },
25 Unchanged {
27 target: TargetItem,
28 locked: LockedItem,
29 },
30 Conflict {
32 target: TargetItem,
33 locked: LockedItem,
34 local_hash: ContentHash,
35 },
36 Orphan { locked: LockedItem },
38 LocalModified {
40 target: TargetItem,
41 locked: LockedItem,
42 local_hash: ContentHash,
43 },
44}
45
46pub 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 for (_dest_key, target_item) in &target.items {
63 if let Some(locked_item) = lock.items.get(&target_item.dest_path) {
64 let source_changed = target_item.source_hash != locked_item.source_checksum;
66
67 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 None
89 };
90
91 match (source_changed, &local_changed) {
92 (false, None) => {
93 if disk_path.exists() {
95 items.push(DiffEntry::Unchanged {
96 target: target_item.clone(),
97 locked: locked_item.clone(),
98 });
99 } else {
100 items.push(DiffEntry::Add {
102 target: target_item.clone(),
103 });
104 }
105 }
106 (true, None) => {
107 items.push(DiffEntry::Update {
109 target: target_item.clone(),
110 locked: locked_item.clone(),
111 });
112 }
113 (false, Some(local_hash)) => {
114 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 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 items.push(DiffEntry::Add {
133 target: target_item.clone(),
134 });
135 }
136 }
137
138 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 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 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 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 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 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 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 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 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 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 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 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 let target = TargetState {
386 items: IndexMap::new(),
387 };
388
389 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 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 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 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 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 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 fs::write(agents_dir.join("stable.md"), b"# unchanged").unwrap();
464
465 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); 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 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}