Skip to main content

mars_agents/sync/
apply.rs

1use std::path::Path;
2
3use crate::error::MarsError;
4use crate::lock::{ItemId, ItemKind};
5use crate::reconcile::fs_ops;
6use crate::sync::plan::{PlannedAction, SyncPlan};
7use crate::sync::target::TargetItem;
8pub use crate::sync::types::SyncOptions;
9use crate::types::{ContentHash, DestPath, ItemName, SourceName};
10
11/// The result of applying the sync plan.
12#[derive(Debug, Clone)]
13pub struct ApplyResult {
14    pub outcomes: Vec<ActionOutcome>,
15}
16
17/// What action was taken for a single item.
18#[derive(Debug, Clone)]
19pub struct ActionOutcome {
20    pub item_id: ItemId,
21    pub action: ActionTaken,
22    pub dest_path: DestPath,
23    /// Which source this item came from.
24    pub source_name: SourceName,
25    /// Source checksum (pre-rewrite hash of source content).
26    pub source_checksum: Option<ContentHash>,
27    /// Installed checksum (post-rewrite hash of what was written to disk).
28    pub installed_checksum: Option<ContentHash>,
29}
30
31/// The specific action taken.
32#[derive(Debug, Clone)]
33pub enum ActionTaken {
34    Installed,
35    Updated,
36    Merged,
37    Conflicted,
38    Removed,
39    Skipped,
40    Kept,
41}
42
43/// Execute the sync plan, applying changes to disk.
44///
45/// For each action:
46/// - Install: copy source content to dest (atomic_write or atomic_install_dir)
47/// - Overwrite: replace existing with new source content
48/// - Merge: three-way merge using base from cache
49/// - Remove: delete file/dir from disk
50/// - Skip/KeepLocal: record as no-op
51///
52/// Returns outcomes with both source_checksum and installed_checksum.
53/// The installed_checksum may differ from source_checksum when frontmatter
54/// rewriting occurred.
55pub fn execute(
56    root: &Path,
57    plan: &SyncPlan,
58    options: &SyncOptions,
59    cache_bases_dir: &Path,
60) -> Result<ApplyResult, MarsError> {
61    let mut outcomes = Vec::new();
62
63    for action in &plan.actions {
64        let outcome = if options.dry_run {
65            // Dry run: compute the outcome without touching disk
66            dry_run_action(action)
67        } else {
68            execute_action(root, action, cache_bases_dir)?
69        };
70        outcomes.push(outcome);
71    }
72
73    Ok(ApplyResult { outcomes })
74}
75
76/// Execute a single action, writing to disk.
77fn execute_action(
78    root: &Path,
79    action: &PlannedAction,
80    cache_bases_dir: &Path,
81) -> Result<ActionOutcome, MarsError> {
82    match action {
83        PlannedAction::Install { target } => {
84            let dest = target.dest_path.resolve(root);
85
86            // Read source content and install
87            let installed_checksum = install_item(target, &dest)?;
88
89            // Writes base for future three-way merge support — currently unused by plan stage.
90            cache_base_content(cache_bases_dir, &installed_checksum, &dest, target.id.kind)?;
91
92            Ok(ActionOutcome {
93                item_id: target.id.clone(),
94                action: ActionTaken::Installed,
95                dest_path: target.dest_path.clone(),
96                source_name: target.source_name.clone(),
97                source_checksum: Some(target.source_hash.clone()),
98                installed_checksum: Some(installed_checksum),
99            })
100        }
101
102        PlannedAction::Overwrite { target } => {
103            let dest = target.dest_path.resolve(root);
104
105            // Install (overwrite) source content
106            let installed_checksum = install_item(target, &dest)?;
107
108            // Writes base for future three-way merge support — currently unused by plan stage.
109            cache_base_content(cache_bases_dir, &installed_checksum, &dest, target.id.kind)?;
110
111            Ok(ActionOutcome {
112                item_id: target.id.clone(),
113                action: ActionTaken::Updated,
114                dest_path: target.dest_path.clone(),
115                source_name: target.source_name.clone(),
116                source_checksum: Some(target.source_hash.clone()),
117                installed_checksum: Some(installed_checksum),
118            })
119        }
120
121        // Reserved — unreachable from current plan stage. See PlannedAction::Merge.
122        PlannedAction::Merge {
123            target,
124            base_content,
125            local_path,
126        } => {
127            let dest = target.dest_path.resolve(root);
128            let full_local_path = root.join(local_path);
129
130            // Read source (theirs) content
131            let theirs_content = read_target_content_for_merge(target)?;
132
133            // Read local content
134            let local_content = read_item_content(&full_local_path, target.id.kind)?;
135
136            // Perform three-way merge
137            let labels = crate::merge::MergeLabels {
138                base: "base (last sync)".into(),
139                local: "local".into(),
140                theirs: format!("{}@{}", target.source_name, "upstream"),
141            };
142
143            let merge_result = crate::merge::merge_content(
144                base_content,
145                &local_content,
146                &theirs_content,
147                &labels,
148            )?;
149
150            // Write merged content and verify persisted bytes before recording checksum.
151            let installed_checksum = write_file_and_verify(&dest, &merge_result.content)?;
152
153            // Cache the merged content as new base
154            cache_base_content(cache_bases_dir, &installed_checksum, &dest, target.id.kind)?;
155
156            let action_taken = if merge_result.has_conflicts {
157                ActionTaken::Conflicted
158            } else {
159                ActionTaken::Merged
160            };
161
162            Ok(ActionOutcome {
163                item_id: target.id.clone(),
164                action: action_taken,
165                dest_path: target.dest_path.clone(),
166                source_name: target.source_name.clone(),
167                source_checksum: Some(target.source_hash.clone()),
168                installed_checksum: Some(installed_checksum),
169            })
170        }
171
172        PlannedAction::Remove { locked } => {
173            let dest = removal_path(root, &locked.dest_path, locked.kind);
174            if dest.exists() {
175                fs_ops::safe_remove(&dest)?;
176            }
177
178            let item_id = ItemId {
179                kind: locked.kind,
180                name: ItemName::from(locked.dest_path.item_name(locked.kind)),
181            };
182
183            Ok(ActionOutcome {
184                item_id,
185                action: ActionTaken::Removed,
186                dest_path: locked.dest_path.clone(),
187                source_name: locked.source.clone(),
188                source_checksum: None,
189                installed_checksum: None,
190            })
191        }
192
193        PlannedAction::Skip {
194            item_id,
195            dest_path,
196            source_name,
197            installed_checksum,
198            reason: _,
199        } => Ok(ActionOutcome {
200            item_id: item_id.clone(),
201            action: ActionTaken::Skipped,
202            dest_path: dest_path.clone(),
203            source_name: source_name.clone(),
204            source_checksum: None,
205            installed_checksum: installed_checksum.clone(),
206        }),
207
208        PlannedAction::KeepLocal {
209            item_id,
210            dest_path,
211            source_name,
212        } => Ok(ActionOutcome {
213            item_id: item_id.clone(),
214            action: ActionTaken::Kept,
215            dest_path: dest_path.clone(),
216            source_name: source_name.clone(),
217            source_checksum: None,
218            installed_checksum: None,
219        }),
220    }
221}
222
223/// Produce a dry-run outcome without touching disk.
224fn dry_run_action(action: &PlannedAction) -> ActionOutcome {
225    match action {
226        PlannedAction::Install { target } => ActionOutcome {
227            item_id: target.id.clone(),
228            action: ActionTaken::Installed,
229            dest_path: target.dest_path.clone(),
230            source_name: target.source_name.clone(),
231            source_checksum: Some(target.source_hash.clone()),
232            installed_checksum: None, // Can't know without actually installing
233        },
234        PlannedAction::Overwrite { target } => ActionOutcome {
235            item_id: target.id.clone(),
236            action: ActionTaken::Updated,
237            dest_path: target.dest_path.clone(),
238            source_name: target.source_name.clone(),
239            source_checksum: Some(target.source_hash.clone()),
240            installed_checksum: None,
241        },
242        PlannedAction::Merge { target, .. } => ActionOutcome {
243            item_id: target.id.clone(),
244            action: ActionTaken::Merged,
245            dest_path: target.dest_path.clone(),
246            source_name: target.source_name.clone(),
247            source_checksum: Some(target.source_hash.clone()),
248            installed_checksum: None,
249        },
250        PlannedAction::Remove { locked } => {
251            let item_id = ItemId {
252                kind: locked.kind,
253                name: ItemName::from(locked.dest_path.item_name(locked.kind)),
254            };
255            ActionOutcome {
256                item_id,
257                action: ActionTaken::Removed,
258                dest_path: locked.dest_path.clone(),
259                source_name: locked.source.clone(),
260                source_checksum: None,
261                installed_checksum: None,
262            }
263        }
264        PlannedAction::Skip {
265            item_id,
266            dest_path,
267            source_name,
268            installed_checksum,
269            ..
270        } => ActionOutcome {
271            item_id: item_id.clone(),
272            action: ActionTaken::Skipped,
273            dest_path: dest_path.clone(),
274            source_name: source_name.clone(),
275            source_checksum: None,
276            installed_checksum: installed_checksum.clone(),
277        },
278        PlannedAction::KeepLocal {
279            item_id,
280            dest_path,
281            source_name,
282        } => ActionOutcome {
283            item_id: item_id.clone(),
284            action: ActionTaken::Kept,
285            dest_path: dest_path.clone(),
286            source_name: source_name.clone(),
287            source_checksum: None,
288            installed_checksum: None,
289        },
290    }
291}
292
293/// Install an item (file or directory) to the destination.
294///
295/// Returns the installed checksum (hash of what was written to disk).
296fn install_item(target: &TargetItem, dest: &Path) -> Result<ContentHash, MarsError> {
297    match target.id.kind {
298        ItemKind::Agent | ItemKind::Hook | ItemKind::McpServer => {
299            let content = content_to_install(target)?;
300            write_file_and_verify(dest, &content)
301        }
302        ItemKind::BootstrapDoc => {
303            let doc_dest = dest.parent().ok_or_else(|| {
304                std::io::Error::other(format!(
305                    "bootstrap destination has no parent directory: {}",
306                    dest.display()
307                ))
308            })?;
309            fs_ops::atomic_install_dir(&target.source_path, doc_dest)?;
310            crate::hash::compute_hash(doc_dest, ItemKind::BootstrapDoc).map(ContentHash::from)
311        }
312        ItemKind::Skill => {
313            if target.is_flat_skill {
314                crate::fs::atomic_install_dir_filtered(
315                    &target.source_path,
316                    dest,
317                    crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL,
318                )?;
319            } else {
320                fs_ops::atomic_install_dir(&target.source_path, dest)?;
321            }
322            // Skills are verified by hashing the installed directory content.
323            crate::hash::compute_hash(dest, ItemKind::Skill).map(ContentHash::from)
324        }
325    }
326}
327
328/// Write bytes to `dest` and verify persisted bytes hash matches expected.
329fn write_file_and_verify(dest: &Path, content: &[u8]) -> Result<ContentHash, MarsError> {
330    fs_ops::atomic_write_file(dest, content)?;
331    let expected = ContentHash::from(crate::hash::hash_bytes(content));
332    let persisted = std::fs::read(dest)?;
333    let actual = ContentHash::from(crate::hash::hash_bytes(&persisted));
334    if expected != actual {
335        return Err(std::io::Error::other(format!(
336            "post-write verification failed for {}: expected {expected}, got {actual}",
337            dest.display()
338        ))
339        .into());
340    }
341    Ok(actual)
342}
343
344/// Read bytes to install for an agent, honoring in-memory rewrite overrides.
345fn content_to_install(target: &TargetItem) -> Result<Vec<u8>, MarsError> {
346    if let Some(content) = &target.rewritten_content {
347        Ok(content.as_bytes().to_vec())
348    } else if target.id.kind == ItemKind::BootstrapDoc {
349        Ok(std::fs::read(target.source_path.join("BOOTSTRAP.md"))?)
350    } else {
351        Ok(std::fs::read(&target.source_path)?)
352    }
353}
354
355/// Read source content for merge operations.
356fn read_target_content_for_merge(target: &TargetItem) -> Result<Vec<u8>, MarsError> {
357    match target.id.kind {
358        ItemKind::Agent | ItemKind::Hook | ItemKind::McpServer | ItemKind::BootstrapDoc => {
359            content_to_install(target)
360        }
361        ItemKind::Skill => read_item_content(&target.source_path, target.id.kind),
362    }
363}
364
365/// Read content from an item (file for agents, concatenated for skills).
366/// For merge purposes, we only support file-level merge (agents).
367/// Skills that need merging would require per-file merge, which is complex.
368/// For now, read the primary file content.
369fn read_item_content(path: &Path, kind: ItemKind) -> Result<Vec<u8>, MarsError> {
370    match kind {
371        ItemKind::Agent | ItemKind::Hook | ItemKind::McpServer => Ok(std::fs::read(path)?),
372        ItemKind::BootstrapDoc => Ok(std::fs::read(path.join("BOOTSTRAP.md"))?),
373        ItemKind::Skill => {
374            // For skills (directories), read the SKILL.md as the merge target
375            let skill_md = path.join("SKILL.md");
376            if skill_md.exists() {
377                Ok(std::fs::read(&skill_md)?)
378            } else {
379                Ok(Vec::new())
380            }
381        }
382    }
383}
384
385/// Cache base content for future three-way merges.
386///
387/// Content-addressed by installed checksum. Written after every install/overwrite.
388/// Missing cache = degrade to two-way diff (more conflict markers), not crash.
389fn cache_base_content(
390    cache_bases_dir: &Path,
391    installed_checksum: &ContentHash,
392    dest: &Path,
393    kind: ItemKind,
394) -> Result<(), MarsError> {
395    std::fs::create_dir_all(cache_bases_dir)?;
396    // Replace colon with underscore for Windows filename compatibility.
397    let safe_filename = installed_checksum.as_ref().replace(':', "_");
398    let cache_path = cache_bases_dir.join(&safe_filename);
399
400    // Only cache if not already present (content-addressed = immutable)
401    if cache_path.exists() {
402        return Ok(());
403    }
404
405    match kind {
406        ItemKind::Agent | ItemKind::Hook | ItemKind::McpServer => {
407            let content = std::fs::read(dest)?;
408            fs_ops::atomic_write_file(&cache_path, &content)?;
409        }
410        ItemKind::BootstrapDoc => {
411            let content = std::fs::read(dest)?;
412            fs_ops::atomic_write_file(&cache_path, &content)?;
413        }
414        ItemKind::Skill => {
415            // For skills, cache the SKILL.md content (the merge-relevant part)
416            let skill_md = dest.join("SKILL.md");
417            if skill_md.exists() {
418                let content = std::fs::read(&skill_md)?;
419                fs_ops::atomic_write_file(&cache_path, &content)?;
420            }
421        }
422    }
423
424    Ok(())
425}
426
427/// Prune orphans: items in old lock but not in new target.
428///
429/// This is handled by the Remove action in the plan, but exposed
430/// separately for the sync pipeline if needed.
431pub fn prune_orphans(
432    root: &Path,
433    lock: &crate::lock::LockFile,
434    target: &crate::sync::target::TargetState,
435) -> Result<Vec<ActionOutcome>, MarsError> {
436    let mut outcomes = Vec::new();
437
438    for (dest_path_str, locked_item) in lock.canonical_flat_items() {
439        if !target.items.contains_key(&dest_path_str) {
440            let dest = removal_path(root, &dest_path_str, locked_item.kind);
441            if dest.exists() {
442                fs_ops::safe_remove(&dest)?;
443            }
444            outcomes.push(ActionOutcome {
445                item_id: ItemId {
446                    kind: locked_item.kind,
447                    name: ItemName::from(dest_path_str.item_name(locked_item.kind)),
448                },
449                action: ActionTaken::Removed,
450                dest_path: dest_path_str,
451                source_name: locked_item.source,
452                source_checksum: None,
453                installed_checksum: None,
454            });
455        }
456    }
457
458    Ok(outcomes)
459}
460
461fn removal_path(root: &Path, dest_path: &DestPath, kind: ItemKind) -> std::path::PathBuf {
462    let dest = dest_path.resolve(root);
463    if kind == ItemKind::BootstrapDoc {
464        if dest_path.as_str().split('/').count() >= 3 {
465            dest.parent()
466                .map(Path::to_path_buf)
467                .unwrap_or_else(|| dest.clone())
468        } else {
469            dest
470        }
471    } else {
472        dest
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use crate::hash;
480    use crate::lock::{ItemId, ItemKind, LockedItem};
481    use crate::sync::plan::{PlannedAction, SyncPlan};
482    use crate::sync::target::TargetItem;
483    use std::fs;
484    use std::path::PathBuf;
485    use tempfile::TempDir;
486
487    fn make_agent_target(name: &str, source_path: PathBuf, content: &[u8]) -> TargetItem {
488        TargetItem {
489            id: ItemId {
490                kind: ItemKind::Agent,
491                name: name.into(),
492            },
493            source_name: "test-source".into(),
494            origin: crate::types::SourceOrigin::Dependency("test-source".into()),
495            source_id: crate::types::SourceId::Path {
496                canonical: source_path.clone(),
497                subpath: None,
498            },
499            source_path,
500            dest_path: format!("agents/{name}.md").into(),
501            source_hash: hash::hash_bytes(content).into(),
502            is_flat_skill: false,
503            rewritten_content: None,
504        }
505    }
506
507    fn make_bootstrap_target(name: &str, source_path: PathBuf) -> TargetItem {
508        TargetItem {
509            id: ItemId {
510                kind: ItemKind::BootstrapDoc,
511                name: name.into(),
512            },
513            source_name: "test-source".into(),
514            origin: crate::types::SourceOrigin::Dependency("test-source".into()),
515            source_id: crate::types::SourceId::Path {
516                canonical: source_path.clone(),
517                subpath: None,
518            },
519            source_hash: crate::hash::compute_hash(&source_path, ItemKind::BootstrapDoc)
520                .unwrap()
521                .into(),
522            source_path,
523            dest_path: format!("bootstrap/{name}/BOOTSTRAP.md").into(),
524            is_flat_skill: false,
525            rewritten_content: None,
526        }
527    }
528
529    fn setup_source_agent(dir: &Path, name: &str, content: &[u8]) -> PathBuf {
530        let agents_dir = dir.join("source").join("agents");
531        fs::create_dir_all(&agents_dir).unwrap();
532        let path = agents_dir.join(format!("{name}.md"));
533        fs::write(&path, content).unwrap();
534        path
535    }
536
537    // === Install tests ===
538
539    #[test]
540    fn install_creates_new_file() {
541        let root = TempDir::new().unwrap();
542        let source_dir = TempDir::new().unwrap();
543        let cache_dir = TempDir::new().unwrap();
544        let bases_dir = cache_dir.path().join("bases");
545
546        let content = b"# new agent content";
547        let source_path = setup_source_agent(source_dir.path(), "coder", content);
548        let target = make_agent_target("coder", source_path, content);
549
550        let plan = SyncPlan {
551            actions: vec![PlannedAction::Install {
552                target: target.clone(),
553            }],
554        };
555
556        let options = SyncOptions::default();
557
558        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
559        assert_eq!(result.outcomes.len(), 1);
560
561        let outcome = &result.outcomes[0];
562        assert!(matches!(outcome.action, ActionTaken::Installed));
563
564        // Verify file was created
565        let installed_path = root.path().join("agents/coder.md");
566        assert!(installed_path.exists());
567        assert_eq!(fs::read(&installed_path).unwrap(), content);
568
569        // Verify checksums
570        assert_eq!(
571            outcome.source_checksum.as_deref(),
572            Some(hash::hash_bytes(content).as_str())
573        );
574        assert!(outcome.installed_checksum.is_some());
575    }
576
577    #[test]
578    fn install_caches_base_content() {
579        let root = TempDir::new().unwrap();
580        let source_dir = TempDir::new().unwrap();
581        let cache_dir = TempDir::new().unwrap();
582        let bases_dir = cache_dir.path().join("bases");
583
584        let content = b"# cached content";
585        let source_path = setup_source_agent(source_dir.path(), "coder", content);
586        let target = make_agent_target("coder", source_path, content);
587
588        let plan = SyncPlan {
589            actions: vec![PlannedAction::Install { target }],
590        };
591
592        let options = SyncOptions::default();
593
594        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
595        let installed_checksum = result.outcomes[0].installed_checksum.as_ref().unwrap();
596
597        // Verify base content was cached
598        let cached = bases_dir.join(installed_checksum.as_ref().replace(':', "_"));
599        assert!(cached.exists(), "base content should be cached");
600        assert_eq!(fs::read(&cached).unwrap(), content);
601    }
602
603    // === Overwrite tests ===
604
605    #[test]
606    fn overwrite_replaces_existing_file() {
607        let root = TempDir::new().unwrap();
608        let source_dir = TempDir::new().unwrap();
609        let cache_dir = TempDir::new().unwrap();
610        let bases_dir = cache_dir.path().join("bases");
611
612        // Create existing file
613        let agents_dir = root.path().join("agents");
614        fs::create_dir_all(&agents_dir).unwrap();
615        fs::write(agents_dir.join("coder.md"), b"# old content").unwrap();
616
617        let new_content = b"# new content";
618        let source_path = setup_source_agent(source_dir.path(), "coder", new_content);
619        let target = make_agent_target("coder", source_path, new_content);
620
621        let plan = SyncPlan {
622            actions: vec![PlannedAction::Overwrite { target }],
623        };
624
625        let options = SyncOptions::default();
626
627        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
628        assert!(matches!(result.outcomes[0].action, ActionTaken::Updated));
629
630        let installed = fs::read(root.path().join("agents/coder.md")).unwrap();
631        assert_eq!(installed, new_content);
632    }
633
634    #[test]
635    fn install_bootstrap_doc_directory_to_canonical_file_path() {
636        let root = TempDir::new().unwrap();
637        let source_dir = TempDir::new().unwrap();
638        let cache_dir = TempDir::new().unwrap();
639        let bases_dir = cache_dir.path().join("bases");
640        let bootstrap_dir = source_dir.path().join("bootstrap/global-auth");
641        fs::create_dir_all(&bootstrap_dir).unwrap();
642        fs::write(bootstrap_dir.join("BOOTSTRAP.md"), b"# auth").unwrap();
643
644        let target = make_bootstrap_target("global-auth", bootstrap_dir);
645        let plan = SyncPlan {
646            actions: vec![PlannedAction::Install { target }],
647        };
648        let options = SyncOptions::default();
649
650        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
651
652        assert!(matches!(result.outcomes[0].action, ActionTaken::Installed));
653        assert_eq!(
654            fs::read(root.path().join("bootstrap/global-auth/BOOTSTRAP.md")).unwrap(),
655            b"# auth"
656        );
657    }
658
659    // === Remove tests ===
660
661    #[test]
662    fn remove_deletes_file() {
663        let root = TempDir::new().unwrap();
664        let cache_dir = TempDir::new().unwrap();
665        let bases_dir = cache_dir.path().join("bases");
666
667        // Create file to remove
668        let agents_dir = root.path().join("agents");
669        fs::create_dir_all(&agents_dir).unwrap();
670        fs::write(agents_dir.join("orphan.md"), b"# orphan").unwrap();
671
672        let locked = LockedItem {
673            source: "old-source".into(),
674            kind: ItemKind::Agent,
675            version: None,
676            source_checksum: "sha256:aaa".into(),
677            installed_checksum: "sha256:bbb".into(),
678            dest_path: "agents/orphan.md".into(),
679        };
680
681        let plan = SyncPlan {
682            actions: vec![PlannedAction::Remove { locked }],
683        };
684
685        let options = SyncOptions::default();
686
687        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
688        assert!(matches!(result.outcomes[0].action, ActionTaken::Removed));
689        assert!(!root.path().join("agents/orphan.md").exists());
690    }
691
692    #[test]
693    fn remove_skill_directory() {
694        let root = TempDir::new().unwrap();
695        let cache_dir = TempDir::new().unwrap();
696        let bases_dir = cache_dir.path().join("bases");
697
698        // Create skill directory
699        let skill_dir = root.path().join("skills/old-skill");
700        fs::create_dir_all(&skill_dir).unwrap();
701        fs::write(skill_dir.join("SKILL.md"), b"# old skill").unwrap();
702
703        let locked = LockedItem {
704            source: "old-source".into(),
705            kind: ItemKind::Skill,
706            version: None,
707            source_checksum: "sha256:aaa".into(),
708            installed_checksum: "sha256:bbb".into(),
709            dest_path: "skills/old-skill".into(),
710        };
711
712        let plan = SyncPlan {
713            actions: vec![PlannedAction::Remove { locked }],
714        };
715
716        let options = SyncOptions::default();
717
718        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
719        assert!(matches!(result.outcomes[0].action, ActionTaken::Removed));
720        assert!(!root.path().join("skills/old-skill").exists());
721    }
722
723    #[test]
724    fn remove_bootstrap_doc_removes_container_directory() {
725        let root = TempDir::new().unwrap();
726        let cache_dir = TempDir::new().unwrap();
727        let bases_dir = cache_dir.path().join("bases");
728        let bootstrap_dir = root.path().join("bootstrap/global-auth");
729        fs::create_dir_all(&bootstrap_dir).unwrap();
730        fs::write(bootstrap_dir.join("BOOTSTRAP.md"), b"# auth").unwrap();
731
732        let locked = LockedItem {
733            source: "old-source".into(),
734            kind: ItemKind::BootstrapDoc,
735            version: None,
736            source_checksum: "sha256:aaa".into(),
737            installed_checksum: "sha256:bbb".into(),
738            dest_path: "bootstrap/global-auth/BOOTSTRAP.md".into(),
739        };
740
741        let plan = SyncPlan {
742            actions: vec![PlannedAction::Remove { locked }],
743        };
744        let options = SyncOptions::default();
745
746        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
747        assert!(matches!(result.outcomes[0].action, ActionTaken::Removed));
748        assert!(!bootstrap_dir.exists());
749    }
750
751    #[test]
752    fn remove_degenerate_bootstrap_doc_path_removes_exact_file_only() {
753        let root = TempDir::new().unwrap();
754        let cache_dir = TempDir::new().unwrap();
755        let bases_dir = cache_dir.path().join("bases");
756        let bootstrap_dir = root.path().join("bootstrap");
757        fs::create_dir_all(&bootstrap_dir).unwrap();
758        fs::write(bootstrap_dir.join("BOOTSTRAP.md"), b"# root").unwrap();
759        fs::write(bootstrap_dir.join("keep.md"), b"# keep").unwrap();
760
761        let locked = LockedItem {
762            source: "old-source".into(),
763            kind: ItemKind::BootstrapDoc,
764            version: None,
765            source_checksum: "sha256:aaa".into(),
766            installed_checksum: "sha256:bbb".into(),
767            dest_path: "bootstrap/BOOTSTRAP.md".into(),
768        };
769
770        let plan = SyncPlan {
771            actions: vec![PlannedAction::Remove { locked }],
772        };
773        let options = SyncOptions::default();
774
775        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
776        assert!(matches!(result.outcomes[0].action, ActionTaken::Removed));
777        assert!(!bootstrap_dir.join("BOOTSTRAP.md").exists());
778        assert!(bootstrap_dir.join("keep.md").exists());
779    }
780
781    // === Dry run tests ===
782
783    #[test]
784    fn dry_run_does_not_modify_files() {
785        let root = TempDir::new().unwrap();
786        let source_dir = TempDir::new().unwrap();
787        let cache_dir = TempDir::new().unwrap();
788        let bases_dir = cache_dir.path().join("bases");
789
790        let content = b"# new agent";
791        let source_path = setup_source_agent(source_dir.path(), "coder", content);
792        let target = make_agent_target("coder", source_path, content);
793
794        let plan = SyncPlan {
795            actions: vec![PlannedAction::Install { target }],
796        };
797
798        let options = SyncOptions {
799            dry_run: true,
800            ..SyncOptions::default()
801        };
802
803        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
804        assert_eq!(result.outcomes.len(), 1);
805        assert!(matches!(result.outcomes[0].action, ActionTaken::Installed));
806
807        // File should NOT exist
808        assert!(!root.path().join("agents/coder.md").exists());
809    }
810
811    // === Skip/KeepLocal tests ===
812
813    #[test]
814    fn skip_produces_skipped_outcome() {
815        let root = TempDir::new().unwrap();
816        let cache_dir = TempDir::new().unwrap();
817        let bases_dir = cache_dir.path().join("bases");
818
819        let plan = SyncPlan {
820            actions: vec![PlannedAction::Skip {
821                item_id: ItemId {
822                    kind: ItemKind::Agent,
823                    name: "stable".into(),
824                },
825                dest_path: "agents/stable.md".into(),
826                source_name: "base".into(),
827                installed_checksum: Some("sha256:stable".into()),
828                reason: "unchanged",
829            }],
830        };
831
832        let options = SyncOptions::default();
833
834        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
835        assert!(matches!(result.outcomes[0].action, ActionTaken::Skipped));
836        assert_eq!(
837            result.outcomes[0].dest_path,
838            crate::types::DestPath::from("agents/stable.md")
839        );
840        assert_eq!(result.outcomes[0].source_name, "base");
841        assert_eq!(
842            result.outcomes[0].installed_checksum.as_deref(),
843            Some("sha256:stable")
844        );
845    }
846
847    #[test]
848    fn keep_local_produces_kept_outcome() {
849        let root = TempDir::new().unwrap();
850        let cache_dir = TempDir::new().unwrap();
851        let bases_dir = cache_dir.path().join("bases");
852
853        let plan = SyncPlan {
854            actions: vec![PlannedAction::KeepLocal {
855                item_id: ItemId {
856                    kind: ItemKind::Agent,
857                    name: "modified".into(),
858                },
859                dest_path: "agents/modified.md".into(),
860                source_name: "base".into(),
861            }],
862        };
863
864        let options = SyncOptions::default();
865
866        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
867        assert!(matches!(result.outcomes[0].action, ActionTaken::Kept));
868        assert_eq!(
869            result.outcomes[0].dest_path,
870            crate::types::DestPath::from("agents/modified.md")
871        );
872        assert_eq!(result.outcomes[0].source_name, "base");
873    }
874
875    // === Install skill directory tests ===
876
877    #[test]
878    fn install_skill_directory() {
879        let root = TempDir::new().unwrap();
880        let source_dir = TempDir::new().unwrap();
881        let cache_dir = TempDir::new().unwrap();
882        let bases_dir = cache_dir.path().join("bases");
883
884        // Create source skill directory
885        let source_skill = source_dir.path().join("skills/planning");
886        fs::create_dir_all(&source_skill).unwrap();
887        fs::write(source_skill.join("SKILL.md"), b"# Planning skill").unwrap();
888        fs::write(source_skill.join("helper.md"), b"# Helper").unwrap();
889
890        let skill_hash = hash::compute_hash(&source_skill, ItemKind::Skill).unwrap();
891
892        let target = TargetItem {
893            id: ItemId {
894                kind: ItemKind::Skill,
895                name: "planning".into(),
896            },
897            source_name: "test".into(),
898            origin: crate::types::SourceOrigin::Dependency("test".into()),
899            source_id: crate::types::SourceId::Path {
900                canonical: source_skill.clone(),
901                subpath: None,
902            },
903            source_path: source_skill,
904            dest_path: "skills/planning".into(),
905            source_hash: skill_hash.into(),
906            is_flat_skill: false,
907            rewritten_content: None,
908        };
909
910        let plan = SyncPlan {
911            actions: vec![PlannedAction::Install { target }],
912        };
913
914        let options = SyncOptions::default();
915
916        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
917        assert!(matches!(result.outcomes[0].action, ActionTaken::Installed));
918
919        let installed_dir = root.path().join("skills/planning");
920        assert!(installed_dir.exists());
921        assert!(installed_dir.join("SKILL.md").exists());
922        assert!(installed_dir.join("helper.md").exists());
923        assert_eq!(
924            fs::read_to_string(installed_dir.join("SKILL.md")).unwrap(),
925            "# Planning skill"
926        );
927    }
928
929    #[test]
930    fn install_flat_skill_excludes_repo_metadata() {
931        let root = TempDir::new().unwrap();
932        let source_dir = TempDir::new().unwrap();
933        let cache_dir = TempDir::new().unwrap();
934        let bases_dir = cache_dir.path().join("bases");
935
936        let flat_source = source_dir.path().join("flat-skill");
937        fs::create_dir_all(flat_source.join(".git")).unwrap();
938        fs::create_dir_all(flat_source.join("resources")).unwrap();
939        fs::write(flat_source.join("SKILL.md"), b"# Flat skill").unwrap();
940        fs::write(flat_source.join("resources/guide.md"), b"# Guide").unwrap();
941        fs::write(flat_source.join("mars.toml"), b"[sources]").unwrap();
942        fs::write(flat_source.join(".gitignore"), b"target/").unwrap();
943        fs::write(flat_source.join(".git/config"), b"[core]").unwrap();
944
945        let source_hash = hash::compute_skill_hash_filtered(
946            &flat_source,
947            crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL,
948        )
949        .unwrap();
950
951        let target = TargetItem {
952            id: ItemId {
953                kind: ItemKind::Skill,
954                name: "flat-skill".into(),
955            },
956            source_name: "test".into(),
957            origin: crate::types::SourceOrigin::Dependency("test".into()),
958            source_id: crate::types::SourceId::Path {
959                canonical: flat_source.clone(),
960                subpath: None,
961            },
962            source_path: flat_source,
963            dest_path: "skills/flat-skill".into(),
964            source_hash: source_hash.into(),
965            is_flat_skill: true,
966            rewritten_content: None,
967        };
968
969        let plan = SyncPlan {
970            actions: vec![PlannedAction::Install { target }],
971        };
972
973        let options = SyncOptions::default();
974
975        execute(root.path(), &plan, &options, &bases_dir).unwrap();
976
977        let installed = root.path().join("skills/flat-skill");
978        assert!(installed.join("SKILL.md").exists());
979        assert!(installed.join("resources/guide.md").exists());
980        assert!(!installed.join(".git").exists());
981        assert!(!installed.join("mars.toml").exists());
982        assert!(!installed.join(".gitignore").exists());
983    }
984
985    // === Prune orphans tests ===
986
987    #[test]
988    fn prune_removes_orphaned_items() {
989        let root = TempDir::new().unwrap();
990
991        // Create orphaned file
992        let agents_dir = root.path().join("agents");
993        fs::create_dir_all(&agents_dir).unwrap();
994        fs::write(agents_dir.join("old.md"), b"# orphan").unwrap();
995
996        let mut lock_items = indexmap::IndexMap::new();
997        lock_items.insert(
998            "agent/old".to_string(),
999            crate::lock::LockedItemV2 {
1000                source: "old-source".into(),
1001                kind: ItemKind::Agent,
1002                version: None,
1003                source_checksum: "sha256:aaa".into(),
1004                outputs: vec![crate::lock::OutputRecord {
1005                    target_root: ".mars".to_string(),
1006                    dest_path: "agents/old.md".into(),
1007                    installed_checksum: "sha256:bbb".into(),
1008                }],
1009            },
1010        );
1011        let lock = crate::lock::LockFile {
1012            version: 2,
1013            dependencies: indexmap::IndexMap::new(),
1014            items: lock_items,
1015            config_entries: std::collections::BTreeMap::new(),
1016            dependency_model_aliases: indexmap::IndexMap::new(),
1017        };
1018
1019        // Empty target = orphan should be pruned
1020        let target = crate::sync::target::TargetState {
1021            items: indexmap::IndexMap::new(),
1022        };
1023
1024        let outcomes = prune_orphans(root.path(), &lock, &target).unwrap();
1025        assert_eq!(outcomes.len(), 1);
1026        assert!(matches!(outcomes[0].action, ActionTaken::Removed));
1027        assert!(!root.path().join("agents/old.md").exists());
1028    }
1029
1030    // === DestPath::item_name tests ===
1031
1032    #[test]
1033    fn extract_agent_name() {
1034        assert_eq!(
1035            crate::types::DestPath::from("agents/coder.md").item_name(ItemKind::Agent),
1036            "coder"
1037        );
1038    }
1039
1040    #[test]
1041    fn extract_skill_name() {
1042        assert_eq!(
1043            crate::types::DestPath::from("skills/planning").item_name(ItemKind::Skill),
1044            "planning"
1045        );
1046    }
1047}