Skip to main content

mars_agents/sync/
plan.rs

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/// A planned set of actions to execute.
11#[derive(Debug, Clone)]
12pub struct SyncPlan {
13    pub actions: Vec<PlannedAction>,
14}
15
16/// A single planned action derived from a diff entry.
17///
18/// The plan accounts for `--force` (all conflicts become `Overwrite`)
19/// and `--diff` (plan is computed but not executed).
20#[derive(Debug, Clone)]
21pub enum PlannedAction {
22    /// Copy source content to destination.
23    Install { target: TargetItem },
24    /// Overwrite existing file with new source content.
25    Overwrite { target: TargetItem },
26    /// Skip — no changes needed.
27    Skip {
28        item_id: ItemId,
29        dest_path: DestPath,
30        source_name: SourceName,
31        installed_checksum: Option<ContentHash>,
32        reason: &'static str,
33    },
34    /// Three-way merge required.
35    Merge {
36        target: TargetItem,
37        base_content: Vec<u8>,
38        local_path: PathBuf,
39    },
40    /// Remove an orphaned item.
41    Remove { locked: LockedItem },
42    /// Keep the local modification.
43    KeepLocal {
44        item_id: ItemId,
45        dest_path: DestPath,
46        source_name: SourceName,
47    },
48}
49
50/// Create execution plan from diff.
51///
52/// `--force`: all Conflict entries become Overwrite (source wins).
53/// `--dry_run`: plan is computed identically but not executed (handled by apply).
54pub 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                // Source wins: overwrite local modifications
102                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                    // --force: source wins even when only local changed
120                    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}