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 {
228 force: false,
229 dry_run: false,
230 frozen: false,
231 refresh_models: false,
232 no_refresh_models: false,
233 }
234 }
235
236 fn force_options() -> SyncOptions {
237 SyncOptions {
238 force: true,
239 dry_run: false,
240 frozen: false,
241 refresh_models: false,
242 no_refresh_models: false,
243 }
244 }
245
246 fn create_plan(
247 diff: &SyncDiff,
248 options: &SyncOptions,
249 cache_bases_dir: &std::path::Path,
250 ) -> SyncPlan {
251 let mut diag = DiagnosticCollector::new();
252 create(diff, options, cache_bases_dir, &mut diag)
253 }
254
255 fn create_plan_with_diag(
256 diff: &SyncDiff,
257 options: &SyncOptions,
258 cache_bases_dir: &std::path::Path,
259 ) -> (SyncPlan, DiagnosticCollector) {
260 let mut diag = DiagnosticCollector::new();
261 let plan = create(diff, options, cache_bases_dir, &mut diag);
262 (plan, diag)
263 }
264
265 #[test]
266 fn add_produces_install() {
267 let cache_dir = TempDir::new().unwrap();
268 let diff = SyncDiff {
269 items: vec![DiffEntry::Add {
270 target: make_target("new-agent"),
271 }],
272 };
273
274 let plan = create_plan(&diff, &default_options(), cache_dir.path());
275 assert_eq!(plan.actions.len(), 1);
276 assert!(matches!(&plan.actions[0], PlannedAction::Install { .. }));
277 }
278
279 #[test]
280 fn update_produces_overwrite() {
281 let cache_dir = TempDir::new().unwrap();
282 let diff = SyncDiff {
283 items: vec![DiffEntry::Update {
284 target: make_target("updated"),
285 locked: make_locked("updated"),
286 }],
287 };
288
289 let plan = create_plan(&diff, &default_options(), cache_dir.path());
290 assert_eq!(plan.actions.len(), 1);
291 assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
292 }
293
294 #[test]
295 fn unchanged_produces_skip() {
296 let cache_dir = TempDir::new().unwrap();
297 let diff = SyncDiff {
298 items: vec![DiffEntry::Unchanged {
299 target: make_target("stable"),
300 locked: make_locked("stable"),
301 }],
302 };
303
304 let plan = create_plan(&diff, &default_options(), cache_dir.path());
305 assert_eq!(plan.actions.len(), 1);
306 assert!(matches!(
307 &plan.actions[0],
308 PlannedAction::Skip {
309 reason: "unchanged",
310 ..
311 }
312 ));
313 }
314
315 #[test]
316 fn conflict_produces_overwrite_and_warning() {
317 let cache_dir = TempDir::new().unwrap();
318 let diff = SyncDiff {
319 items: vec![DiffEntry::Conflict {
320 target: make_target("conflicted"),
321 locked: make_locked("conflicted"),
322 local_hash: "sha256:local".into(),
323 }],
324 };
325
326 let (plan, mut diag) = create_plan_with_diag(&diff, &default_options(), cache_dir.path());
327 assert_eq!(plan.actions.len(), 1);
328 assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
329
330 let diagnostics = diag.drain();
331 assert_eq!(diagnostics.len(), 1);
332 assert_eq!(diagnostics[0].code, "conflict-overwrite");
333 }
334
335 #[test]
336 fn skill_conflict_produces_overwrite_and_warning() {
337 let cache_dir = TempDir::new().unwrap();
338 let diff = SyncDiff {
339 items: vec![DiffEntry::Conflict {
340 target: make_skill_target("planning"),
341 locked: make_skill_locked("planning"),
342 local_hash: "sha256:local".into(),
343 }],
344 };
345 let mut diag = DiagnosticCollector::new();
346
347 let plan = create(&diff, &default_options(), cache_dir.path(), &mut diag);
348 assert_eq!(plan.actions.len(), 1);
349 assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
350
351 let diagnostics = diag.drain();
352 assert_eq!(diagnostics.len(), 1);
353 assert_eq!(diagnostics[0].code, "conflict-overwrite");
354 assert_eq!(
355 diagnostics[0].message,
356 "skill `planning` has local modifications — overwriting with upstream"
357 );
358 }
359
360 #[test]
361 fn conflict_with_force_produces_overwrite() {
362 let cache_dir = TempDir::new().unwrap();
363 let diff = SyncDiff {
364 items: vec![DiffEntry::Conflict {
365 target: make_target("conflicted"),
366 locked: make_locked("conflicted"),
367 local_hash: "sha256:local".into(),
368 }],
369 };
370
371 let plan = create_plan(&diff, &force_options(), cache_dir.path());
372 assert_eq!(plan.actions.len(), 1);
373 assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
374 }
375
376 #[test]
377 fn orphan_produces_remove() {
378 let cache_dir = TempDir::new().unwrap();
379 let diff = SyncDiff {
380 items: vec![DiffEntry::Orphan {
381 locked: make_locked("removed"),
382 }],
383 };
384
385 let plan = create_plan(&diff, &default_options(), cache_dir.path());
386 assert_eq!(plan.actions.len(), 1);
387 assert!(matches!(&plan.actions[0], PlannedAction::Remove { .. }));
388 }
389
390 #[test]
391 fn local_modified_produces_keep_local() {
392 let cache_dir = TempDir::new().unwrap();
393 let diff = SyncDiff {
394 items: vec![DiffEntry::LocalModified {
395 target: make_target("modified"),
396 locked: make_locked("modified"),
397 local_hash: "sha256:local".into(),
398 }],
399 };
400
401 let plan = create_plan(&diff, &default_options(), cache_dir.path());
402 assert_eq!(plan.actions.len(), 1);
403 assert!(matches!(&plan.actions[0], PlannedAction::KeepLocal { .. }));
404 }
405
406 #[test]
407 fn local_modified_with_force_produces_overwrite() {
408 let cache_dir = TempDir::new().unwrap();
409 let diff = SyncDiff {
410 items: vec![DiffEntry::LocalModified {
411 target: make_target("modified"),
412 locked: make_locked("modified"),
413 local_hash: "sha256:local".into(),
414 }],
415 };
416
417 let plan = create_plan(&diff, &force_options(), cache_dir.path());
418 assert_eq!(plan.actions.len(), 1);
419 assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
420 }
421
422 #[test]
423 fn mixed_plan() {
424 let cache_dir = TempDir::new().unwrap();
425 let diff = SyncDiff {
426 items: vec![
427 DiffEntry::Add {
428 target: make_target("new"),
429 },
430 DiffEntry::Update {
431 target: make_target("updated"),
432 locked: make_locked("updated"),
433 },
434 DiffEntry::Unchanged {
435 target: make_target("stable"),
436 locked: make_locked("stable"),
437 },
438 DiffEntry::Orphan {
439 locked: make_locked("removed"),
440 },
441 ],
442 };
443
444 let plan = create_plan(&diff, &default_options(), cache_dir.path());
445 assert_eq!(plan.actions.len(), 4);
446
447 assert!(matches!(&plan.actions[0], PlannedAction::Install { .. }));
448 assert!(matches!(&plan.actions[1], PlannedAction::Overwrite { .. }));
449 assert!(matches!(&plan.actions[2], PlannedAction::Skip { .. }));
450 assert!(matches!(&plan.actions[3], PlannedAction::Remove { .. }));
451 }
452}