Skip to main content

mars_agents/sync/
plan.rs

1use std::path::PathBuf;
2
3use crate::lock::{ItemId, LockedItem};
4use crate::sync::apply::SyncOptions;
5use crate::sync::diff::{DiffEntry, SyncDiff};
6use crate::sync::target::TargetItem;
7use crate::types::{DestPath, SourceName};
8
9/// A planned set of actions to execute.
10#[derive(Debug, Clone)]
11pub struct SyncPlan {
12    pub actions: Vec<PlannedAction>,
13}
14
15/// A single planned action derived from a diff entry.
16///
17/// The plan accounts for `--force` (all conflicts become `Overwrite`)
18/// and `--diff` (plan is computed but not executed).
19#[derive(Debug, Clone)]
20pub enum PlannedAction {
21    /// Copy source content to destination.
22    Install { target: TargetItem },
23    /// Overwrite existing file with new source content.
24    Overwrite { target: TargetItem },
25    /// Skip — no changes needed.
26    Skip {
27        item_id: ItemId,
28        dest_path: DestPath,
29        source_name: SourceName,
30        reason: &'static str,
31    },
32    /// Three-way merge required.
33    Merge {
34        target: TargetItem,
35        base_content: Vec<u8>,
36        local_path: PathBuf,
37    },
38    /// Remove an orphaned item.
39    Remove { locked: LockedItem },
40    /// Keep the local modification.
41    KeepLocal {
42        item_id: ItemId,
43        dest_path: DestPath,
44        source_name: SourceName,
45    },
46}
47
48/// Create execution plan from diff.
49///
50/// `--force`: all Conflict entries become Overwrite (source wins).
51/// `--dry_run`: plan is computed identically but not executed (handled by apply).
52pub fn create(
53    diff: &SyncDiff,
54    options: &SyncOptions,
55    cache_bases_dir: &std::path::Path,
56) -> SyncPlan {
57    let mut actions = Vec::new();
58
59    for entry in &diff.items {
60        match entry {
61            DiffEntry::Add { target } => {
62                actions.push(PlannedAction::Install {
63                    target: target.clone(),
64                });
65            }
66
67            DiffEntry::Update { target, locked: _ } => {
68                actions.push(PlannedAction::Overwrite {
69                    target: target.clone(),
70                });
71            }
72
73            DiffEntry::Unchanged { target, locked: _ } => {
74                actions.push(PlannedAction::Skip {
75                    item_id: target.id.clone(),
76                    dest_path: target.dest_path.clone(),
77                    source_name: target.source_name.clone(),
78                    reason: "unchanged",
79                });
80            }
81
82            DiffEntry::Conflict {
83                target,
84                locked,
85                local_hash: _,
86            } => {
87                if options.force {
88                    // --force: source wins, overwrite local modifications
89                    actions.push(PlannedAction::Overwrite {
90                        target: target.clone(),
91                    });
92                } else {
93                    // Three-way merge needed
94                    let base_path = cache_bases_dir.join(locked.installed_checksum.as_ref());
95
96                    // Read base content from cache (or empty if missing)
97                    let base_content = std::fs::read(&base_path).unwrap_or_default();
98
99                    // Local path is the installed dest
100                    let local_path = locked.dest_path.as_path().to_path_buf();
101
102                    actions.push(PlannedAction::Merge {
103                        target: target.clone(),
104                        base_content,
105                        local_path,
106                    });
107                }
108            }
109
110            DiffEntry::Orphan { locked } => {
111                actions.push(PlannedAction::Remove {
112                    locked: locked.clone(),
113                });
114            }
115
116            DiffEntry::LocalModified {
117                target,
118                locked: _,
119                local_hash: _,
120            } => {
121                if options.force {
122                    // --force: source wins even when only local changed
123                    actions.push(PlannedAction::Overwrite {
124                        target: target.clone(),
125                    });
126                } else {
127                    actions.push(PlannedAction::KeepLocal {
128                        item_id: target.id.clone(),
129                        dest_path: target.dest_path.clone(),
130                        source_name: target.source_name.clone(),
131                    });
132                }
133            }
134        }
135    }
136
137    SyncPlan { actions }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::hash;
144    use crate::lock::{ItemId, ItemKind, LockedItem};
145    use crate::sync::diff::{DiffEntry, SyncDiff};
146    use crate::sync::target::TargetItem;
147    use std::path::PathBuf;
148    use tempfile::TempDir;
149
150    fn make_target(name: &str) -> TargetItem {
151        TargetItem {
152            id: ItemId {
153                kind: ItemKind::Agent,
154                name: name.into(),
155            },
156            source_name: "test".into(),
157            source_id: crate::types::SourceId::Path {
158                canonical: PathBuf::from(format!("/tmp/source/agents/{name}.md")),
159            },
160            source_path: PathBuf::from(format!("/tmp/source/agents/{name}.md")),
161            dest_path: format!("agents/{name}.md").into(),
162            source_hash: hash::hash_bytes(b"test content").into(),
163            is_flat_skill: false,
164            rewritten_content: None,
165        }
166    }
167
168    fn make_locked(name: &str) -> LockedItem {
169        LockedItem {
170            source: "test".into(),
171            kind: ItemKind::Agent,
172            version: None,
173            source_checksum: hash::hash_bytes(b"old content").into(),
174            installed_checksum: hash::hash_bytes(b"old content").into(),
175            dest_path: format!("agents/{name}.md").into(),
176        }
177    }
178
179    fn default_options() -> SyncOptions {
180        SyncOptions {
181            force: false,
182            dry_run: false,
183            frozen: false,
184        }
185    }
186
187    fn force_options() -> SyncOptions {
188        SyncOptions {
189            force: true,
190            dry_run: false,
191            frozen: false,
192        }
193    }
194
195    #[test]
196    fn add_produces_install() {
197        let cache_dir = TempDir::new().unwrap();
198        let diff = SyncDiff {
199            items: vec![DiffEntry::Add {
200                target: make_target("new-agent"),
201            }],
202        };
203
204        let plan = create(&diff, &default_options(), cache_dir.path());
205        assert_eq!(plan.actions.len(), 1);
206        assert!(matches!(&plan.actions[0], PlannedAction::Install { .. }));
207    }
208
209    #[test]
210    fn update_produces_overwrite() {
211        let cache_dir = TempDir::new().unwrap();
212        let diff = SyncDiff {
213            items: vec![DiffEntry::Update {
214                target: make_target("updated"),
215                locked: make_locked("updated"),
216            }],
217        };
218
219        let plan = create(&diff, &default_options(), cache_dir.path());
220        assert_eq!(plan.actions.len(), 1);
221        assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
222    }
223
224    #[test]
225    fn unchanged_produces_skip() {
226        let cache_dir = TempDir::new().unwrap();
227        let diff = SyncDiff {
228            items: vec![DiffEntry::Unchanged {
229                target: make_target("stable"),
230                locked: make_locked("stable"),
231            }],
232        };
233
234        let plan = create(&diff, &default_options(), cache_dir.path());
235        assert_eq!(plan.actions.len(), 1);
236        assert!(matches!(
237            &plan.actions[0],
238            PlannedAction::Skip {
239                reason: "unchanged",
240                ..
241            }
242        ));
243    }
244
245    #[test]
246    fn conflict_produces_merge() {
247        let cache_dir = TempDir::new().unwrap();
248        let diff = SyncDiff {
249            items: vec![DiffEntry::Conflict {
250                target: make_target("conflicted"),
251                locked: make_locked("conflicted"),
252                local_hash: "sha256:local".into(),
253            }],
254        };
255
256        let plan = create(&diff, &default_options(), cache_dir.path());
257        assert_eq!(plan.actions.len(), 1);
258        assert!(matches!(&plan.actions[0], PlannedAction::Merge { .. }));
259    }
260
261    #[test]
262    fn conflict_with_force_produces_overwrite() {
263        let cache_dir = TempDir::new().unwrap();
264        let diff = SyncDiff {
265            items: vec![DiffEntry::Conflict {
266                target: make_target("conflicted"),
267                locked: make_locked("conflicted"),
268                local_hash: "sha256:local".into(),
269            }],
270        };
271
272        let plan = create(&diff, &force_options(), cache_dir.path());
273        assert_eq!(plan.actions.len(), 1);
274        assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
275    }
276
277    #[test]
278    fn orphan_produces_remove() {
279        let cache_dir = TempDir::new().unwrap();
280        let diff = SyncDiff {
281            items: vec![DiffEntry::Orphan {
282                locked: make_locked("removed"),
283            }],
284        };
285
286        let plan = create(&diff, &default_options(), cache_dir.path());
287        assert_eq!(plan.actions.len(), 1);
288        assert!(matches!(&plan.actions[0], PlannedAction::Remove { .. }));
289    }
290
291    #[test]
292    fn local_modified_produces_keep_local() {
293        let cache_dir = TempDir::new().unwrap();
294        let diff = SyncDiff {
295            items: vec![DiffEntry::LocalModified {
296                target: make_target("modified"),
297                locked: make_locked("modified"),
298                local_hash: "sha256:local".into(),
299            }],
300        };
301
302        let plan = create(&diff, &default_options(), cache_dir.path());
303        assert_eq!(plan.actions.len(), 1);
304        assert!(matches!(&plan.actions[0], PlannedAction::KeepLocal { .. }));
305    }
306
307    #[test]
308    fn local_modified_with_force_produces_overwrite() {
309        let cache_dir = TempDir::new().unwrap();
310        let diff = SyncDiff {
311            items: vec![DiffEntry::LocalModified {
312                target: make_target("modified"),
313                locked: make_locked("modified"),
314                local_hash: "sha256:local".into(),
315            }],
316        };
317
318        let plan = create(&diff, &force_options(), cache_dir.path());
319        assert_eq!(plan.actions.len(), 1);
320        assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
321    }
322
323    #[test]
324    fn merge_reads_base_from_cache() {
325        let cache_dir = TempDir::new().unwrap();
326        let installed_hash = hash::hash_bytes(b"installed content");
327
328        // Write base content to cache
329        let base_path = cache_dir.path().join(&installed_hash);
330        std::fs::write(&base_path, b"installed content").unwrap();
331
332        let diff = SyncDiff {
333            items: vec![DiffEntry::Conflict {
334                target: make_target("agent"),
335                locked: {
336                    let mut locked = make_locked("agent");
337                    locked.installed_checksum = installed_hash.into();
338                    locked
339                },
340                local_hash: "sha256:local".into(),
341            }],
342        };
343
344        let plan = create(&diff, &default_options(), cache_dir.path());
345        match &plan.actions[0] {
346            PlannedAction::Merge { base_content, .. } => {
347                assert_eq!(base_content, b"installed content");
348            }
349            other => panic!("expected Merge, got {other:?}"),
350        }
351    }
352
353    #[test]
354    fn merge_with_missing_cache_uses_empty_base() {
355        let cache_dir = TempDir::new().unwrap();
356        // Don't write any cache file
357
358        let diff = SyncDiff {
359            items: vec![DiffEntry::Conflict {
360                target: make_target("agent"),
361                locked: make_locked("agent"),
362                local_hash: "sha256:local".into(),
363            }],
364        };
365
366        let plan = create(&diff, &default_options(), cache_dir.path());
367        match &plan.actions[0] {
368            PlannedAction::Merge { base_content, .. } => {
369                assert!(
370                    base_content.is_empty(),
371                    "missing cache should fall back to empty base"
372                );
373            }
374            other => panic!("expected Merge, got {other:?}"),
375        }
376    }
377
378    #[test]
379    fn mixed_plan() {
380        let cache_dir = TempDir::new().unwrap();
381        let diff = SyncDiff {
382            items: vec![
383                DiffEntry::Add {
384                    target: make_target("new"),
385                },
386                DiffEntry::Update {
387                    target: make_target("updated"),
388                    locked: make_locked("updated"),
389                },
390                DiffEntry::Unchanged {
391                    target: make_target("stable"),
392                    locked: make_locked("stable"),
393                },
394                DiffEntry::Orphan {
395                    locked: make_locked("removed"),
396                },
397            ],
398        };
399
400        let plan = create(&diff, &default_options(), cache_dir.path());
401        assert_eq!(plan.actions.len(), 4);
402
403        assert!(matches!(&plan.actions[0], PlannedAction::Install { .. }));
404        assert!(matches!(&plan.actions[1], PlannedAction::Overwrite { .. }));
405        assert!(matches!(&plan.actions[2], PlannedAction::Skip { .. }));
406        assert!(matches!(&plan.actions[3], PlannedAction::Remove { .. }));
407    }
408}