Skip to main content

mars_agents/sync/
plan.rs

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