1use std::path::PathBuf;
2
3use crate::lock::{ItemId, ItemKind, LockedItem};
4use crate::sync::apply::SyncOptions;
5use crate::sync::diff::{DiffEntry, SyncDiff};
6use crate::sync::target::TargetItem;
7use crate::types::{DestPath, ItemName, 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 mut actions = Vec::new();
67
68 for entry in &diff.items {
69 match entry {
70 DiffEntry::Add { target } => {
71 actions.push(PlannedAction::Install {
72 target: target.clone(),
73 });
74 }
75
76 DiffEntry::Update { target, locked: _ } => {
77 actions.push(PlannedAction::Overwrite {
78 target: target.clone(),
79 });
80 }
81
82 DiffEntry::Unchanged { target, locked: _ } => {
83 actions.push(PlannedAction::Skip {
84 item_id: target.id.clone(),
85 dest_path: target.dest_path.clone(),
86 source_name: target.source_name.clone(),
87 reason: "unchanged",
88 });
89 }
90
91 DiffEntry::Conflict {
92 target,
93 locked,
94 local_hash: _,
95 } => {
96 if options.force {
97 actions.push(PlannedAction::Overwrite {
99 target: target.clone(),
100 });
101 } else {
102 let base_path = cache_bases_dir.join(locked.installed_checksum.as_ref());
104
105 let base_content = std::fs::read(&base_path).unwrap_or_default();
107
108 let local_path = locked.dest_path.as_path().to_path_buf();
110
111 actions.push(PlannedAction::Merge {
112 target: target.clone(),
113 base_content,
114 local_path,
115 });
116 }
117 }
118
119 DiffEntry::Orphan { locked } => {
120 actions.push(PlannedAction::Remove {
121 locked: locked.clone(),
122 });
123 }
124
125 DiffEntry::LocalModified {
126 target,
127 locked: _,
128 local_hash: _,
129 } => {
130 if options.force {
131 actions.push(PlannedAction::Overwrite {
133 target: target.clone(),
134 });
135 } else {
136 actions.push(PlannedAction::KeepLocal {
137 item_id: target.id.clone(),
138 dest_path: target.dest_path.clone(),
139 source_name: target.source_name.clone(),
140 });
141 }
142 }
143 }
144 }
145
146 SyncPlan { actions }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152 use crate::hash;
153 use crate::lock::{ItemId, ItemKind, LockedItem};
154 use crate::sync::diff::{DiffEntry, SyncDiff};
155 use crate::sync::target::TargetItem;
156 use std::path::PathBuf;
157 use tempfile::TempDir;
158
159 fn make_target(name: &str) -> TargetItem {
160 TargetItem {
161 id: ItemId {
162 kind: ItemKind::Agent,
163 name: name.into(),
164 },
165 source_name: "test".into(),
166 source_id: crate::types::SourceId::Path {
167 canonical: PathBuf::from(format!("/tmp/source/agents/{name}.md")),
168 },
169 source_path: PathBuf::from(format!("/tmp/source/agents/{name}.md")),
170 dest_path: format!("agents/{name}.md").into(),
171 source_hash: hash::hash_bytes(b"test content").into(),
172 is_flat_skill: false,
173 rewritten_content: None,
174 }
175 }
176
177 fn make_locked(name: &str) -> LockedItem {
178 LockedItem {
179 source: "test".into(),
180 kind: ItemKind::Agent,
181 version: None,
182 source_checksum: hash::hash_bytes(b"old content").into(),
183 installed_checksum: hash::hash_bytes(b"old content").into(),
184 dest_path: format!("agents/{name}.md").into(),
185 }
186 }
187
188 fn default_options() -> SyncOptions {
189 SyncOptions {
190 force: false,
191 dry_run: false,
192 frozen: false,
193 }
194 }
195
196 fn force_options() -> SyncOptions {
197 SyncOptions {
198 force: true,
199 dry_run: false,
200 frozen: false,
201 }
202 }
203
204 #[test]
205 fn add_produces_install() {
206 let cache_dir = TempDir::new().unwrap();
207 let diff = SyncDiff {
208 items: vec![DiffEntry::Add {
209 target: make_target("new-agent"),
210 }],
211 };
212
213 let plan = create(&diff, &default_options(), cache_dir.path());
214 assert_eq!(plan.actions.len(), 1);
215 assert!(matches!(&plan.actions[0], PlannedAction::Install { .. }));
216 }
217
218 #[test]
219 fn update_produces_overwrite() {
220 let cache_dir = TempDir::new().unwrap();
221 let diff = SyncDiff {
222 items: vec![DiffEntry::Update {
223 target: make_target("updated"),
224 locked: make_locked("updated"),
225 }],
226 };
227
228 let plan = create(&diff, &default_options(), cache_dir.path());
229 assert_eq!(plan.actions.len(), 1);
230 assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
231 }
232
233 #[test]
234 fn unchanged_produces_skip() {
235 let cache_dir = TempDir::new().unwrap();
236 let diff = SyncDiff {
237 items: vec![DiffEntry::Unchanged {
238 target: make_target("stable"),
239 locked: make_locked("stable"),
240 }],
241 };
242
243 let plan = create(&diff, &default_options(), cache_dir.path());
244 assert_eq!(plan.actions.len(), 1);
245 assert!(matches!(
246 &plan.actions[0],
247 PlannedAction::Skip {
248 reason: "unchanged",
249 ..
250 }
251 ));
252 }
253
254 #[test]
255 fn conflict_produces_merge() {
256 let cache_dir = TempDir::new().unwrap();
257 let diff = SyncDiff {
258 items: vec![DiffEntry::Conflict {
259 target: make_target("conflicted"),
260 locked: make_locked("conflicted"),
261 local_hash: "sha256:local".into(),
262 }],
263 };
264
265 let plan = create(&diff, &default_options(), cache_dir.path());
266 assert_eq!(plan.actions.len(), 1);
267 assert!(matches!(&plan.actions[0], PlannedAction::Merge { .. }));
268 }
269
270 #[test]
271 fn conflict_with_force_produces_overwrite() {
272 let cache_dir = TempDir::new().unwrap();
273 let diff = SyncDiff {
274 items: vec![DiffEntry::Conflict {
275 target: make_target("conflicted"),
276 locked: make_locked("conflicted"),
277 local_hash: "sha256:local".into(),
278 }],
279 };
280
281 let plan = create(&diff, &force_options(), cache_dir.path());
282 assert_eq!(plan.actions.len(), 1);
283 assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
284 }
285
286 #[test]
287 fn orphan_produces_remove() {
288 let cache_dir = TempDir::new().unwrap();
289 let diff = SyncDiff {
290 items: vec![DiffEntry::Orphan {
291 locked: make_locked("removed"),
292 }],
293 };
294
295 let plan = create(&diff, &default_options(), cache_dir.path());
296 assert_eq!(plan.actions.len(), 1);
297 assert!(matches!(&plan.actions[0], PlannedAction::Remove { .. }));
298 }
299
300 #[test]
301 fn local_modified_produces_keep_local() {
302 let cache_dir = TempDir::new().unwrap();
303 let diff = SyncDiff {
304 items: vec![DiffEntry::LocalModified {
305 target: make_target("modified"),
306 locked: make_locked("modified"),
307 local_hash: "sha256:local".into(),
308 }],
309 };
310
311 let plan = create(&diff, &default_options(), cache_dir.path());
312 assert_eq!(plan.actions.len(), 1);
313 assert!(matches!(&plan.actions[0], PlannedAction::KeepLocal { .. }));
314 }
315
316 #[test]
317 fn local_modified_with_force_produces_overwrite() {
318 let cache_dir = TempDir::new().unwrap();
319 let diff = SyncDiff {
320 items: vec![DiffEntry::LocalModified {
321 target: make_target("modified"),
322 locked: make_locked("modified"),
323 local_hash: "sha256:local".into(),
324 }],
325 };
326
327 let plan = create(&diff, &force_options(), cache_dir.path());
328 assert_eq!(plan.actions.len(), 1);
329 assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
330 }
331
332 #[test]
333 fn merge_reads_base_from_cache() {
334 let cache_dir = TempDir::new().unwrap();
335 let installed_hash = hash::hash_bytes(b"installed content");
336
337 let base_path = cache_dir.path().join(&installed_hash);
339 std::fs::write(&base_path, b"installed content").unwrap();
340
341 let diff = SyncDiff {
342 items: vec![DiffEntry::Conflict {
343 target: make_target("agent"),
344 locked: {
345 let mut locked = make_locked("agent");
346 locked.installed_checksum = installed_hash.into();
347 locked
348 },
349 local_hash: "sha256:local".into(),
350 }],
351 };
352
353 let plan = create(&diff, &default_options(), cache_dir.path());
354 match &plan.actions[0] {
355 PlannedAction::Merge { base_content, .. } => {
356 assert_eq!(base_content, b"installed content");
357 }
358 other => panic!("expected Merge, got {other:?}"),
359 }
360 }
361
362 #[test]
363 fn merge_with_missing_cache_uses_empty_base() {
364 let cache_dir = TempDir::new().unwrap();
365 let diff = SyncDiff {
368 items: vec![DiffEntry::Conflict {
369 target: make_target("agent"),
370 locked: make_locked("agent"),
371 local_hash: "sha256:local".into(),
372 }],
373 };
374
375 let plan = create(&diff, &default_options(), cache_dir.path());
376 match &plan.actions[0] {
377 PlannedAction::Merge { base_content, .. } => {
378 assert!(
379 base_content.is_empty(),
380 "missing cache should fall back to empty base"
381 );
382 }
383 other => panic!("expected Merge, got {other:?}"),
384 }
385 }
386
387 #[test]
388 fn mixed_plan() {
389 let cache_dir = TempDir::new().unwrap();
390 let diff = SyncDiff {
391 items: vec![
392 DiffEntry::Add {
393 target: make_target("new"),
394 },
395 DiffEntry::Update {
396 target: make_target("updated"),
397 locked: make_locked("updated"),
398 },
399 DiffEntry::Unchanged {
400 target: make_target("stable"),
401 locked: make_locked("stable"),
402 },
403 DiffEntry::Orphan {
404 locked: make_locked("removed"),
405 },
406 ],
407 };
408
409 let plan = create(&diff, &default_options(), cache_dir.path());
410 assert_eq!(plan.actions.len(), 4);
411
412 assert!(matches!(&plan.actions[0], PlannedAction::Install { .. }));
413 assert!(matches!(&plan.actions[1], PlannedAction::Overwrite { .. }));
414 assert!(matches!(&plan.actions[2], PlannedAction::Skip { .. }));
415 assert!(matches!(&plan.actions[3], PlannedAction::Remove { .. }));
416 }
417}