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    // Reserved — plan stage emits Overwrite for conflicts; merge not yet implemented end-to-end.
36    Merge {
37        target: TargetItem,
38        base_content: Vec<u8>,
39        local_path: PathBuf,
40    },
41    /// Remove an orphaned item.
42    Remove { locked: LockedItem },
43    /// Keep the local modification.
44    KeepLocal {
45        item_id: ItemId,
46        dest_path: DestPath,
47        source_name: SourceName,
48    },
49}
50
51/// Create execution plan from diff.
52///
53/// `--force`: all Conflict entries become Overwrite (source wins).
54/// `--dry_run`: plan is computed identically but not executed (handled by apply).
55pub 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                // Source wins: overwrite local modifications
103                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                    // --force: source wins even when only local changed
121                    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::default()
228    }
229
230    fn force_options() -> SyncOptions {
231        SyncOptions {
232            force: true,
233            ..SyncOptions::default()
234        }
235    }
236
237    fn create_plan(
238        diff: &SyncDiff,
239        options: &SyncOptions,
240        cache_bases_dir: &std::path::Path,
241    ) -> SyncPlan {
242        let mut diag = DiagnosticCollector::new();
243        create(diff, options, cache_bases_dir, &mut diag)
244    }
245
246    fn create_plan_with_diag(
247        diff: &SyncDiff,
248        options: &SyncOptions,
249        cache_bases_dir: &std::path::Path,
250    ) -> (SyncPlan, DiagnosticCollector) {
251        let mut diag = DiagnosticCollector::new();
252        let plan = create(diff, options, cache_bases_dir, &mut diag);
253        (plan, diag)
254    }
255
256    #[test]
257    fn add_produces_install() {
258        let cache_dir = TempDir::new().unwrap();
259        let diff = SyncDiff {
260            items: vec![DiffEntry::Add {
261                target: make_target("new-agent"),
262            }],
263        };
264
265        let plan = create_plan(&diff, &default_options(), cache_dir.path());
266        assert_eq!(plan.actions.len(), 1);
267        assert!(matches!(&plan.actions[0], PlannedAction::Install { .. }));
268    }
269
270    #[test]
271    fn update_produces_overwrite() {
272        let cache_dir = TempDir::new().unwrap();
273        let diff = SyncDiff {
274            items: vec![DiffEntry::Update {
275                target: make_target("updated"),
276                locked: make_locked("updated"),
277            }],
278        };
279
280        let plan = create_plan(&diff, &default_options(), cache_dir.path());
281        assert_eq!(plan.actions.len(), 1);
282        assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
283    }
284
285    #[test]
286    fn unchanged_produces_skip() {
287        let cache_dir = TempDir::new().unwrap();
288        let diff = SyncDiff {
289            items: vec![DiffEntry::Unchanged {
290                target: make_target("stable"),
291                locked: make_locked("stable"),
292            }],
293        };
294
295        let plan = create_plan(&diff, &default_options(), cache_dir.path());
296        assert_eq!(plan.actions.len(), 1);
297        assert!(matches!(
298            &plan.actions[0],
299            PlannedAction::Skip {
300                reason: "unchanged",
301                ..
302            }
303        ));
304    }
305
306    #[test]
307    fn conflict_produces_overwrite_and_warning() {
308        let cache_dir = TempDir::new().unwrap();
309        let diff = SyncDiff {
310            items: vec![DiffEntry::Conflict {
311                target: make_target("conflicted"),
312                locked: make_locked("conflicted"),
313                local_hash: "sha256:local".into(),
314            }],
315        };
316
317        let (plan, mut diag) = create_plan_with_diag(&diff, &default_options(), cache_dir.path());
318        assert_eq!(plan.actions.len(), 1);
319        assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
320
321        let diagnostics = diag.drain();
322        assert_eq!(diagnostics.len(), 1);
323        assert_eq!(diagnostics[0].code, "conflict-overwrite");
324    }
325
326    #[test]
327    fn skill_conflict_produces_overwrite_and_warning() {
328        let cache_dir = TempDir::new().unwrap();
329        let diff = SyncDiff {
330            items: vec![DiffEntry::Conflict {
331                target: make_skill_target("planning"),
332                locked: make_skill_locked("planning"),
333                local_hash: "sha256:local".into(),
334            }],
335        };
336        let mut diag = DiagnosticCollector::new();
337
338        let plan = create(&diff, &default_options(), cache_dir.path(), &mut diag);
339        assert_eq!(plan.actions.len(), 1);
340        assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
341
342        let diagnostics = diag.drain();
343        assert_eq!(diagnostics.len(), 1);
344        assert_eq!(diagnostics[0].code, "conflict-overwrite");
345        assert_eq!(
346            diagnostics[0].message,
347            "skill `planning` has local modifications — overwriting with upstream"
348        );
349    }
350
351    #[test]
352    fn conflict_with_force_produces_overwrite() {
353        let cache_dir = TempDir::new().unwrap();
354        let diff = SyncDiff {
355            items: vec![DiffEntry::Conflict {
356                target: make_target("conflicted"),
357                locked: make_locked("conflicted"),
358                local_hash: "sha256:local".into(),
359            }],
360        };
361
362        let plan = create_plan(&diff, &force_options(), cache_dir.path());
363        assert_eq!(plan.actions.len(), 1);
364        assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
365    }
366
367    #[test]
368    fn orphan_produces_remove() {
369        let cache_dir = TempDir::new().unwrap();
370        let diff = SyncDiff {
371            items: vec![DiffEntry::Orphan {
372                locked: make_locked("removed"),
373            }],
374        };
375
376        let plan = create_plan(&diff, &default_options(), cache_dir.path());
377        assert_eq!(plan.actions.len(), 1);
378        assert!(matches!(&plan.actions[0], PlannedAction::Remove { .. }));
379    }
380
381    #[test]
382    fn local_modified_produces_keep_local() {
383        let cache_dir = TempDir::new().unwrap();
384        let diff = SyncDiff {
385            items: vec![DiffEntry::LocalModified {
386                target: make_target("modified"),
387                locked: make_locked("modified"),
388                local_hash: "sha256:local".into(),
389            }],
390        };
391
392        let plan = create_plan(&diff, &default_options(), cache_dir.path());
393        assert_eq!(plan.actions.len(), 1);
394        assert!(matches!(&plan.actions[0], PlannedAction::KeepLocal { .. }));
395    }
396
397    #[test]
398    fn local_modified_with_force_produces_overwrite() {
399        let cache_dir = TempDir::new().unwrap();
400        let diff = SyncDiff {
401            items: vec![DiffEntry::LocalModified {
402                target: make_target("modified"),
403                locked: make_locked("modified"),
404                local_hash: "sha256:local".into(),
405            }],
406        };
407
408        let plan = create_plan(&diff, &force_options(), cache_dir.path());
409        assert_eq!(plan.actions.len(), 1);
410        assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
411    }
412
413    #[test]
414    fn mixed_plan() {
415        let cache_dir = TempDir::new().unwrap();
416        let diff = SyncDiff {
417            items: vec![
418                DiffEntry::Add {
419                    target: make_target("new"),
420                },
421                DiffEntry::Update {
422                    target: make_target("updated"),
423                    locked: make_locked("updated"),
424                },
425                DiffEntry::Unchanged {
426                    target: make_target("stable"),
427                    locked: make_locked("stable"),
428                },
429                DiffEntry::Orphan {
430                    locked: make_locked("removed"),
431                },
432            ],
433        };
434
435        let plan = create_plan(&diff, &default_options(), cache_dir.path());
436        assert_eq!(plan.actions.len(), 4);
437
438        assert!(matches!(&plan.actions[0], PlannedAction::Install { .. }));
439        assert!(matches!(&plan.actions[1], PlannedAction::Overwrite { .. }));
440        assert!(matches!(&plan.actions[2], PlannedAction::Skip { .. }));
441        assert!(matches!(&plan.actions[3], PlannedAction::Remove { .. }));
442    }
443}