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.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 {
557            force: false,
558            dry_run: false,
559            frozen: false,
560            no_refresh_models: false,
561        };
562
563        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
564        assert_eq!(result.outcomes.len(), 1);
565
566        let outcome = &result.outcomes[0];
567        assert!(matches!(outcome.action, ActionTaken::Installed));
568
569        // Verify file was created
570        let installed_path = root.path().join("agents/coder.md");
571        assert!(installed_path.exists());
572        assert_eq!(fs::read(&installed_path).unwrap(), content);
573
574        // Verify checksums
575        assert_eq!(
576            outcome.source_checksum.as_deref(),
577            Some(hash::hash_bytes(content).as_str())
578        );
579        assert!(outcome.installed_checksum.is_some());
580    }
581
582    #[test]
583    fn install_caches_base_content() {
584        let root = TempDir::new().unwrap();
585        let source_dir = TempDir::new().unwrap();
586        let cache_dir = TempDir::new().unwrap();
587        let bases_dir = cache_dir.path().join("bases");
588
589        let content = b"# cached content";
590        let source_path = setup_source_agent(source_dir.path(), "coder", content);
591        let target = make_agent_target("coder", source_path, content);
592
593        let plan = SyncPlan {
594            actions: vec![PlannedAction::Install { target }],
595        };
596
597        let options = SyncOptions {
598            force: false,
599            dry_run: false,
600            frozen: false,
601            no_refresh_models: false,
602        };
603
604        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
605        let installed_checksum = result.outcomes[0].installed_checksum.as_ref().unwrap();
606
607        // Verify base content was cached
608        let cached = bases_dir.join(installed_checksum.as_ref().replace(':', "_"));
609        assert!(cached.exists(), "base content should be cached");
610        assert_eq!(fs::read(&cached).unwrap(), content);
611    }
612
613    // === Overwrite tests ===
614
615    #[test]
616    fn overwrite_replaces_existing_file() {
617        let root = TempDir::new().unwrap();
618        let source_dir = TempDir::new().unwrap();
619        let cache_dir = TempDir::new().unwrap();
620        let bases_dir = cache_dir.path().join("bases");
621
622        // Create existing file
623        let agents_dir = root.path().join("agents");
624        fs::create_dir_all(&agents_dir).unwrap();
625        fs::write(agents_dir.join("coder.md"), b"# old content").unwrap();
626
627        let new_content = b"# new content";
628        let source_path = setup_source_agent(source_dir.path(), "coder", new_content);
629        let target = make_agent_target("coder", source_path, new_content);
630
631        let plan = SyncPlan {
632            actions: vec![PlannedAction::Overwrite { target }],
633        };
634
635        let options = SyncOptions {
636            force: false,
637            dry_run: false,
638            frozen: false,
639            no_refresh_models: false,
640        };
641
642        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
643        assert!(matches!(result.outcomes[0].action, ActionTaken::Updated));
644
645        let installed = fs::read(root.path().join("agents/coder.md")).unwrap();
646        assert_eq!(installed, new_content);
647    }
648
649    #[test]
650    fn install_bootstrap_doc_directory_to_canonical_file_path() {
651        let root = TempDir::new().unwrap();
652        let source_dir = TempDir::new().unwrap();
653        let cache_dir = TempDir::new().unwrap();
654        let bases_dir = cache_dir.path().join("bases");
655        let bootstrap_dir = source_dir.path().join("bootstrap/global-auth");
656        fs::create_dir_all(&bootstrap_dir).unwrap();
657        fs::write(bootstrap_dir.join("BOOTSTRAP.md"), b"# auth").unwrap();
658
659        let target = make_bootstrap_target("global-auth", bootstrap_dir);
660        let plan = SyncPlan {
661            actions: vec![PlannedAction::Install { target }],
662        };
663        let options = SyncOptions {
664            force: false,
665            dry_run: false,
666            frozen: false,
667            no_refresh_models: false,
668        };
669
670        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
671
672        assert!(matches!(result.outcomes[0].action, ActionTaken::Installed));
673        assert_eq!(
674            fs::read(root.path().join("bootstrap/global-auth/BOOTSTRAP.md")).unwrap(),
675            b"# auth"
676        );
677    }
678
679    // === Remove tests ===
680
681    #[test]
682    fn remove_deletes_file() {
683        let root = TempDir::new().unwrap();
684        let cache_dir = TempDir::new().unwrap();
685        let bases_dir = cache_dir.path().join("bases");
686
687        // Create file to remove
688        let agents_dir = root.path().join("agents");
689        fs::create_dir_all(&agents_dir).unwrap();
690        fs::write(agents_dir.join("orphan.md"), b"# orphan").unwrap();
691
692        let locked = LockedItem {
693            source: "old-source".into(),
694            kind: ItemKind::Agent,
695            version: None,
696            source_checksum: "sha256:aaa".into(),
697            installed_checksum: "sha256:bbb".into(),
698            dest_path: "agents/orphan.md".into(),
699        };
700
701        let plan = SyncPlan {
702            actions: vec![PlannedAction::Remove { locked }],
703        };
704
705        let options = SyncOptions {
706            force: false,
707            dry_run: false,
708            frozen: false,
709            no_refresh_models: false,
710        };
711
712        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
713        assert!(matches!(result.outcomes[0].action, ActionTaken::Removed));
714        assert!(!root.path().join("agents/orphan.md").exists());
715    }
716
717    #[test]
718    fn remove_skill_directory() {
719        let root = TempDir::new().unwrap();
720        let cache_dir = TempDir::new().unwrap();
721        let bases_dir = cache_dir.path().join("bases");
722
723        // Create skill directory
724        let skill_dir = root.path().join("skills/old-skill");
725        fs::create_dir_all(&skill_dir).unwrap();
726        fs::write(skill_dir.join("SKILL.md"), b"# old skill").unwrap();
727
728        let locked = LockedItem {
729            source: "old-source".into(),
730            kind: ItemKind::Skill,
731            version: None,
732            source_checksum: "sha256:aaa".into(),
733            installed_checksum: "sha256:bbb".into(),
734            dest_path: "skills/old-skill".into(),
735        };
736
737        let plan = SyncPlan {
738            actions: vec![PlannedAction::Remove { locked }],
739        };
740
741        let options = SyncOptions {
742            force: false,
743            dry_run: false,
744            frozen: false,
745            no_refresh_models: false,
746        };
747
748        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
749        assert!(matches!(result.outcomes[0].action, ActionTaken::Removed));
750        assert!(!root.path().join("skills/old-skill").exists());
751    }
752
753    #[test]
754    fn remove_bootstrap_doc_removes_container_directory() {
755        let root = TempDir::new().unwrap();
756        let cache_dir = TempDir::new().unwrap();
757        let bases_dir = cache_dir.path().join("bases");
758        let bootstrap_dir = root.path().join("bootstrap/global-auth");
759        fs::create_dir_all(&bootstrap_dir).unwrap();
760        fs::write(bootstrap_dir.join("BOOTSTRAP.md"), b"# auth").unwrap();
761
762        let locked = LockedItem {
763            source: "old-source".into(),
764            kind: ItemKind::BootstrapDoc,
765            version: None,
766            source_checksum: "sha256:aaa".into(),
767            installed_checksum: "sha256:bbb".into(),
768            dest_path: "bootstrap/global-auth/BOOTSTRAP.md".into(),
769        };
770
771        let plan = SyncPlan {
772            actions: vec![PlannedAction::Remove { locked }],
773        };
774        let options = SyncOptions {
775            force: false,
776            dry_run: false,
777            frozen: false,
778            no_refresh_models: false,
779        };
780
781        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
782        assert!(matches!(result.outcomes[0].action, ActionTaken::Removed));
783        assert!(!bootstrap_dir.exists());
784    }
785
786    #[test]
787    fn remove_degenerate_bootstrap_doc_path_removes_exact_file_only() {
788        let root = TempDir::new().unwrap();
789        let cache_dir = TempDir::new().unwrap();
790        let bases_dir = cache_dir.path().join("bases");
791        let bootstrap_dir = root.path().join("bootstrap");
792        fs::create_dir_all(&bootstrap_dir).unwrap();
793        fs::write(bootstrap_dir.join("BOOTSTRAP.md"), b"# root").unwrap();
794        fs::write(bootstrap_dir.join("keep.md"), b"# keep").unwrap();
795
796        let locked = LockedItem {
797            source: "old-source".into(),
798            kind: ItemKind::BootstrapDoc,
799            version: None,
800            source_checksum: "sha256:aaa".into(),
801            installed_checksum: "sha256:bbb".into(),
802            dest_path: "bootstrap/BOOTSTRAP.md".into(),
803        };
804
805        let plan = SyncPlan {
806            actions: vec![PlannedAction::Remove { locked }],
807        };
808        let options = SyncOptions {
809            force: false,
810            dry_run: false,
811            frozen: false,
812            no_refresh_models: false,
813        };
814
815        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
816        assert!(matches!(result.outcomes[0].action, ActionTaken::Removed));
817        assert!(!bootstrap_dir.join("BOOTSTRAP.md").exists());
818        assert!(bootstrap_dir.join("keep.md").exists());
819    }
820
821    // === Dry run tests ===
822
823    #[test]
824    fn dry_run_does_not_modify_files() {
825        let root = TempDir::new().unwrap();
826        let source_dir = TempDir::new().unwrap();
827        let cache_dir = TempDir::new().unwrap();
828        let bases_dir = cache_dir.path().join("bases");
829
830        let content = b"# new agent";
831        let source_path = setup_source_agent(source_dir.path(), "coder", content);
832        let target = make_agent_target("coder", source_path, content);
833
834        let plan = SyncPlan {
835            actions: vec![PlannedAction::Install { target }],
836        };
837
838        let options = SyncOptions {
839            force: false,
840            dry_run: true,
841            frozen: false,
842            no_refresh_models: false,
843        };
844
845        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
846        assert_eq!(result.outcomes.len(), 1);
847        assert!(matches!(result.outcomes[0].action, ActionTaken::Installed));
848
849        // File should NOT exist
850        assert!(!root.path().join("agents/coder.md").exists());
851    }
852
853    // === Skip/KeepLocal tests ===
854
855    #[test]
856    fn skip_produces_skipped_outcome() {
857        let root = TempDir::new().unwrap();
858        let cache_dir = TempDir::new().unwrap();
859        let bases_dir = cache_dir.path().join("bases");
860
861        let plan = SyncPlan {
862            actions: vec![PlannedAction::Skip {
863                item_id: ItemId {
864                    kind: ItemKind::Agent,
865                    name: "stable".into(),
866                },
867                dest_path: "agents/stable.md".into(),
868                source_name: "base".into(),
869                installed_checksum: Some("sha256:stable".into()),
870                reason: "unchanged",
871            }],
872        };
873
874        let options = SyncOptions {
875            force: false,
876            dry_run: false,
877            frozen: false,
878            no_refresh_models: false,
879        };
880
881        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
882        assert!(matches!(result.outcomes[0].action, ActionTaken::Skipped));
883        assert_eq!(
884            result.outcomes[0].dest_path,
885            crate::types::DestPath::from("agents/stable.md")
886        );
887        assert_eq!(result.outcomes[0].source_name, "base");
888        assert_eq!(
889            result.outcomes[0].installed_checksum.as_deref(),
890            Some("sha256:stable")
891        );
892    }
893
894    #[test]
895    fn keep_local_produces_kept_outcome() {
896        let root = TempDir::new().unwrap();
897        let cache_dir = TempDir::new().unwrap();
898        let bases_dir = cache_dir.path().join("bases");
899
900        let plan = SyncPlan {
901            actions: vec![PlannedAction::KeepLocal {
902                item_id: ItemId {
903                    kind: ItemKind::Agent,
904                    name: "modified".into(),
905                },
906                dest_path: "agents/modified.md".into(),
907                source_name: "base".into(),
908            }],
909        };
910
911        let options = SyncOptions {
912            force: false,
913            dry_run: false,
914            frozen: false,
915            no_refresh_models: false,
916        };
917
918        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
919        assert!(matches!(result.outcomes[0].action, ActionTaken::Kept));
920        assert_eq!(
921            result.outcomes[0].dest_path,
922            crate::types::DestPath::from("agents/modified.md")
923        );
924        assert_eq!(result.outcomes[0].source_name, "base");
925    }
926
927    // === Install skill directory tests ===
928
929    #[test]
930    fn install_skill_directory() {
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        // Create source skill directory
937        let source_skill = source_dir.path().join("skills/planning");
938        fs::create_dir_all(&source_skill).unwrap();
939        fs::write(source_skill.join("SKILL.md"), b"# Planning skill").unwrap();
940        fs::write(source_skill.join("helper.md"), b"# Helper").unwrap();
941
942        let skill_hash = hash::compute_hash(&source_skill, ItemKind::Skill).unwrap();
943
944        let target = TargetItem {
945            id: ItemId {
946                kind: ItemKind::Skill,
947                name: "planning".into(),
948            },
949            source_name: "test".into(),
950            origin: crate::types::SourceOrigin::Dependency("test".into()),
951            source_id: crate::types::SourceId::Path {
952                canonical: source_skill.clone(),
953                subpath: None,
954            },
955            source_path: source_skill,
956            dest_path: "skills/planning".into(),
957            source_hash: skill_hash.into(),
958            is_flat_skill: false,
959            rewritten_content: None,
960        };
961
962        let plan = SyncPlan {
963            actions: vec![PlannedAction::Install { target }],
964        };
965
966        let options = SyncOptions {
967            force: false,
968            dry_run: false,
969            frozen: false,
970            no_refresh_models: false,
971        };
972
973        let result = execute(root.path(), &plan, &options, &bases_dir).unwrap();
974        assert!(matches!(result.outcomes[0].action, ActionTaken::Installed));
975
976        let installed_dir = root.path().join("skills/planning");
977        assert!(installed_dir.exists());
978        assert!(installed_dir.join("SKILL.md").exists());
979        assert!(installed_dir.join("helper.md").exists());
980        assert_eq!(
981            fs::read_to_string(installed_dir.join("SKILL.md")).unwrap(),
982            "# Planning skill"
983        );
984    }
985
986    #[test]
987    fn install_flat_skill_excludes_repo_metadata() {
988        let root = TempDir::new().unwrap();
989        let source_dir = TempDir::new().unwrap();
990        let cache_dir = TempDir::new().unwrap();
991        let bases_dir = cache_dir.path().join("bases");
992
993        let flat_source = source_dir.path().join("flat-skill");
994        fs::create_dir_all(flat_source.join(".git")).unwrap();
995        fs::create_dir_all(flat_source.join("resources")).unwrap();
996        fs::write(flat_source.join("SKILL.md"), b"# Flat skill").unwrap();
997        fs::write(flat_source.join("resources/guide.md"), b"# Guide").unwrap();
998        fs::write(flat_source.join("mars.toml"), b"[sources]").unwrap();
999        fs::write(flat_source.join(".gitignore"), b"target/").unwrap();
1000        fs::write(flat_source.join(".git/config"), b"[core]").unwrap();
1001
1002        let source_hash = hash::compute_skill_hash_filtered(
1003            &flat_source,
1004            crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL,
1005        )
1006        .unwrap();
1007
1008        let target = TargetItem {
1009            id: ItemId {
1010                kind: ItemKind::Skill,
1011                name: "flat-skill".into(),
1012            },
1013            source_name: "test".into(),
1014            origin: crate::types::SourceOrigin::Dependency("test".into()),
1015            source_id: crate::types::SourceId::Path {
1016                canonical: flat_source.clone(),
1017                subpath: None,
1018            },
1019            source_path: flat_source,
1020            dest_path: "skills/flat-skill".into(),
1021            source_hash: source_hash.into(),
1022            is_flat_skill: true,
1023            rewritten_content: None,
1024        };
1025
1026        let plan = SyncPlan {
1027            actions: vec![PlannedAction::Install { target }],
1028        };
1029
1030        let options = SyncOptions {
1031            force: false,
1032            dry_run: false,
1033            frozen: false,
1034            no_refresh_models: false,
1035        };
1036
1037        execute(root.path(), &plan, &options, &bases_dir).unwrap();
1038
1039        let installed = root.path().join("skills/flat-skill");
1040        assert!(installed.join("SKILL.md").exists());
1041        assert!(installed.join("resources/guide.md").exists());
1042        assert!(!installed.join(".git").exists());
1043        assert!(!installed.join("mars.toml").exists());
1044        assert!(!installed.join(".gitignore").exists());
1045    }
1046
1047    // === Prune orphans tests ===
1048
1049    #[test]
1050    fn prune_removes_orphaned_items() {
1051        let root = TempDir::new().unwrap();
1052
1053        // Create orphaned file
1054        let agents_dir = root.path().join("agents");
1055        fs::create_dir_all(&agents_dir).unwrap();
1056        fs::write(agents_dir.join("old.md"), b"# orphan").unwrap();
1057
1058        let mut lock_items = indexmap::IndexMap::new();
1059        lock_items.insert(
1060            "agent/old".to_string(),
1061            crate::lock::LockedItemV2 {
1062                source: "old-source".into(),
1063                kind: ItemKind::Agent,
1064                version: None,
1065                source_checksum: "sha256:aaa".into(),
1066                outputs: vec![crate::lock::OutputRecord {
1067                    target_root: ".mars".to_string(),
1068                    dest_path: "agents/old.md".into(),
1069                    installed_checksum: "sha256:bbb".into(),
1070                }],
1071            },
1072        );
1073        let lock = crate::lock::LockFile {
1074            version: 2,
1075            dependencies: indexmap::IndexMap::new(),
1076            items: lock_items,
1077            config_entries: std::collections::BTreeMap::new(),
1078        };
1079
1080        // Empty target = orphan should be pruned
1081        let target = crate::sync::target::TargetState {
1082            items: indexmap::IndexMap::new(),
1083        };
1084
1085        let outcomes = prune_orphans(root.path(), &lock, &target).unwrap();
1086        assert_eq!(outcomes.len(), 1);
1087        assert!(matches!(outcomes[0].action, ActionTaken::Removed));
1088        assert!(!root.path().join("agents/old.md").exists());
1089    }
1090
1091    // === DestPath::item_name tests ===
1092
1093    #[test]
1094    fn extract_agent_name() {
1095        assert_eq!(
1096            crate::types::DestPath::from("agents/coder.md").item_name(ItemKind::Agent),
1097            "coder"
1098        );
1099    }
1100
1101    #[test]
1102    fn extract_skill_name() {
1103        assert_eq!(
1104            crate::types::DestPath::from("skills/planning").item_name(ItemKind::Skill),
1105            "planning"
1106        );
1107    }
1108}