Skip to main content

mars_agents/sync/
plan.rs

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