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 no_refresh_models: false,
249 }
250 }
251
252 fn force_options() -> SyncOptions {
253 SyncOptions {
254 force: true,
255 dry_run: false,
256 frozen: false,
257 no_refresh_models: false,
258 }
259 }
260
261 #[test]
262 fn add_produces_install() {
263 let cache_dir = TempDir::new().unwrap();
264 let diff = SyncDiff {
265 items: vec![DiffEntry::Add {
266 target: make_target("new-agent"),
267 }],
268 };
269
270 let plan = create(&diff, &default_options(), cache_dir.path());
271 assert_eq!(plan.actions.len(), 1);
272 assert!(matches!(&plan.actions[0], PlannedAction::Install { .. }));
273 }
274
275 #[test]
276 fn update_produces_overwrite() {
277 let cache_dir = TempDir::new().unwrap();
278 let diff = SyncDiff {
279 items: vec![DiffEntry::Update {
280 target: make_target("updated"),
281 locked: make_locked("updated"),
282 }],
283 };
284
285 let plan = create(&diff, &default_options(), cache_dir.path());
286 assert_eq!(plan.actions.len(), 1);
287 assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
288 }
289
290 #[test]
291 fn unchanged_produces_skip() {
292 let cache_dir = TempDir::new().unwrap();
293 let diff = SyncDiff {
294 items: vec![DiffEntry::Unchanged {
295 target: make_target("stable"),
296 locked: make_locked("stable"),
297 }],
298 };
299
300 let plan = create(&diff, &default_options(), cache_dir.path());
301 assert_eq!(plan.actions.len(), 1);
302 assert!(matches!(
303 &plan.actions[0],
304 PlannedAction::Skip {
305 reason: "unchanged",
306 ..
307 }
308 ));
309 }
310
311 #[test]
312 fn conflict_produces_merge() {
313 let cache_dir = TempDir::new().unwrap();
314 let diff = SyncDiff {
315 items: vec![DiffEntry::Conflict {
316 target: make_target("conflicted"),
317 locked: make_locked("conflicted"),
318 local_hash: "sha256:local".into(),
319 }],
320 };
321
322 let plan = create(&diff, &default_options(), cache_dir.path());
323 assert_eq!(plan.actions.len(), 1);
324 assert!(matches!(&plan.actions[0], PlannedAction::Merge { .. }));
325 }
326
327 #[test]
328 fn conflict_with_symlink_materialization_produces_symlink() {
329 let cache_dir = TempDir::new().unwrap();
330 let diff = SyncDiff {
331 items: vec![DiffEntry::Conflict {
332 target: make_symlink_target("conflicted"),
333 locked: make_locked("conflicted"),
334 local_hash: "sha256:local".into(),
335 }],
336 };
337
338 let plan = create(&diff, &default_options(), cache_dir.path());
339 assert_eq!(plan.actions.len(), 1);
340 assert!(matches!(&plan.actions[0], PlannedAction::Symlink { .. }));
341 }
342
343 #[test]
344 fn conflict_with_force_produces_overwrite() {
345 let cache_dir = TempDir::new().unwrap();
346 let diff = SyncDiff {
347 items: vec![DiffEntry::Conflict {
348 target: make_target("conflicted"),
349 locked: make_locked("conflicted"),
350 local_hash: "sha256:local".into(),
351 }],
352 };
353
354 let plan = create(&diff, &force_options(), cache_dir.path());
355 assert_eq!(plan.actions.len(), 1);
356 assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
357 }
358
359 #[test]
360 fn orphan_produces_remove() {
361 let cache_dir = TempDir::new().unwrap();
362 let diff = SyncDiff {
363 items: vec![DiffEntry::Orphan {
364 locked: make_locked("removed"),
365 }],
366 };
367
368 let plan = create(&diff, &default_options(), cache_dir.path());
369 assert_eq!(plan.actions.len(), 1);
370 assert!(matches!(&plan.actions[0], PlannedAction::Remove { .. }));
371 }
372
373 #[test]
374 fn local_modified_produces_keep_local() {
375 let cache_dir = TempDir::new().unwrap();
376 let diff = SyncDiff {
377 items: vec![DiffEntry::LocalModified {
378 target: make_target("modified"),
379 locked: make_locked("modified"),
380 local_hash: "sha256:local".into(),
381 }],
382 };
383
384 let plan = create(&diff, &default_options(), cache_dir.path());
385 assert_eq!(plan.actions.len(), 1);
386 assert!(matches!(&plan.actions[0], PlannedAction::KeepLocal { .. }));
387 }
388
389 #[test]
390 fn local_modified_with_force_produces_overwrite() {
391 let cache_dir = TempDir::new().unwrap();
392 let diff = SyncDiff {
393 items: vec![DiffEntry::LocalModified {
394 target: make_target("modified"),
395 locked: make_locked("modified"),
396 local_hash: "sha256:local".into(),
397 }],
398 };
399
400 let plan = create(&diff, &force_options(), cache_dir.path());
401 assert_eq!(plan.actions.len(), 1);
402 assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
403 }
404
405 #[test]
406 fn local_modified_with_symlink_materialization_produces_symlink() {
407 let cache_dir = TempDir::new().unwrap();
408 let diff = SyncDiff {
409 items: vec![DiffEntry::LocalModified {
410 target: make_symlink_target("modified"),
411 locked: make_locked("modified"),
412 local_hash: "sha256:local".into(),
413 }],
414 };
415
416 let plan = create(&diff, &default_options(), cache_dir.path());
417 assert_eq!(plan.actions.len(), 1);
418 assert!(matches!(&plan.actions[0], PlannedAction::Symlink { .. }));
419 }
420
421 #[test]
422 fn unchanged_symlink_with_dependency_lock_source_produces_symlink() {
423 let cache_dir = TempDir::new().unwrap();
424 let diff = SyncDiff {
425 items: vec![DiffEntry::Unchanged {
426 target: make_symlink_target("owner-change"),
427 locked: make_locked_from_source("owner-change", "dep-source"),
428 }],
429 };
430
431 let plan = create(&diff, &default_options(), cache_dir.path());
432 assert_eq!(plan.actions.len(), 1);
433 assert!(matches!(&plan.actions[0], PlannedAction::Symlink { .. }));
434 }
435
436 #[test]
437 fn unchanged_symlink_with_self_lock_source_produces_skip() {
438 let cache_dir = TempDir::new().unwrap();
439 let diff = SyncDiff {
440 items: vec![DiffEntry::Unchanged {
441 target: make_symlink_target("already-self"),
442 locked: make_locked_from_source(
443 "already-self",
444 crate::types::SourceOrigin::LocalPackage
445 .to_string()
446 .as_str(),
447 ),
448 }],
449 };
450
451 let plan = create(&diff, &default_options(), cache_dir.path());
452 assert_eq!(plan.actions.len(), 1);
453 assert!(matches!(
454 &plan.actions[0],
455 PlannedAction::Skip {
456 reason: "unchanged",
457 ..
458 }
459 ));
460 }
461
462 #[test]
463 fn merge_reads_base_from_cache() {
464 let cache_dir = TempDir::new().unwrap();
465 let installed_hash = hash::hash_bytes(b"installed content");
466
467 let base_path = cache_dir.path().join(&installed_hash);
469 std::fs::write(&base_path, b"installed content").unwrap();
470
471 let diff = SyncDiff {
472 items: vec![DiffEntry::Conflict {
473 target: make_target("agent"),
474 locked: {
475 let mut locked = make_locked("agent");
476 locked.installed_checksum = installed_hash.into();
477 locked
478 },
479 local_hash: "sha256:local".into(),
480 }],
481 };
482
483 let plan = create(&diff, &default_options(), cache_dir.path());
484 match &plan.actions[0] {
485 PlannedAction::Merge { base_content, .. } => {
486 assert_eq!(base_content, b"installed content");
487 }
488 other => panic!("expected Merge, got {other:?}"),
489 }
490 }
491
492 #[test]
493 fn merge_with_missing_cache_uses_empty_base() {
494 let cache_dir = TempDir::new().unwrap();
495 let diff = SyncDiff {
498 items: vec![DiffEntry::Conflict {
499 target: make_target("agent"),
500 locked: make_locked("agent"),
501 local_hash: "sha256:local".into(),
502 }],
503 };
504
505 let plan = create(&diff, &default_options(), cache_dir.path());
506 match &plan.actions[0] {
507 PlannedAction::Merge { base_content, .. } => {
508 assert!(
509 base_content.is_empty(),
510 "missing cache should fall back to empty base"
511 );
512 }
513 other => panic!("expected Merge, got {other:?}"),
514 }
515 }
516
517 #[test]
518 fn mixed_plan() {
519 let cache_dir = TempDir::new().unwrap();
520 let diff = SyncDiff {
521 items: vec![
522 DiffEntry::Add {
523 target: make_target("new"),
524 },
525 DiffEntry::Update {
526 target: make_target("updated"),
527 locked: make_locked("updated"),
528 },
529 DiffEntry::Unchanged {
530 target: make_target("stable"),
531 locked: make_locked("stable"),
532 },
533 DiffEntry::Orphan {
534 locked: make_locked("removed"),
535 },
536 ],
537 };
538
539 let plan = create(&diff, &default_options(), cache_dir.path());
540 assert_eq!(plan.actions.len(), 4);
541
542 assert!(matches!(&plan.actions[0], PlannedAction::Install { .. }));
543 assert!(matches!(&plan.actions[1], PlannedAction::Overwrite { .. }));
544 assert!(matches!(&plan.actions[2], PlannedAction::Skip { .. }));
545 assert!(matches!(&plan.actions[3], PlannedAction::Remove { .. }));
546 }
547}