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