Skip to main content

mars_agents/sync/
plan.rs

1use std::path::PathBuf;
2
3use crate::lock::{ItemId, ItemKind, LockedItem};
4use crate::sync::diff::{DiffEntry, SyncDiff};
5use crate::sync::target::TargetItem;
6use crate::sync::types::SyncOptions;
7use crate::types::{DestPath, ItemName, Materialization, 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 local_source_name: SourceName = crate::types::SourceOrigin::LocalPackage.to_string().into();
67    let mut actions = Vec::new();
68
69    for entry in &diff.items {
70        match entry {
71            DiffEntry::Add { target } => match &target.materialization {
72                Materialization::Copy => {
73                    actions.push(PlannedAction::Install {
74                        target: target.clone(),
75                    });
76                }
77                Materialization::Symlink { source_abs } => {
78                    actions.push(symlink_action(target, source_abs));
79                }
80            },
81
82            DiffEntry::Update { target, locked: _ } => match &target.materialization {
83                Materialization::Copy => {
84                    actions.push(PlannedAction::Overwrite {
85                        target: target.clone(),
86                    });
87                }
88                Materialization::Symlink { source_abs } => {
89                    actions.push(symlink_action(target, source_abs));
90                }
91            },
92
93            DiffEntry::Unchanged { target, locked } => match &target.materialization {
94                Materialization::Symlink { source_abs } if locked.source != local_source_name => {
95                    actions.push(symlink_action(target, source_abs));
96                }
97                _ => {
98                    actions.push(PlannedAction::Skip {
99                        item_id: target.id.clone(),
100                        dest_path: target.dest_path.clone(),
101                        source_name: target.source_name.clone(),
102                        reason: "unchanged",
103                    });
104                }
105            },
106
107            DiffEntry::Conflict {
108                target,
109                locked,
110                local_hash: _,
111            } => match &target.materialization {
112                Materialization::Symlink { source_abs } => {
113                    actions.push(symlink_action(target, source_abs));
114                }
115                Materialization::Copy => {
116                    if options.force {
117                        // --force: source wins, overwrite local modifications
118                        actions.push(PlannedAction::Overwrite {
119                            target: target.clone(),
120                        });
121                    } else {
122                        // Three-way merge needed
123                        let base_path = cache_bases_dir.join(locked.installed_checksum.as_ref());
124
125                        // Read base content from cache (or empty if missing)
126                        let base_content = std::fs::read(&base_path).unwrap_or_default();
127
128                        // Local path is the installed dest
129                        let local_path = locked.dest_path.as_path().to_path_buf();
130
131                        actions.push(PlannedAction::Merge {
132                            target: target.clone(),
133                            base_content,
134                            local_path,
135                        });
136                    }
137                }
138            },
139
140            DiffEntry::Orphan { locked } => {
141                actions.push(PlannedAction::Remove {
142                    locked: locked.clone(),
143                });
144            }
145
146            DiffEntry::LocalModified {
147                target,
148                locked: _,
149                local_hash: _,
150            } => match &target.materialization {
151                Materialization::Symlink { source_abs } => {
152                    actions.push(symlink_action(target, source_abs));
153                }
154                Materialization::Copy => {
155                    if options.force {
156                        // --force: source wins even when only local changed
157                        actions.push(PlannedAction::Overwrite {
158                            target: target.clone(),
159                        });
160                    } else {
161                        actions.push(PlannedAction::KeepLocal {
162                            item_id: target.id.clone(),
163                            dest_path: target.dest_path.clone(),
164                            source_name: target.source_name.clone(),
165                        });
166                    }
167                }
168            },
169        }
170    }
171
172    SyncPlan { actions }
173}
174
175fn symlink_action(target: &TargetItem, source_abs: &std::path::Path) -> PlannedAction {
176    PlannedAction::Symlink {
177        source_abs: source_abs.to_path_buf(),
178        dest_rel: target.dest_path.clone(),
179        kind: target.id.kind,
180        name: target.id.name.clone(),
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use crate::hash;
188    use crate::lock::{ItemId, ItemKind, LockedItem};
189    use crate::sync::diff::{DiffEntry, SyncDiff};
190    use crate::sync::target::TargetItem;
191    use std::path::PathBuf;
192    use tempfile::TempDir;
193
194    fn make_target(name: &str) -> TargetItem {
195        TargetItem {
196            id: ItemId {
197                kind: ItemKind::Agent,
198                name: name.into(),
199            },
200            source_name: "test".into(),
201            origin: crate::types::SourceOrigin::Dependency("test".into()),
202            materialization: crate::types::Materialization::Copy,
203            source_id: crate::types::SourceId::Path {
204                canonical: PathBuf::from(format!("/tmp/source/agents/{name}.md")),
205            },
206            source_path: PathBuf::from(format!("/tmp/source/agents/{name}.md")),
207            dest_path: format!("agents/{name}.md").into(),
208            source_hash: hash::hash_bytes(b"test content").into(),
209            is_flat_skill: false,
210            rewritten_content: None,
211        }
212    }
213
214    fn make_symlink_target(name: &str) -> TargetItem {
215        TargetItem {
216            materialization: crate::types::Materialization::Symlink {
217                source_abs: PathBuf::from(format!("/tmp/source/local/agents/{name}.md")),
218            },
219            source_name: crate::types::SourceOrigin::LocalPackage.to_string().into(),
220            origin: crate::types::SourceOrigin::LocalPackage,
221            ..make_target(name)
222        }
223    }
224
225    fn make_locked(name: &str) -> LockedItem {
226        LockedItem {
227            source: "test".into(),
228            kind: ItemKind::Agent,
229            version: None,
230            source_checksum: hash::hash_bytes(b"old content").into(),
231            installed_checksum: hash::hash_bytes(b"old content").into(),
232            dest_path: format!("agents/{name}.md").into(),
233        }
234    }
235
236    fn make_locked_from_source(name: &str, source: &str) -> LockedItem {
237        LockedItem {
238            source: source.into(),
239            ..make_locked(name)
240        }
241    }
242
243    fn default_options() -> SyncOptions {
244        SyncOptions {
245            force: false,
246            dry_run: false,
247            frozen: false,
248        }
249    }
250
251    fn force_options() -> SyncOptions {
252        SyncOptions {
253            force: true,
254            dry_run: false,
255            frozen: false,
256        }
257    }
258
259    #[test]
260    fn add_produces_install() {
261        let cache_dir = TempDir::new().unwrap();
262        let diff = SyncDiff {
263            items: vec![DiffEntry::Add {
264                target: make_target("new-agent"),
265            }],
266        };
267
268        let plan = create(&diff, &default_options(), cache_dir.path());
269        assert_eq!(plan.actions.len(), 1);
270        assert!(matches!(&plan.actions[0], PlannedAction::Install { .. }));
271    }
272
273    #[test]
274    fn update_produces_overwrite() {
275        let cache_dir = TempDir::new().unwrap();
276        let diff = SyncDiff {
277            items: vec![DiffEntry::Update {
278                target: make_target("updated"),
279                locked: make_locked("updated"),
280            }],
281        };
282
283        let plan = create(&diff, &default_options(), cache_dir.path());
284        assert_eq!(plan.actions.len(), 1);
285        assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
286    }
287
288    #[test]
289    fn unchanged_produces_skip() {
290        let cache_dir = TempDir::new().unwrap();
291        let diff = SyncDiff {
292            items: vec![DiffEntry::Unchanged {
293                target: make_target("stable"),
294                locked: make_locked("stable"),
295            }],
296        };
297
298        let plan = create(&diff, &default_options(), cache_dir.path());
299        assert_eq!(plan.actions.len(), 1);
300        assert!(matches!(
301            &plan.actions[0],
302            PlannedAction::Skip {
303                reason: "unchanged",
304                ..
305            }
306        ));
307    }
308
309    #[test]
310    fn conflict_produces_merge() {
311        let cache_dir = TempDir::new().unwrap();
312        let diff = SyncDiff {
313            items: vec![DiffEntry::Conflict {
314                target: make_target("conflicted"),
315                locked: make_locked("conflicted"),
316                local_hash: "sha256:local".into(),
317            }],
318        };
319
320        let plan = create(&diff, &default_options(), cache_dir.path());
321        assert_eq!(plan.actions.len(), 1);
322        assert!(matches!(&plan.actions[0], PlannedAction::Merge { .. }));
323    }
324
325    #[test]
326    fn conflict_with_symlink_materialization_produces_symlink() {
327        let cache_dir = TempDir::new().unwrap();
328        let diff = SyncDiff {
329            items: vec![DiffEntry::Conflict {
330                target: make_symlink_target("conflicted"),
331                locked: make_locked("conflicted"),
332                local_hash: "sha256:local".into(),
333            }],
334        };
335
336        let plan = create(&diff, &default_options(), cache_dir.path());
337        assert_eq!(plan.actions.len(), 1);
338        assert!(matches!(&plan.actions[0], PlannedAction::Symlink { .. }));
339    }
340
341    #[test]
342    fn conflict_with_force_produces_overwrite() {
343        let cache_dir = TempDir::new().unwrap();
344        let diff = SyncDiff {
345            items: vec![DiffEntry::Conflict {
346                target: make_target("conflicted"),
347                locked: make_locked("conflicted"),
348                local_hash: "sha256:local".into(),
349            }],
350        };
351
352        let plan = create(&diff, &force_options(), cache_dir.path());
353        assert_eq!(plan.actions.len(), 1);
354        assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
355    }
356
357    #[test]
358    fn orphan_produces_remove() {
359        let cache_dir = TempDir::new().unwrap();
360        let diff = SyncDiff {
361            items: vec![DiffEntry::Orphan {
362                locked: make_locked("removed"),
363            }],
364        };
365
366        let plan = create(&diff, &default_options(), cache_dir.path());
367        assert_eq!(plan.actions.len(), 1);
368        assert!(matches!(&plan.actions[0], PlannedAction::Remove { .. }));
369    }
370
371    #[test]
372    fn local_modified_produces_keep_local() {
373        let cache_dir = TempDir::new().unwrap();
374        let diff = SyncDiff {
375            items: vec![DiffEntry::LocalModified {
376                target: make_target("modified"),
377                locked: make_locked("modified"),
378                local_hash: "sha256:local".into(),
379            }],
380        };
381
382        let plan = create(&diff, &default_options(), cache_dir.path());
383        assert_eq!(plan.actions.len(), 1);
384        assert!(matches!(&plan.actions[0], PlannedAction::KeepLocal { .. }));
385    }
386
387    #[test]
388    fn local_modified_with_force_produces_overwrite() {
389        let cache_dir = TempDir::new().unwrap();
390        let diff = SyncDiff {
391            items: vec![DiffEntry::LocalModified {
392                target: make_target("modified"),
393                locked: make_locked("modified"),
394                local_hash: "sha256:local".into(),
395            }],
396        };
397
398        let plan = create(&diff, &force_options(), cache_dir.path());
399        assert_eq!(plan.actions.len(), 1);
400        assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
401    }
402
403    #[test]
404    fn local_modified_with_symlink_materialization_produces_symlink() {
405        let cache_dir = TempDir::new().unwrap();
406        let diff = SyncDiff {
407            items: vec![DiffEntry::LocalModified {
408                target: make_symlink_target("modified"),
409                locked: make_locked("modified"),
410                local_hash: "sha256:local".into(),
411            }],
412        };
413
414        let plan = create(&diff, &default_options(), cache_dir.path());
415        assert_eq!(plan.actions.len(), 1);
416        assert!(matches!(&plan.actions[0], PlannedAction::Symlink { .. }));
417    }
418
419    #[test]
420    fn unchanged_symlink_with_dependency_lock_source_produces_symlink() {
421        let cache_dir = TempDir::new().unwrap();
422        let diff = SyncDiff {
423            items: vec![DiffEntry::Unchanged {
424                target: make_symlink_target("owner-change"),
425                locked: make_locked_from_source("owner-change", "dep-source"),
426            }],
427        };
428
429        let plan = create(&diff, &default_options(), cache_dir.path());
430        assert_eq!(plan.actions.len(), 1);
431        assert!(matches!(&plan.actions[0], PlannedAction::Symlink { .. }));
432    }
433
434    #[test]
435    fn unchanged_symlink_with_self_lock_source_produces_skip() {
436        let cache_dir = TempDir::new().unwrap();
437        let diff = SyncDiff {
438            items: vec![DiffEntry::Unchanged {
439                target: make_symlink_target("already-self"),
440                locked: make_locked_from_source(
441                    "already-self",
442                    crate::types::SourceOrigin::LocalPackage
443                        .to_string()
444                        .as_str(),
445                ),
446            }],
447        };
448
449        let plan = create(&diff, &default_options(), cache_dir.path());
450        assert_eq!(plan.actions.len(), 1);
451        assert!(matches!(
452            &plan.actions[0],
453            PlannedAction::Skip {
454                reason: "unchanged",
455                ..
456            }
457        ));
458    }
459
460    #[test]
461    fn merge_reads_base_from_cache() {
462        let cache_dir = TempDir::new().unwrap();
463        let installed_hash = hash::hash_bytes(b"installed content");
464
465        // Write base content to cache
466        let base_path = cache_dir.path().join(&installed_hash);
467        std::fs::write(&base_path, b"installed content").unwrap();
468
469        let diff = SyncDiff {
470            items: vec![DiffEntry::Conflict {
471                target: make_target("agent"),
472                locked: {
473                    let mut locked = make_locked("agent");
474                    locked.installed_checksum = installed_hash.into();
475                    locked
476                },
477                local_hash: "sha256:local".into(),
478            }],
479        };
480
481        let plan = create(&diff, &default_options(), cache_dir.path());
482        match &plan.actions[0] {
483            PlannedAction::Merge { base_content, .. } => {
484                assert_eq!(base_content, b"installed content");
485            }
486            other => panic!("expected Merge, got {other:?}"),
487        }
488    }
489
490    #[test]
491    fn merge_with_missing_cache_uses_empty_base() {
492        let cache_dir = TempDir::new().unwrap();
493        // Don't write any cache file
494
495        let diff = SyncDiff {
496            items: vec![DiffEntry::Conflict {
497                target: make_target("agent"),
498                locked: make_locked("agent"),
499                local_hash: "sha256:local".into(),
500            }],
501        };
502
503        let plan = create(&diff, &default_options(), cache_dir.path());
504        match &plan.actions[0] {
505            PlannedAction::Merge { base_content, .. } => {
506                assert!(
507                    base_content.is_empty(),
508                    "missing cache should fall back to empty base"
509                );
510            }
511            other => panic!("expected Merge, got {other:?}"),
512        }
513    }
514
515    #[test]
516    fn mixed_plan() {
517        let cache_dir = TempDir::new().unwrap();
518        let diff = SyncDiff {
519            items: vec![
520                DiffEntry::Add {
521                    target: make_target("new"),
522                },
523                DiffEntry::Update {
524                    target: make_target("updated"),
525                    locked: make_locked("updated"),
526                },
527                DiffEntry::Unchanged {
528                    target: make_target("stable"),
529                    locked: make_locked("stable"),
530                },
531                DiffEntry::Orphan {
532                    locked: make_locked("removed"),
533                },
534            ],
535        };
536
537        let plan = create(&diff, &default_options(), cache_dir.path());
538        assert_eq!(plan.actions.len(), 4);
539
540        assert!(matches!(&plan.actions[0], PlannedAction::Install { .. }));
541        assert!(matches!(&plan.actions[1], PlannedAction::Overwrite { .. }));
542        assert!(matches!(&plan.actions[2], PlannedAction::Skip { .. }));
543        assert!(matches!(&plan.actions[3], PlannedAction::Remove { .. }));
544    }
545}