1use std::path::PathBuf;
2
3use crate::lock::{ItemId, ItemKind, LockedItem};
4use crate::sync::diff::{DiffEntry, SyncDiff};
5use crate::sync::target::TargetItem;
6use crate::sync::types::SyncOptions;
7use crate::types::{DestPath, ItemName, Materialization, SourceName};
8
9#[derive(Debug, Clone)]
11pub struct SyncPlan {
12 pub actions: Vec<PlannedAction>,
13}
14
15#[derive(Debug, Clone)]
20pub enum PlannedAction {
21 Install { target: TargetItem },
23 Overwrite { target: TargetItem },
25 Skip {
27 item_id: ItemId,
28 dest_path: DestPath,
29 source_name: SourceName,
30 reason: &'static str,
31 },
32 Merge {
34 target: TargetItem,
35 base_content: Vec<u8>,
36 local_path: PathBuf,
37 },
38 Remove { locked: LockedItem },
40 KeepLocal {
42 item_id: ItemId,
43 dest_path: DestPath,
44 source_name: SourceName,
45 },
46 Symlink {
48 source_abs: PathBuf,
50 dest_rel: DestPath,
52 kind: ItemKind,
53 name: ItemName,
54 },
55}
56
57pub fn create(
62 diff: &SyncDiff,
63 options: &SyncOptions,
64 cache_bases_dir: &std::path::Path,
65) -> SyncPlan {
66 let local_source_name: SourceName = crate::types::SourceOrigin::LocalPackage.to_string().into();
67 let mut actions = Vec::new();
68
69 for entry in &diff.items {
70 match entry {
71 DiffEntry::Add { target } => match &target.materialization {
72 Materialization::Copy => {
73 actions.push(PlannedAction::Install {
74 target: target.clone(),
75 });
76 }
77 Materialization::Symlink { source_abs } => {
78 actions.push(symlink_action(target, source_abs));
79 }
80 },
81
82 DiffEntry::Update { target, locked: _ } => match &target.materialization {
83 Materialization::Copy => {
84 actions.push(PlannedAction::Overwrite {
85 target: target.clone(),
86 });
87 }
88 Materialization::Symlink { source_abs } => {
89 actions.push(symlink_action(target, source_abs));
90 }
91 },
92
93 DiffEntry::Unchanged { target, locked } => match &target.materialization {
94 Materialization::Symlink { source_abs } if locked.source != local_source_name => {
95 actions.push(symlink_action(target, source_abs));
96 }
97 _ => {
98 actions.push(PlannedAction::Skip {
99 item_id: target.id.clone(),
100 dest_path: target.dest_path.clone(),
101 source_name: target.source_name.clone(),
102 reason: "unchanged",
103 });
104 }
105 },
106
107 DiffEntry::Conflict {
108 target,
109 locked,
110 local_hash: _,
111 } => match &target.materialization {
112 Materialization::Symlink { source_abs } => {
113 actions.push(symlink_action(target, source_abs));
114 }
115 Materialization::Copy => {
116 if options.force {
117 actions.push(PlannedAction::Overwrite {
119 target: target.clone(),
120 });
121 } else {
122 let base_path = cache_bases_dir.join(locked.installed_checksum.as_ref());
124
125 let base_content = std::fs::read(&base_path).unwrap_or_default();
127
128 let local_path = locked.dest_path.as_path().to_path_buf();
130
131 actions.push(PlannedAction::Merge {
132 target: target.clone(),
133 base_content,
134 local_path,
135 });
136 }
137 }
138 },
139
140 DiffEntry::Orphan { locked } => {
141 actions.push(PlannedAction::Remove {
142 locked: locked.clone(),
143 });
144 }
145
146 DiffEntry::LocalModified {
147 target,
148 locked: _,
149 local_hash: _,
150 } => match &target.materialization {
151 Materialization::Symlink { source_abs } => {
152 actions.push(symlink_action(target, source_abs));
153 }
154 Materialization::Copy => {
155 if options.force {
156 actions.push(PlannedAction::Overwrite {
158 target: target.clone(),
159 });
160 } else {
161 actions.push(PlannedAction::KeepLocal {
162 item_id: target.id.clone(),
163 dest_path: target.dest_path.clone(),
164 source_name: target.source_name.clone(),
165 });
166 }
167 }
168 },
169 }
170 }
171
172 SyncPlan { actions }
173}
174
175fn symlink_action(target: &TargetItem, source_abs: &std::path::Path) -> PlannedAction {
176 PlannedAction::Symlink {
177 source_abs: source_abs.to_path_buf(),
178 dest_rel: target.dest_path.clone(),
179 kind: target.id.kind,
180 name: target.id.name.clone(),
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use crate::hash;
188 use crate::lock::{ItemId, ItemKind, LockedItem};
189 use crate::sync::diff::{DiffEntry, SyncDiff};
190 use crate::sync::target::TargetItem;
191 use std::path::PathBuf;
192 use tempfile::TempDir;
193
194 fn make_target(name: &str) -> TargetItem {
195 TargetItem {
196 id: ItemId {
197 kind: ItemKind::Agent,
198 name: name.into(),
199 },
200 source_name: "test".into(),
201 origin: crate::types::SourceOrigin::Dependency("test".into()),
202 materialization: crate::types::Materialization::Copy,
203 source_id: crate::types::SourceId::Path {
204 canonical: PathBuf::from(format!("/tmp/source/agents/{name}.md")),
205 },
206 source_path: PathBuf::from(format!("/tmp/source/agents/{name}.md")),
207 dest_path: format!("agents/{name}.md").into(),
208 source_hash: hash::hash_bytes(b"test content").into(),
209 is_flat_skill: false,
210 rewritten_content: None,
211 }
212 }
213
214 fn make_symlink_target(name: &str) -> TargetItem {
215 TargetItem {
216 materialization: crate::types::Materialization::Symlink {
217 source_abs: PathBuf::from(format!("/tmp/source/local/agents/{name}.md")),
218 },
219 source_name: crate::types::SourceOrigin::LocalPackage.to_string().into(),
220 origin: crate::types::SourceOrigin::LocalPackage,
221 ..make_target(name)
222 }
223 }
224
225 fn make_locked(name: &str) -> LockedItem {
226 LockedItem {
227 source: "test".into(),
228 kind: ItemKind::Agent,
229 version: None,
230 source_checksum: hash::hash_bytes(b"old content").into(),
231 installed_checksum: hash::hash_bytes(b"old content").into(),
232 dest_path: format!("agents/{name}.md").into(),
233 }
234 }
235
236 fn make_locked_from_source(name: &str, source: &str) -> LockedItem {
237 LockedItem {
238 source: source.into(),
239 ..make_locked(name)
240 }
241 }
242
243 fn default_options() -> SyncOptions {
244 SyncOptions {
245 force: false,
246 dry_run: false,
247 frozen: false,
248 }
249 }
250
251 fn force_options() -> SyncOptions {
252 SyncOptions {
253 force: true,
254 dry_run: false,
255 frozen: false,
256 }
257 }
258
259 #[test]
260 fn add_produces_install() {
261 let cache_dir = TempDir::new().unwrap();
262 let diff = SyncDiff {
263 items: vec![DiffEntry::Add {
264 target: make_target("new-agent"),
265 }],
266 };
267
268 let plan = create(&diff, &default_options(), cache_dir.path());
269 assert_eq!(plan.actions.len(), 1);
270 assert!(matches!(&plan.actions[0], PlannedAction::Install { .. }));
271 }
272
273 #[test]
274 fn update_produces_overwrite() {
275 let cache_dir = TempDir::new().unwrap();
276 let diff = SyncDiff {
277 items: vec![DiffEntry::Update {
278 target: make_target("updated"),
279 locked: make_locked("updated"),
280 }],
281 };
282
283 let plan = create(&diff, &default_options(), cache_dir.path());
284 assert_eq!(plan.actions.len(), 1);
285 assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
286 }
287
288 #[test]
289 fn unchanged_produces_skip() {
290 let cache_dir = TempDir::new().unwrap();
291 let diff = SyncDiff {
292 items: vec![DiffEntry::Unchanged {
293 target: make_target("stable"),
294 locked: make_locked("stable"),
295 }],
296 };
297
298 let plan = create(&diff, &default_options(), cache_dir.path());
299 assert_eq!(plan.actions.len(), 1);
300 assert!(matches!(
301 &plan.actions[0],
302 PlannedAction::Skip {
303 reason: "unchanged",
304 ..
305 }
306 ));
307 }
308
309 #[test]
310 fn conflict_produces_merge() {
311 let cache_dir = TempDir::new().unwrap();
312 let diff = SyncDiff {
313 items: vec![DiffEntry::Conflict {
314 target: make_target("conflicted"),
315 locked: make_locked("conflicted"),
316 local_hash: "sha256:local".into(),
317 }],
318 };
319
320 let plan = create(&diff, &default_options(), cache_dir.path());
321 assert_eq!(plan.actions.len(), 1);
322 assert!(matches!(&plan.actions[0], PlannedAction::Merge { .. }));
323 }
324
325 #[test]
326 fn conflict_with_symlink_materialization_produces_symlink() {
327 let cache_dir = TempDir::new().unwrap();
328 let diff = SyncDiff {
329 items: vec![DiffEntry::Conflict {
330 target: make_symlink_target("conflicted"),
331 locked: make_locked("conflicted"),
332 local_hash: "sha256:local".into(),
333 }],
334 };
335
336 let plan = create(&diff, &default_options(), cache_dir.path());
337 assert_eq!(plan.actions.len(), 1);
338 assert!(matches!(&plan.actions[0], PlannedAction::Symlink { .. }));
339 }
340
341 #[test]
342 fn conflict_with_force_produces_overwrite() {
343 let cache_dir = TempDir::new().unwrap();
344 let diff = SyncDiff {
345 items: vec![DiffEntry::Conflict {
346 target: make_target("conflicted"),
347 locked: make_locked("conflicted"),
348 local_hash: "sha256:local".into(),
349 }],
350 };
351
352 let plan = create(&diff, &force_options(), cache_dir.path());
353 assert_eq!(plan.actions.len(), 1);
354 assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
355 }
356
357 #[test]
358 fn orphan_produces_remove() {
359 let cache_dir = TempDir::new().unwrap();
360 let diff = SyncDiff {
361 items: vec![DiffEntry::Orphan {
362 locked: make_locked("removed"),
363 }],
364 };
365
366 let plan = create(&diff, &default_options(), cache_dir.path());
367 assert_eq!(plan.actions.len(), 1);
368 assert!(matches!(&plan.actions[0], PlannedAction::Remove { .. }));
369 }
370
371 #[test]
372 fn local_modified_produces_keep_local() {
373 let cache_dir = TempDir::new().unwrap();
374 let diff = SyncDiff {
375 items: vec![DiffEntry::LocalModified {
376 target: make_target("modified"),
377 locked: make_locked("modified"),
378 local_hash: "sha256:local".into(),
379 }],
380 };
381
382 let plan = create(&diff, &default_options(), cache_dir.path());
383 assert_eq!(plan.actions.len(), 1);
384 assert!(matches!(&plan.actions[0], PlannedAction::KeepLocal { .. }));
385 }
386
387 #[test]
388 fn local_modified_with_force_produces_overwrite() {
389 let cache_dir = TempDir::new().unwrap();
390 let diff = SyncDiff {
391 items: vec![DiffEntry::LocalModified {
392 target: make_target("modified"),
393 locked: make_locked("modified"),
394 local_hash: "sha256:local".into(),
395 }],
396 };
397
398 let plan = create(&diff, &force_options(), cache_dir.path());
399 assert_eq!(plan.actions.len(), 1);
400 assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
401 }
402
403 #[test]
404 fn local_modified_with_symlink_materialization_produces_symlink() {
405 let cache_dir = TempDir::new().unwrap();
406 let diff = SyncDiff {
407 items: vec![DiffEntry::LocalModified {
408 target: make_symlink_target("modified"),
409 locked: make_locked("modified"),
410 local_hash: "sha256:local".into(),
411 }],
412 };
413
414 let plan = create(&diff, &default_options(), cache_dir.path());
415 assert_eq!(plan.actions.len(), 1);
416 assert!(matches!(&plan.actions[0], PlannedAction::Symlink { .. }));
417 }
418
419 #[test]
420 fn unchanged_symlink_with_dependency_lock_source_produces_symlink() {
421 let cache_dir = TempDir::new().unwrap();
422 let diff = SyncDiff {
423 items: vec![DiffEntry::Unchanged {
424 target: make_symlink_target("owner-change"),
425 locked: make_locked_from_source("owner-change", "dep-source"),
426 }],
427 };
428
429 let plan = create(&diff, &default_options(), cache_dir.path());
430 assert_eq!(plan.actions.len(), 1);
431 assert!(matches!(&plan.actions[0], PlannedAction::Symlink { .. }));
432 }
433
434 #[test]
435 fn unchanged_symlink_with_self_lock_source_produces_skip() {
436 let cache_dir = TempDir::new().unwrap();
437 let diff = SyncDiff {
438 items: vec![DiffEntry::Unchanged {
439 target: make_symlink_target("already-self"),
440 locked: make_locked_from_source(
441 "already-self",
442 crate::types::SourceOrigin::LocalPackage
443 .to_string()
444 .as_str(),
445 ),
446 }],
447 };
448
449 let plan = create(&diff, &default_options(), cache_dir.path());
450 assert_eq!(plan.actions.len(), 1);
451 assert!(matches!(
452 &plan.actions[0],
453 PlannedAction::Skip {
454 reason: "unchanged",
455 ..
456 }
457 ));
458 }
459
460 #[test]
461 fn merge_reads_base_from_cache() {
462 let cache_dir = TempDir::new().unwrap();
463 let installed_hash = hash::hash_bytes(b"installed content");
464
465 let base_path = cache_dir.path().join(&installed_hash);
467 std::fs::write(&base_path, b"installed content").unwrap();
468
469 let diff = SyncDiff {
470 items: vec![DiffEntry::Conflict {
471 target: make_target("agent"),
472 locked: {
473 let mut locked = make_locked("agent");
474 locked.installed_checksum = installed_hash.into();
475 locked
476 },
477 local_hash: "sha256:local".into(),
478 }],
479 };
480
481 let plan = create(&diff, &default_options(), cache_dir.path());
482 match &plan.actions[0] {
483 PlannedAction::Merge { base_content, .. } => {
484 assert_eq!(base_content, b"installed content");
485 }
486 other => panic!("expected Merge, got {other:?}"),
487 }
488 }
489
490 #[test]
491 fn merge_with_missing_cache_uses_empty_base() {
492 let cache_dir = TempDir::new().unwrap();
493 let diff = SyncDiff {
496 items: vec![DiffEntry::Conflict {
497 target: make_target("agent"),
498 locked: make_locked("agent"),
499 local_hash: "sha256:local".into(),
500 }],
501 };
502
503 let plan = create(&diff, &default_options(), cache_dir.path());
504 match &plan.actions[0] {
505 PlannedAction::Merge { base_content, .. } => {
506 assert!(
507 base_content.is_empty(),
508 "missing cache should fall back to empty base"
509 );
510 }
511 other => panic!("expected Merge, got {other:?}"),
512 }
513 }
514
515 #[test]
516 fn mixed_plan() {
517 let cache_dir = TempDir::new().unwrap();
518 let diff = SyncDiff {
519 items: vec![
520 DiffEntry::Add {
521 target: make_target("new"),
522 },
523 DiffEntry::Update {
524 target: make_target("updated"),
525 locked: make_locked("updated"),
526 },
527 DiffEntry::Unchanged {
528 target: make_target("stable"),
529 locked: make_locked("stable"),
530 },
531 DiffEntry::Orphan {
532 locked: make_locked("removed"),
533 },
534 ],
535 };
536
537 let plan = create(&diff, &default_options(), cache_dir.path());
538 assert_eq!(plan.actions.len(), 4);
539
540 assert!(matches!(&plan.actions[0], PlannedAction::Install { .. }));
541 assert!(matches!(&plan.actions[1], PlannedAction::Overwrite { .. }));
542 assert!(matches!(&plan.actions[2], PlannedAction::Skip { .. }));
543 assert!(matches!(&plan.actions[3], PlannedAction::Remove { .. }));
544 }
545}