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