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 {
228            force: false,
229            dry_run: false,
230            frozen: false,
231            refresh_models: false,
232            no_refresh_models: false,
233        }
234    }
235
236    fn force_options() -> SyncOptions {
237        SyncOptions {
238            force: true,
239            dry_run: false,
240            frozen: false,
241            refresh_models: false,
242            no_refresh_models: false,
243        }
244    }
245
246    fn create_plan(
247        diff: &SyncDiff,
248        options: &SyncOptions,
249        cache_bases_dir: &std::path::Path,
250    ) -> SyncPlan {
251        let mut diag = DiagnosticCollector::new();
252        create(diff, options, cache_bases_dir, &mut diag)
253    }
254
255    fn create_plan_with_diag(
256        diff: &SyncDiff,
257        options: &SyncOptions,
258        cache_bases_dir: &std::path::Path,
259    ) -> (SyncPlan, DiagnosticCollector) {
260        let mut diag = DiagnosticCollector::new();
261        let plan = create(diff, options, cache_bases_dir, &mut diag);
262        (plan, diag)
263    }
264
265    #[test]
266    fn add_produces_install() {
267        let cache_dir = TempDir::new().unwrap();
268        let diff = SyncDiff {
269            items: vec![DiffEntry::Add {
270                target: make_target("new-agent"),
271            }],
272        };
273
274        let plan = create_plan(&diff, &default_options(), cache_dir.path());
275        assert_eq!(plan.actions.len(), 1);
276        assert!(matches!(&plan.actions[0], PlannedAction::Install { .. }));
277    }
278
279    #[test]
280    fn update_produces_overwrite() {
281        let cache_dir = TempDir::new().unwrap();
282        let diff = SyncDiff {
283            items: vec![DiffEntry::Update {
284                target: make_target("updated"),
285                locked: make_locked("updated"),
286            }],
287        };
288
289        let plan = create_plan(&diff, &default_options(), cache_dir.path());
290        assert_eq!(plan.actions.len(), 1);
291        assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
292    }
293
294    #[test]
295    fn unchanged_produces_skip() {
296        let cache_dir = TempDir::new().unwrap();
297        let diff = SyncDiff {
298            items: vec![DiffEntry::Unchanged {
299                target: make_target("stable"),
300                locked: make_locked("stable"),
301            }],
302        };
303
304        let plan = create_plan(&diff, &default_options(), cache_dir.path());
305        assert_eq!(plan.actions.len(), 1);
306        assert!(matches!(
307            &plan.actions[0],
308            PlannedAction::Skip {
309                reason: "unchanged",
310                ..
311            }
312        ));
313    }
314
315    #[test]
316    fn conflict_produces_overwrite_and_warning() {
317        let cache_dir = TempDir::new().unwrap();
318        let diff = SyncDiff {
319            items: vec![DiffEntry::Conflict {
320                target: make_target("conflicted"),
321                locked: make_locked("conflicted"),
322                local_hash: "sha256:local".into(),
323            }],
324        };
325
326        let (plan, mut diag) = create_plan_with_diag(&diff, &default_options(), cache_dir.path());
327        assert_eq!(plan.actions.len(), 1);
328        assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
329
330        let diagnostics = diag.drain();
331        assert_eq!(diagnostics.len(), 1);
332        assert_eq!(diagnostics[0].code, "conflict-overwrite");
333    }
334
335    #[test]
336    fn skill_conflict_produces_overwrite_and_warning() {
337        let cache_dir = TempDir::new().unwrap();
338        let diff = SyncDiff {
339            items: vec![DiffEntry::Conflict {
340                target: make_skill_target("planning"),
341                locked: make_skill_locked("planning"),
342                local_hash: "sha256:local".into(),
343            }],
344        };
345        let mut diag = DiagnosticCollector::new();
346
347        let plan = create(&diff, &default_options(), cache_dir.path(), &mut diag);
348        assert_eq!(plan.actions.len(), 1);
349        assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
350
351        let diagnostics = diag.drain();
352        assert_eq!(diagnostics.len(), 1);
353        assert_eq!(diagnostics[0].code, "conflict-overwrite");
354        assert_eq!(
355            diagnostics[0].message,
356            "skill `planning` has local modifications — overwriting with upstream"
357        );
358    }
359
360    #[test]
361    fn conflict_with_force_produces_overwrite() {
362        let cache_dir = TempDir::new().unwrap();
363        let diff = SyncDiff {
364            items: vec![DiffEntry::Conflict {
365                target: make_target("conflicted"),
366                locked: make_locked("conflicted"),
367                local_hash: "sha256:local".into(),
368            }],
369        };
370
371        let plan = create_plan(&diff, &force_options(), cache_dir.path());
372        assert_eq!(plan.actions.len(), 1);
373        assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
374    }
375
376    #[test]
377    fn orphan_produces_remove() {
378        let cache_dir = TempDir::new().unwrap();
379        let diff = SyncDiff {
380            items: vec![DiffEntry::Orphan {
381                locked: make_locked("removed"),
382            }],
383        };
384
385        let plan = create_plan(&diff, &default_options(), cache_dir.path());
386        assert_eq!(plan.actions.len(), 1);
387        assert!(matches!(&plan.actions[0], PlannedAction::Remove { .. }));
388    }
389
390    #[test]
391    fn local_modified_produces_keep_local() {
392        let cache_dir = TempDir::new().unwrap();
393        let diff = SyncDiff {
394            items: vec![DiffEntry::LocalModified {
395                target: make_target("modified"),
396                locked: make_locked("modified"),
397                local_hash: "sha256:local".into(),
398            }],
399        };
400
401        let plan = create_plan(&diff, &default_options(), cache_dir.path());
402        assert_eq!(plan.actions.len(), 1);
403        assert!(matches!(&plan.actions[0], PlannedAction::KeepLocal { .. }));
404    }
405
406    #[test]
407    fn local_modified_with_force_produces_overwrite() {
408        let cache_dir = TempDir::new().unwrap();
409        let diff = SyncDiff {
410            items: vec![DiffEntry::LocalModified {
411                target: make_target("modified"),
412                locked: make_locked("modified"),
413                local_hash: "sha256:local".into(),
414            }],
415        };
416
417        let plan = create_plan(&diff, &force_options(), cache_dir.path());
418        assert_eq!(plan.actions.len(), 1);
419        assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
420    }
421
422    #[test]
423    fn mixed_plan() {
424        let cache_dir = TempDir::new().unwrap();
425        let diff = SyncDiff {
426            items: vec![
427                DiffEntry::Add {
428                    target: make_target("new"),
429                },
430                DiffEntry::Update {
431                    target: make_target("updated"),
432                    locked: make_locked("updated"),
433                },
434                DiffEntry::Unchanged {
435                    target: make_target("stable"),
436                    locked: make_locked("stable"),
437                },
438                DiffEntry::Orphan {
439                    locked: make_locked("removed"),
440                },
441            ],
442        };
443
444        let plan = create_plan(&diff, &default_options(), cache_dir.path());
445        assert_eq!(plan.actions.len(), 4);
446
447        assert!(matches!(&plan.actions[0], PlannedAction::Install { .. }));
448        assert!(matches!(&plan.actions[1], PlannedAction::Overwrite { .. }));
449        assert!(matches!(&plan.actions[2], PlannedAction::Skip { .. }));
450        assert!(matches!(&plan.actions[3], PlannedAction::Remove { .. }));
451    }
452}