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