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 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 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 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 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 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 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 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 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 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 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 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 let target = TargetState {
388 items: IndexMap::new(),
389 };
390
391 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 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 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 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 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 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 fs::write(agents_dir.join("stable.md"), b"# unchanged").unwrap();
466
467 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); 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 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}