1use std::path::PathBuf;
2
3use crate::diagnostic::DiagnosticCollector;
4use crate::lock::{ItemId, LockedItem};
5use crate::sync::diff::{DiffEntry, SyncDiff};
6use crate::sync::target::TargetItem;
7use crate::sync::types::SyncOptions;
8use crate::types::{ContentHash, DestPath, SourceName};
9
10#[derive(Debug, Clone)]
12pub struct SyncPlan {
13 pub actions: Vec<PlannedAction>,
14}
15
16#[derive(Debug, Clone)]
21pub enum PlannedAction {
22 Install { target: TargetItem },
24 Overwrite { target: TargetItem },
26 Skip {
28 item_id: ItemId,
29 dest_path: DestPath,
30 source_name: SourceName,
31 installed_checksum: Option<ContentHash>,
32 reason: &'static str,
33 },
34 Merge {
37 target: TargetItem,
38 base_content: Vec<u8>,
39 local_path: PathBuf,
40 },
41 Remove { locked: LockedItem },
43 KeepLocal {
45 item_id: ItemId,
46 dest_path: DestPath,
47 source_name: SourceName,
48 },
49}
50
51pub fn create(
56 diff: &SyncDiff,
57 options: &SyncOptions,
58 _cache_bases_dir: &std::path::Path,
59 diag: &mut DiagnosticCollector,
60) -> SyncPlan {
61 let mut actions = Vec::new();
62
63 for entry in &diff.items {
64 match entry {
65 DiffEntry::Add { target } => {
66 actions.push(PlannedAction::Install {
67 target: target.clone(),
68 });
69 }
70
71 DiffEntry::Update { target, locked: _ } => {
72 actions.push(PlannedAction::Overwrite {
73 target: target.clone(),
74 });
75 }
76
77 DiffEntry::Unchanged { target, locked } => {
78 actions.push(PlannedAction::Skip {
79 item_id: target.id.clone(),
80 dest_path: target.dest_path.clone(),
81 source_name: target.source_name.clone(),
82 installed_checksum: Some(locked.installed_checksum.clone()),
83 reason: "unchanged",
84 });
85 }
86
87 DiffEntry::Conflict {
88 target,
89 locked: _,
90 local_hash: _,
91 } => {
92 if !options.force {
93 diag.warn(
94 "conflict-overwrite",
95 format!(
96 "{} `{}` has local modifications — overwriting with upstream",
97 target.id.kind, target.id.name
98 ),
99 );
100 }
101
102 actions.push(PlannedAction::Overwrite {
104 target: target.clone(),
105 });
106 }
107
108 DiffEntry::Orphan { locked } => {
109 actions.push(PlannedAction::Remove {
110 locked: locked.clone(),
111 });
112 }
113
114 DiffEntry::LocalModified {
115 target,
116 locked: _,
117 local_hash: _,
118 } => {
119 if options.force {
120 actions.push(PlannedAction::Overwrite {
122 target: target.clone(),
123 });
124 } else {
125 actions.push(PlannedAction::KeepLocal {
126 item_id: target.id.clone(),
127 dest_path: target.dest_path.clone(),
128 source_name: target.source_name.clone(),
129 });
130 }
131 }
132 }
133 }
134
135 SyncPlan { actions }
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use crate::hash;
142 use crate::lock::{ItemId, ItemKind, LockedItem};
143 use crate::sync::diff::{DiffEntry, SyncDiff};
144 use crate::sync::target::TargetItem;
145 use std::path::PathBuf;
146 use tempfile::TempDir;
147
148 fn make_target_with_kind(name: &str, kind: ItemKind) -> TargetItem {
149 let (source_path, dest_path) = match kind {
150 ItemKind::Agent => (
151 PathBuf::from(format!("/tmp/source/agents/{name}.md")),
152 format!("agents/{name}.md"),
153 ),
154 ItemKind::Skill => (
155 PathBuf::from(format!("/tmp/source/skills/{name}")),
156 format!("skills/{name}"),
157 ),
158 ItemKind::Hook => (
159 PathBuf::from(format!("/tmp/source/hooks/{name}")),
160 format!("hooks/{name}"),
161 ),
162 ItemKind::McpServer => (
163 PathBuf::from(format!("/tmp/source/mcp/{name}")),
164 format!("mcp/{name}"),
165 ),
166 ItemKind::BootstrapDoc => (
167 PathBuf::from(format!("/tmp/source/bootstrap/{name}")),
168 format!("bootstrap/{name}/BOOTSTRAP.md"),
169 ),
170 };
171
172 TargetItem {
173 id: ItemId {
174 kind,
175 name: name.into(),
176 },
177 source_name: "test".into(),
178 origin: crate::types::SourceOrigin::Dependency("test".into()),
179 source_id: crate::types::SourceId::Path {
180 canonical: source_path.clone(),
181 subpath: None,
182 },
183 source_path,
184 dest_path: dest_path.into(),
185 source_hash: hash::hash_bytes(b"test content").into(),
186 is_flat_skill: false,
187 rewritten_content: None,
188 }
189 }
190
191 fn make_target(name: &str) -> TargetItem {
192 make_target_with_kind(name, ItemKind::Agent)
193 }
194
195 fn make_skill_target(name: &str) -> TargetItem {
196 make_target_with_kind(name, ItemKind::Skill)
197 }
198
199 fn make_locked_with_kind(name: &str, kind: ItemKind) -> LockedItem {
200 let dest_path = match kind {
201 ItemKind::Agent => format!("agents/{name}.md"),
202 ItemKind::Skill => format!("skills/{name}"),
203 ItemKind::Hook => format!("hooks/{name}"),
204 ItemKind::McpServer => format!("mcp/{name}"),
205 ItemKind::BootstrapDoc => format!("bootstrap/{name}/BOOTSTRAP.md"),
206 };
207
208 LockedItem {
209 source: "test".into(),
210 kind,
211 version: None,
212 source_checksum: hash::hash_bytes(b"old content").into(),
213 installed_checksum: hash::hash_bytes(b"old content").into(),
214 dest_path: dest_path.into(),
215 }
216 }
217
218 fn make_locked(name: &str) -> LockedItem {
219 make_locked_with_kind(name, ItemKind::Agent)
220 }
221
222 fn make_skill_locked(name: &str) -> LockedItem {
223 make_locked_with_kind(name, ItemKind::Skill)
224 }
225
226 fn default_options() -> SyncOptions {
227 SyncOptions::default()
228 }
229
230 fn force_options() -> SyncOptions {
231 SyncOptions {
232 force: true,
233 ..SyncOptions::default()
234 }
235 }
236
237 fn create_plan(
238 diff: &SyncDiff,
239 options: &SyncOptions,
240 cache_bases_dir: &std::path::Path,
241 ) -> SyncPlan {
242 let mut diag = DiagnosticCollector::new();
243 create(diff, options, cache_bases_dir, &mut diag)
244 }
245
246 fn create_plan_with_diag(
247 diff: &SyncDiff,
248 options: &SyncOptions,
249 cache_bases_dir: &std::path::Path,
250 ) -> (SyncPlan, DiagnosticCollector) {
251 let mut diag = DiagnosticCollector::new();
252 let plan = create(diff, options, cache_bases_dir, &mut diag);
253 (plan, diag)
254 }
255
256 #[test]
257 fn add_produces_install() {
258 let cache_dir = TempDir::new().unwrap();
259 let diff = SyncDiff {
260 items: vec![DiffEntry::Add {
261 target: make_target("new-agent"),
262 }],
263 };
264
265 let plan = create_plan(&diff, &default_options(), cache_dir.path());
266 assert_eq!(plan.actions.len(), 1);
267 assert!(matches!(&plan.actions[0], PlannedAction::Install { .. }));
268 }
269
270 #[test]
271 fn update_produces_overwrite() {
272 let cache_dir = TempDir::new().unwrap();
273 let diff = SyncDiff {
274 items: vec![DiffEntry::Update {
275 target: make_target("updated"),
276 locked: make_locked("updated"),
277 }],
278 };
279
280 let plan = create_plan(&diff, &default_options(), cache_dir.path());
281 assert_eq!(plan.actions.len(), 1);
282 assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
283 }
284
285 #[test]
286 fn unchanged_produces_skip() {
287 let cache_dir = TempDir::new().unwrap();
288 let diff = SyncDiff {
289 items: vec![DiffEntry::Unchanged {
290 target: make_target("stable"),
291 locked: make_locked("stable"),
292 }],
293 };
294
295 let plan = create_plan(&diff, &default_options(), cache_dir.path());
296 assert_eq!(plan.actions.len(), 1);
297 assert!(matches!(
298 &plan.actions[0],
299 PlannedAction::Skip {
300 reason: "unchanged",
301 ..
302 }
303 ));
304 }
305
306 #[test]
307 fn conflict_produces_overwrite_and_warning() {
308 let cache_dir = TempDir::new().unwrap();
309 let diff = SyncDiff {
310 items: vec![DiffEntry::Conflict {
311 target: make_target("conflicted"),
312 locked: make_locked("conflicted"),
313 local_hash: "sha256:local".into(),
314 }],
315 };
316
317 let (plan, mut diag) = create_plan_with_diag(&diff, &default_options(), cache_dir.path());
318 assert_eq!(plan.actions.len(), 1);
319 assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
320
321 let diagnostics = diag.drain();
322 assert_eq!(diagnostics.len(), 1);
323 assert_eq!(diagnostics[0].code, "conflict-overwrite");
324 }
325
326 #[test]
327 fn skill_conflict_produces_overwrite_and_warning() {
328 let cache_dir = TempDir::new().unwrap();
329 let diff = SyncDiff {
330 items: vec![DiffEntry::Conflict {
331 target: make_skill_target("planning"),
332 locked: make_skill_locked("planning"),
333 local_hash: "sha256:local".into(),
334 }],
335 };
336 let mut diag = DiagnosticCollector::new();
337
338 let plan = create(&diff, &default_options(), cache_dir.path(), &mut diag);
339 assert_eq!(plan.actions.len(), 1);
340 assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
341
342 let diagnostics = diag.drain();
343 assert_eq!(diagnostics.len(), 1);
344 assert_eq!(diagnostics[0].code, "conflict-overwrite");
345 assert_eq!(
346 diagnostics[0].message,
347 "skill `planning` has local modifications — overwriting with upstream"
348 );
349 }
350
351 #[test]
352 fn conflict_with_force_produces_overwrite() {
353 let cache_dir = TempDir::new().unwrap();
354 let diff = SyncDiff {
355 items: vec![DiffEntry::Conflict {
356 target: make_target("conflicted"),
357 locked: make_locked("conflicted"),
358 local_hash: "sha256:local".into(),
359 }],
360 };
361
362 let plan = create_plan(&diff, &force_options(), cache_dir.path());
363 assert_eq!(plan.actions.len(), 1);
364 assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
365 }
366
367 #[test]
368 fn orphan_produces_remove() {
369 let cache_dir = TempDir::new().unwrap();
370 let diff = SyncDiff {
371 items: vec![DiffEntry::Orphan {
372 locked: make_locked("removed"),
373 }],
374 };
375
376 let plan = create_plan(&diff, &default_options(), cache_dir.path());
377 assert_eq!(plan.actions.len(), 1);
378 assert!(matches!(&plan.actions[0], PlannedAction::Remove { .. }));
379 }
380
381 #[test]
382 fn local_modified_produces_keep_local() {
383 let cache_dir = TempDir::new().unwrap();
384 let diff = SyncDiff {
385 items: vec![DiffEntry::LocalModified {
386 target: make_target("modified"),
387 locked: make_locked("modified"),
388 local_hash: "sha256:local".into(),
389 }],
390 };
391
392 let plan = create_plan(&diff, &default_options(), cache_dir.path());
393 assert_eq!(plan.actions.len(), 1);
394 assert!(matches!(&plan.actions[0], PlannedAction::KeepLocal { .. }));
395 }
396
397 #[test]
398 fn local_modified_with_force_produces_overwrite() {
399 let cache_dir = TempDir::new().unwrap();
400 let diff = SyncDiff {
401 items: vec![DiffEntry::LocalModified {
402 target: make_target("modified"),
403 locked: make_locked("modified"),
404 local_hash: "sha256:local".into(),
405 }],
406 };
407
408 let plan = create_plan(&diff, &force_options(), cache_dir.path());
409 assert_eq!(plan.actions.len(), 1);
410 assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
411 }
412
413 #[test]
414 fn mixed_plan() {
415 let cache_dir = TempDir::new().unwrap();
416 let diff = SyncDiff {
417 items: vec![
418 DiffEntry::Add {
419 target: make_target("new"),
420 },
421 DiffEntry::Update {
422 target: make_target("updated"),
423 locked: make_locked("updated"),
424 },
425 DiffEntry::Unchanged {
426 target: make_target("stable"),
427 locked: make_locked("stable"),
428 },
429 DiffEntry::Orphan {
430 locked: make_locked("removed"),
431 },
432 ],
433 };
434
435 let plan = create_plan(&diff, &default_options(), cache_dir.path());
436 assert_eq!(plan.actions.len(), 4);
437
438 assert!(matches!(&plan.actions[0], PlannedAction::Install { .. }));
439 assert!(matches!(&plan.actions[1], PlannedAction::Overwrite { .. }));
440 assert!(matches!(&plan.actions[2], PlannedAction::Skip { .. }));
441 assert!(matches!(&plan.actions[3], PlannedAction::Remove { .. }));
442 }
443}