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