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