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