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