Skip to main content

cli/
shim.rs

1//! Top-level `shine install/uninstall <target>` shims: they resolve canonical
2//! or unambiguous preset targets and delegate to the corresponding handler.
3
4use anyhow::{Result, bail};
5
6use crate::config::Config;
7use crate::{apps, shells};
8
9#[derive(Copy, Clone, Debug, Eq, PartialEq)]
10pub(crate) enum PresetKind {
11    Shell,
12    App,
13}
14
15#[derive(Copy, Clone, Debug, Eq, PartialEq)]
16enum ShimResolution {
17    Found(PresetKind),
18    Conflict,
19    Missing,
20}
21
22pub async fn handle_install_shim(
23    config: &Config,
24    target: &str,
25    replace_managed: bool,
26) -> Result<()> {
27    let (explicit_kind, category) = parse_preset_target(target)?;
28    match resolve_shim_target(config, explicit_kind, category).await? {
29        ShimResolution::Found(PresetKind::Shell) => {
30            Box::pin(shells::handle_install(
31                config,
32                Some(category),
33                replace_managed,
34            ))
35            .await
36        }
37        ShimResolution::Found(PresetKind::App) => {
38            Box::pin(apps::handle_install(
39                config,
40                Some(category),
41                false,
42                replace_managed,
43            ))
44            .await
45        }
46        ShimResolution::Conflict => bail_ambiguous(category),
47        ShimResolution::Missing => bail_shim_missing(category),
48    }
49}
50
51pub async fn handle_uninstall_shim(
52    config: &Config,
53    target: &str,
54    force: bool,
55    purge: bool,
56    dry_run: bool,
57) -> Result<()> {
58    let (explicit_kind, category) = parse_preset_target(target)?;
59    match resolve_shim_target(config, explicit_kind, category).await? {
60        ShimResolution::Found(PresetKind::Shell) => {
61            if force {
62                bail!("`--force` applies only to app presets");
63            }
64            Box::pin(shells::handle_uninstall(
65                config,
66                Some(category),
67                purge,
68                dry_run,
69            ))
70            .await
71        }
72        ShimResolution::Found(PresetKind::App) => {
73            Box::pin(apps::handle_uninstall(
74                config,
75                Some(category),
76                force,
77                purge,
78                dry_run,
79            ))
80            .await
81        }
82        ShimResolution::Conflict => bail_ambiguous(category),
83        ShimResolution::Missing => bail_shim_missing(category),
84    }
85}
86
87pub(crate) async fn resolve_preset_kind(
88    config: &Config,
89    target: &str,
90) -> Result<(PresetKind, String)> {
91    let (explicit_kind, category) = parse_preset_target(target)?;
92    match resolve_shim_target(config, explicit_kind, category).await? {
93        ShimResolution::Found(kind) => Ok((kind, category.to_string())),
94        ShimResolution::Conflict => bail_ambiguous(category),
95        ShimResolution::Missing => bail_shim_missing(category),
96    }
97}
98
99fn parse_preset_target(target: &str) -> Result<(Option<PresetKind>, &str)> {
100    let target = target.trim();
101    if target.is_empty() {
102        bail!("preset target must not be empty");
103    }
104    let (kind, category) = match target.split_once('/') {
105        Some(("app", category)) => (Some(PresetKind::App), category),
106        Some(("shell", category)) => (Some(PresetKind::Shell), category),
107        Some((kind, _)) => bail!(
108            "unsupported preset target kind `{kind}`; expected app/<category> or shell/<category>"
109        ),
110        None => (None, target),
111    };
112    if category.is_empty() || category.contains('/') {
113        bail!(
114            "invalid preset target `{target}`; expected app/<category>, shell/<category>, or a unique category name"
115        );
116    }
117    Ok((kind, category))
118}
119
120async fn resolve_shim_target(
121    config: &Config,
122    explicit_kind: Option<PresetKind>,
123    category: &str,
124) -> Result<ShimResolution> {
125    if let Some(kind) = explicit_kind {
126        let resolution = resolve_shim_category(config, category).await?;
127        return Ok(match (kind, resolution) {
128            (
129                PresetKind::Shell,
130                ShimResolution::Found(PresetKind::Shell) | ShimResolution::Conflict,
131            ) => ShimResolution::Found(PresetKind::Shell),
132            (
133                PresetKind::App,
134                ShimResolution::Found(PresetKind::App) | ShimResolution::Conflict,
135            ) => ShimResolution::Found(PresetKind::App),
136            _ => ShimResolution::Missing,
137        });
138    }
139    resolve_shim_category(config, category).await
140}
141
142async fn resolve_shim_category(config: &Config, category: &str) -> Result<ShimResolution> {
143    // Not migrated to metadata::load_active_categories: this guards with an
144    // existence check before calling load_installed_categories in external
145    // mode, deliberately returning 0 matches instead of propagating that
146    // function's `bail!` on an empty result — load_active_categories would
147    // change resolve_shim_category's error semantics here.
148    let shell_matches = if config.is_external_presets {
149        let shell_path = config.preset_path(std::path::Path::new("shell").join(category));
150        if shell_path.exists() {
151            shells::metadata::load_installed_categories(config, Some(category))
152                .await?
153                .len()
154        } else {
155            0
156        }
157    } else {
158        shells::metadata::load_embedded_categories(Some(category))?.len()
159    };
160    let app_matches = if config.is_external_presets {
161        let app_path = config.preset_path(std::path::Path::new("app").join(category));
162        if app_path.exists() {
163            apps::load_installed_categories(config, Some(category))
164                .await?
165                .len()
166        } else {
167            0
168        }
169    } else {
170        apps::load_embedded_categories(Some(category))?.len()
171    };
172
173    Ok(classify_shim_resolution(shell_matches > 0, app_matches > 0))
174}
175
176fn classify_shim_resolution(shell_matches: bool, app_matches: bool) -> ShimResolution {
177    match (shell_matches, app_matches) {
178        (true, false) => ShimResolution::Found(PresetKind::Shell),
179        (false, true) => ShimResolution::Found(PresetKind::App),
180        (true, true) => ShimResolution::Conflict,
181        (false, false) => ShimResolution::Missing,
182    }
183}
184
185fn bail_ambiguous<T>(category: &str) -> Result<T> {
186    bail!("ambiguous preset target `{category}`; use `app/{category}` or `shell/{category}`")
187}
188
189fn bail_shim_missing<T>(category: &str) -> Result<T> {
190    bail!(
191        "preset category not found in shell or app presets: {category}\nRun `shine shell list` or `shine app list` to see available categories."
192    )
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use tokio::fs;
199
200    async fn make_temp_dir() -> std::path::PathBuf {
201        crate::test_support::make_temp_dir("shine-shim-test").await
202    }
203
204    fn config_in(dir: &std::path::Path) -> Config {
205        crate::test_support::test_config(dir)
206    }
207
208    #[test]
209    fn classify_shim_resolution_handles_all_match_shapes() {
210        assert_eq!(
211            classify_shim_resolution(true, false),
212            ShimResolution::Found(PresetKind::Shell)
213        );
214        assert_eq!(
215            classify_shim_resolution(false, true),
216            ShimResolution::Found(PresetKind::App)
217        );
218        assert_eq!(
219            classify_shim_resolution(true, true),
220            ShimResolution::Conflict
221        );
222        assert_eq!(
223            classify_shim_resolution(false, false),
224            ShimResolution::Missing
225        );
226    }
227
228    #[test]
229    fn parse_preset_target_accepts_canonical_and_unique_shorthand_forms() {
230        assert_eq!(
231            parse_preset_target("app/starship").unwrap(),
232            (Some(PresetKind::App), "starship")
233        );
234        assert_eq!(
235            parse_preset_target("shell/proxy").unwrap(),
236            (Some(PresetKind::Shell), "proxy")
237        );
238        assert_eq!(parse_preset_target("proxy").unwrap(), (None, "proxy"));
239        assert!(parse_preset_target("sys/split-dns").is_err());
240        assert!(parse_preset_target("app/surge/file").is_err());
241    }
242
243    #[tokio::test]
244    async fn resolve_shim_category_matches_embedded_shell_category() {
245        let dir = make_temp_dir().await;
246        let config = config_in(&dir);
247
248        let resolution = resolve_shim_category(&config, "proxy").await.unwrap();
249
250        assert_eq!(resolution, ShimResolution::Found(PresetKind::Shell));
251        fs::remove_dir_all(dir).await.unwrap();
252    }
253
254    #[tokio::test]
255    async fn resolve_shim_category_matches_embedded_app_category() {
256        let dir = make_temp_dir().await;
257        let config = config_in(&dir);
258
259        let resolution = resolve_shim_category(&config, "starship").await.unwrap();
260
261        assert_eq!(resolution, ShimResolution::Found(PresetKind::App));
262        fs::remove_dir_all(dir).await.unwrap();
263    }
264
265    #[tokio::test]
266    async fn resolve_shim_category_reports_missing_category() {
267        let dir = make_temp_dir().await;
268        let config = config_in(&dir);
269
270        let resolution = resolve_shim_category(&config, "does-not-exist")
271            .await
272            .unwrap();
273
274        assert_eq!(resolution, ShimResolution::Missing);
275        fs::remove_dir_all(dir).await.unwrap();
276    }
277}