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            no_refresh_models: false,
249        }
250    }
251
252    fn force_options() -> SyncOptions {
253        SyncOptions {
254            force: true,
255            dry_run: false,
256            frozen: false,
257            no_refresh_models: false,
258        }
259    }
260
261    #[test]
262    fn add_produces_install() {
263        let cache_dir = TempDir::new().unwrap();
264        let diff = SyncDiff {
265            items: vec![DiffEntry::Add {
266                target: make_target("new-agent"),
267            }],
268        };
269
270        let plan = create(&diff, &default_options(), cache_dir.path());
271        assert_eq!(plan.actions.len(), 1);
272        assert!(matches!(&plan.actions[0], PlannedAction::Install { .. }));
273    }
274
275    #[test]
276    fn update_produces_overwrite() {
277        let cache_dir = TempDir::new().unwrap();
278        let diff = SyncDiff {
279            items: vec![DiffEntry::Update {
280                target: make_target("updated"),
281                locked: make_locked("updated"),
282            }],
283        };
284
285        let plan = create(&diff, &default_options(), cache_dir.path());
286        assert_eq!(plan.actions.len(), 1);
287        assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
288    }
289
290    #[test]
291    fn unchanged_produces_skip() {
292        let cache_dir = TempDir::new().unwrap();
293        let diff = SyncDiff {
294            items: vec![DiffEntry::Unchanged {
295                target: make_target("stable"),
296                locked: make_locked("stable"),
297            }],
298        };
299
300        let plan = create(&diff, &default_options(), cache_dir.path());
301        assert_eq!(plan.actions.len(), 1);
302        assert!(matches!(
303            &plan.actions[0],
304            PlannedAction::Skip {
305                reason: "unchanged",
306                ..
307            }
308        ));
309    }
310
311    #[test]
312    fn conflict_produces_merge() {
313        let cache_dir = TempDir::new().unwrap();
314        let diff = SyncDiff {
315            items: vec![DiffEntry::Conflict {
316                target: make_target("conflicted"),
317                locked: make_locked("conflicted"),
318                local_hash: "sha256:local".into(),
319            }],
320        };
321
322        let plan = create(&diff, &default_options(), cache_dir.path());
323        assert_eq!(plan.actions.len(), 1);
324        assert!(matches!(&plan.actions[0], PlannedAction::Merge { .. }));
325    }
326
327    #[test]
328    fn conflict_with_symlink_materialization_produces_symlink() {
329        let cache_dir = TempDir::new().unwrap();
330        let diff = SyncDiff {
331            items: vec![DiffEntry::Conflict {
332                target: make_symlink_target("conflicted"),
333                locked: make_locked("conflicted"),
334                local_hash: "sha256:local".into(),
335            }],
336        };
337
338        let plan = create(&diff, &default_options(), cache_dir.path());
339        assert_eq!(plan.actions.len(), 1);
340        assert!(matches!(&plan.actions[0], PlannedAction::Symlink { .. }));
341    }
342
343    #[test]
344    fn conflict_with_force_produces_overwrite() {
345        let cache_dir = TempDir::new().unwrap();
346        let diff = SyncDiff {
347            items: vec![DiffEntry::Conflict {
348                target: make_target("conflicted"),
349                locked: make_locked("conflicted"),
350                local_hash: "sha256:local".into(),
351            }],
352        };
353
354        let plan = create(&diff, &force_options(), cache_dir.path());
355        assert_eq!(plan.actions.len(), 1);
356        assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
357    }
358
359    #[test]
360    fn orphan_produces_remove() {
361        let cache_dir = TempDir::new().unwrap();
362        let diff = SyncDiff {
363            items: vec![DiffEntry::Orphan {
364                locked: make_locked("removed"),
365            }],
366        };
367
368        let plan = create(&diff, &default_options(), cache_dir.path());
369        assert_eq!(plan.actions.len(), 1);
370        assert!(matches!(&plan.actions[0], PlannedAction::Remove { .. }));
371    }
372
373    #[test]
374    fn local_modified_produces_keep_local() {
375        let cache_dir = TempDir::new().unwrap();
376        let diff = SyncDiff {
377            items: vec![DiffEntry::LocalModified {
378                target: make_target("modified"),
379                locked: make_locked("modified"),
380                local_hash: "sha256:local".into(),
381            }],
382        };
383
384        let plan = create(&diff, &default_options(), cache_dir.path());
385        assert_eq!(plan.actions.len(), 1);
386        assert!(matches!(&plan.actions[0], PlannedAction::KeepLocal { .. }));
387    }
388
389    #[test]
390    fn local_modified_with_force_produces_overwrite() {
391        let cache_dir = TempDir::new().unwrap();
392        let diff = SyncDiff {
393            items: vec![DiffEntry::LocalModified {
394                target: make_target("modified"),
395                locked: make_locked("modified"),
396                local_hash: "sha256:local".into(),
397            }],
398        };
399
400        let plan = create(&diff, &force_options(), cache_dir.path());
401        assert_eq!(plan.actions.len(), 1);
402        assert!(matches!(&plan.actions[0], PlannedAction::Overwrite { .. }));
403    }
404
405    #[test]
406    fn local_modified_with_symlink_materialization_produces_symlink() {
407        let cache_dir = TempDir::new().unwrap();
408        let diff = SyncDiff {
409            items: vec![DiffEntry::LocalModified {
410                target: make_symlink_target("modified"),
411                locked: make_locked("modified"),
412                local_hash: "sha256:local".into(),
413            }],
414        };
415
416        let plan = create(&diff, &default_options(), cache_dir.path());
417        assert_eq!(plan.actions.len(), 1);
418        assert!(matches!(&plan.actions[0], PlannedAction::Symlink { .. }));
419    }
420
421    #[test]
422    fn unchanged_symlink_with_dependency_lock_source_produces_symlink() {
423        let cache_dir = TempDir::new().unwrap();
424        let diff = SyncDiff {
425            items: vec![DiffEntry::Unchanged {
426                target: make_symlink_target("owner-change"),
427                locked: make_locked_from_source("owner-change", "dep-source"),
428            }],
429        };
430
431        let plan = create(&diff, &default_options(), cache_dir.path());
432        assert_eq!(plan.actions.len(), 1);
433        assert!(matches!(&plan.actions[0], PlannedAction::Symlink { .. }));
434    }
435
436    #[test]
437    fn unchanged_symlink_with_self_lock_source_produces_skip() {
438        let cache_dir = TempDir::new().unwrap();
439        let diff = SyncDiff {
440            items: vec![DiffEntry::Unchanged {
441                target: make_symlink_target("already-self"),
442                locked: make_locked_from_source(
443                    "already-self",
444                    crate::types::SourceOrigin::LocalPackage
445                        .to_string()
446                        .as_str(),
447                ),
448            }],
449        };
450
451        let plan = create(&diff, &default_options(), cache_dir.path());
452        assert_eq!(plan.actions.len(), 1);
453        assert!(matches!(
454            &plan.actions[0],
455            PlannedAction::Skip {
456                reason: "unchanged",
457                ..
458            }
459        ));
460    }
461
462    #[test]
463    fn merge_reads_base_from_cache() {
464        let cache_dir = TempDir::new().unwrap();
465        let installed_hash = hash::hash_bytes(b"installed content");
466
467        // Write base content to cache
468        let base_path = cache_dir.path().join(&installed_hash);
469        std::fs::write(&base_path, b"installed content").unwrap();
470
471        let diff = SyncDiff {
472            items: vec![DiffEntry::Conflict {
473                target: make_target("agent"),
474                locked: {
475                    let mut locked = make_locked("agent");
476                    locked.installed_checksum = installed_hash.into();
477                    locked
478                },
479                local_hash: "sha256:local".into(),
480            }],
481        };
482
483        let plan = create(&diff, &default_options(), cache_dir.path());
484        match &plan.actions[0] {
485            PlannedAction::Merge { base_content, .. } => {
486                assert_eq!(base_content, b"installed content");
487            }
488            other => panic!("expected Merge, got {other:?}"),
489        }
490    }
491
492    #[test]
493    fn merge_with_missing_cache_uses_empty_base() {
494        let cache_dir = TempDir::new().unwrap();
495        // Don't write any cache file
496
497        let diff = SyncDiff {
498            items: vec![DiffEntry::Conflict {
499                target: make_target("agent"),
500                locked: make_locked("agent"),
501                local_hash: "sha256:local".into(),
502            }],
503        };
504
505        let plan = create(&diff, &default_options(), cache_dir.path());
506        match &plan.actions[0] {
507            PlannedAction::Merge { base_content, .. } => {
508                assert!(
509                    base_content.is_empty(),
510                    "missing cache should fall back to empty base"
511                );
512            }
513            other => panic!("expected Merge, got {other:?}"),
514        }
515    }
516
517    #[test]
518    fn mixed_plan() {
519        let cache_dir = TempDir::new().unwrap();
520        let diff = SyncDiff {
521            items: vec![
522                DiffEntry::Add {
523                    target: make_target("new"),
524                },
525                DiffEntry::Update {
526                    target: make_target("updated"),
527                    locked: make_locked("updated"),
528                },
529                DiffEntry::Unchanged {
530                    target: make_target("stable"),
531                    locked: make_locked("stable"),
532                },
533                DiffEntry::Orphan {
534                    locked: make_locked("removed"),
535                },
536            ],
537        };
538
539        let plan = create(&diff, &default_options(), cache_dir.path());
540        assert_eq!(plan.actions.len(), 4);
541
542        assert!(matches!(&plan.actions[0], PlannedAction::Install { .. }));
543        assert!(matches!(&plan.actions[1], PlannedAction::Overwrite { .. }));
544        assert!(matches!(&plan.actions[2], PlannedAction::Skip { .. }));
545        assert!(matches!(&plan.actions[3], PlannedAction::Remove { .. }));
546    }
547}