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    handle_install_shim_approved(config, target, replace_managed, true).await
28}
29
30pub async fn handle_install_shim_approved(
31    config: &Config,
32    target: &str,
33    replace_managed: bool,
34    yes: bool,
35) -> Result<()> {
36    let (explicit_kind, category) = parse_preset_target(target)?;
37    if explicit_kind == Some(PresetKind::Shell) && category.contains('/') {
38        return Box::pin(shells::handle_install_approved(
39            config,
40            Some(category),
41            replace_managed,
42            yes,
43        ))
44        .await;
45    }
46    match resolve_shim_target(config, explicit_kind, category).await? {
47        ShimResolution::Found(PresetKind::Shell) => {
48            Box::pin(shells::handle_install_approved(
49                config,
50                Some(category),
51                replace_managed,
52                yes,
53            ))
54            .await
55        }
56        ShimResolution::Found(PresetKind::App) => {
57            Box::pin(apps::handle_install_approved(
58                config,
59                Some(category),
60                false,
61                replace_managed,
62                yes,
63            ))
64            .await
65        }
66        ShimResolution::Conflict => bail_ambiguous(category),
67        ShimResolution::Missing => bail_shim_missing(category),
68    }
69}
70
71pub async fn handle_uninstall_shim(
72    config: &Config,
73    target: &str,
74    force: bool,
75    purge: bool,
76    dry_run: bool,
77) -> Result<()> {
78    handle_uninstall_shim_approved(config, target, force, purge, dry_run, true).await
79}
80
81pub async fn handle_uninstall_shim_approved(
82    config: &Config,
83    target: &str,
84    force: bool,
85    purge: bool,
86    dry_run: bool,
87    yes: bool,
88) -> Result<()> {
89    let (explicit_kind, category) = parse_preset_target(target)?;
90    if explicit_kind == Some(PresetKind::Shell) && category.contains('/') {
91        if force {
92            bail!("`--force` applies only to app presets");
93        }
94        return Box::pin(shells::handle_uninstall_approved(
95            config,
96            Some(category),
97            purge,
98            dry_run,
99            yes,
100        ))
101        .await;
102    }
103    match resolve_shim_target(config, explicit_kind, category).await? {
104        ShimResolution::Found(PresetKind::Shell) => {
105            if force {
106                bail!("`--force` applies only to app presets");
107            }
108            Box::pin(shells::handle_uninstall_approved(
109                config,
110                Some(category),
111                purge,
112                dry_run,
113                yes,
114            ))
115            .await
116        }
117        ShimResolution::Found(PresetKind::App) => {
118            Box::pin(apps::handle_uninstall_approved(
119                config,
120                Some(category),
121                force,
122                purge,
123                dry_run,
124                yes,
125            ))
126            .await
127        }
128        ShimResolution::Conflict => bail_ambiguous(category),
129        ShimResolution::Missing => bail_shim_missing(category),
130    }
131}
132
133pub(crate) async fn resolve_preset_kind(
134    config: &Config,
135    target: &str,
136) -> Result<(PresetKind, String)> {
137    let (explicit_kind, target) = parse_preset_target(target)?;
138    let category = if explicit_kind == Some(PresetKind::Shell) {
139        target.split('/').next().unwrap_or_default()
140    } else {
141        target
142    };
143    match resolve_shim_target(config, explicit_kind, category).await? {
144        ShimResolution::Found(kind) => Ok((kind, category.to_string())),
145        ShimResolution::Conflict => bail_ambiguous(category),
146        ShimResolution::Missing => bail_shim_missing(category),
147    }
148}
149
150fn parse_preset_target(target: &str) -> Result<(Option<PresetKind>, &str)> {
151    let target = target.trim();
152    if target.is_empty() {
153        bail!("preset target must not be empty");
154    }
155    let (kind, category) = match target.split_once('/') {
156        Some(("app", category)) => (Some(PresetKind::App), category),
157        Some(("shell", category)) => (Some(PresetKind::Shell), category),
158        Some((kind, _)) => bail!(
159            "unsupported preset target kind `{kind}`; expected app/<category> or shell/<category>[/<command>]"
160        ),
161        None => (None, target),
162    };
163    let valid = match kind {
164        Some(PresetKind::Shell) => {
165            let mut parts = category.split('/');
166            parts.next().is_some_and(|part| !part.is_empty())
167                && parts.next().is_none_or(|part| !part.is_empty())
168                && parts.next().is_none()
169        }
170        Some(PresetKind::App) | None => !category.is_empty() && !category.contains('/'),
171    };
172    if !valid {
173        bail!(
174            "invalid preset target `{target}`; expected app/<category>, shell/<category>[/<command>], or a unique category name"
175        );
176    }
177    Ok((kind, category))
178}
179
180async fn resolve_shim_target(
181    config: &Config,
182    explicit_kind: Option<PresetKind>,
183    category: &str,
184) -> Result<ShimResolution> {
185    if let Some(kind) = explicit_kind {
186        let resolution = resolve_shim_category(config, category).await?;
187        return Ok(match (kind, resolution) {
188            (
189                PresetKind::Shell,
190                ShimResolution::Found(PresetKind::Shell) | ShimResolution::Conflict,
191            ) => ShimResolution::Found(PresetKind::Shell),
192            (
193                PresetKind::App,
194                ShimResolution::Found(PresetKind::App) | ShimResolution::Conflict,
195            ) => ShimResolution::Found(PresetKind::App),
196            _ => ShimResolution::Missing,
197        });
198    }
199    resolve_shim_category(config, category).await
200}
201
202async fn resolve_shim_category(config: &Config, category: &str) -> Result<ShimResolution> {
203    // Not migrated to metadata::load_active_categories: this guards with an
204    // existence check before calling load_installed_categories in external
205    // mode, deliberately returning 0 matches instead of propagating that
206    // function's `bail!` on an empty result — load_active_categories would
207    // change resolve_shim_category's error semantics here.
208    let shell_matches = if config.is_external_presets {
209        let shell_path = config.preset_path(std::path::Path::new("shell").join(category));
210        if shell_path.exists() {
211            shells::metadata::load_installed_categories(config, Some(category))
212                .await?
213                .len()
214        } else {
215            0
216        }
217    } else {
218        shells::metadata::load_embedded_categories(Some(category))?.len()
219    };
220    let app_matches = if config.is_external_presets {
221        let app_path = config.preset_path(std::path::Path::new("app").join(category));
222        if app_path.exists() {
223            apps::load_installed_categories(config, Some(category))
224                .await?
225                .len()
226        } else {
227            0
228        }
229    } else {
230        apps::load_embedded_categories(Some(category))?.len()
231    };
232
233    Ok(classify_shim_resolution(shell_matches > 0, app_matches > 0))
234}
235
236fn classify_shim_resolution(shell_matches: bool, app_matches: bool) -> ShimResolution {
237    match (shell_matches, app_matches) {
238        (true, false) => ShimResolution::Found(PresetKind::Shell),
239        (false, true) => ShimResolution::Found(PresetKind::App),
240        (true, true) => ShimResolution::Conflict,
241        (false, false) => ShimResolution::Missing,
242    }
243}
244
245fn bail_ambiguous<T>(category: &str) -> Result<T> {
246    bail!("ambiguous preset target `{category}`; use `app/{category}` or `shell/{category}`")
247}
248
249fn bail_shim_missing<T>(category: &str) -> Result<T> {
250    bail!(
251        "preset category not found in shell or app presets: {category}\nRun `shine shell list` or `shine app list` to see available categories."
252    )
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use tokio::fs;
259
260    async fn make_temp_dir() -> std::path::PathBuf {
261        crate::test_support::make_temp_dir("shine-shim-test").await
262    }
263
264    fn config_in(dir: &std::path::Path) -> Config {
265        crate::test_support::test_config(dir)
266    }
267
268    #[test]
269    fn classify_shim_resolution_handles_all_match_shapes() {
270        assert_eq!(
271            classify_shim_resolution(true, false),
272            ShimResolution::Found(PresetKind::Shell)
273        );
274        assert_eq!(
275            classify_shim_resolution(false, true),
276            ShimResolution::Found(PresetKind::App)
277        );
278        assert_eq!(
279            classify_shim_resolution(true, true),
280            ShimResolution::Conflict
281        );
282        assert_eq!(
283            classify_shim_resolution(false, false),
284            ShimResolution::Missing
285        );
286    }
287
288    #[test]
289    fn parse_preset_target_accepts_canonical_and_unique_shorthand_forms() {
290        assert_eq!(
291            parse_preset_target("app/starship").unwrap(),
292            (Some(PresetKind::App), "starship")
293        );
294        assert_eq!(
295            parse_preset_target("shell/proxy").unwrap(),
296            (Some(PresetKind::Shell), "proxy")
297        );
298        assert_eq!(
299            parse_preset_target("shell/utils/shine-env-export").unwrap(),
300            (Some(PresetKind::Shell), "utils/shine-env-export")
301        );
302        assert_eq!(parse_preset_target("proxy").unwrap(), (None, "proxy"));
303        assert!(parse_preset_target("sys/split-dns").is_err());
304        assert!(parse_preset_target("app/surge/file").is_err());
305        assert!(parse_preset_target("shell/utils/tool/extra").is_err());
306    }
307
308    #[tokio::test]
309    async fn resolve_shim_category_matches_embedded_shell_category() {
310        let dir = make_temp_dir().await;
311        let config = config_in(&dir);
312
313        let resolution = resolve_shim_category(&config, "proxy").await.unwrap();
314
315        assert_eq!(resolution, ShimResolution::Found(PresetKind::Shell));
316        fs::remove_dir_all(dir).await.unwrap();
317    }
318
319    #[tokio::test]
320    async fn resolve_shim_category_matches_embedded_app_category() {
321        let dir = make_temp_dir().await;
322        let config = config_in(&dir);
323
324        let resolution = resolve_shim_category(&config, "starship").await.unwrap();
325
326        assert_eq!(resolution, ShimResolution::Found(PresetKind::App));
327        fs::remove_dir_all(dir).await.unwrap();
328    }
329
330    #[tokio::test]
331    async fn resolve_shim_category_reports_missing_category() {
332        let dir = make_temp_dir().await;
333        let config = config_in(&dir);
334
335        let resolution = resolve_shim_category(&config, "does-not-exist")
336            .await
337            .unwrap();
338
339        assert_eq!(resolution, ShimResolution::Missing);
340        fs::remove_dir_all(dir).await.unwrap();
341    }
342
343    #[tokio::test]
344    async fn canonical_shell_command_target_installs_and_uninstalls_one_command() {
345        let dir = make_temp_dir().await;
346        let config = config_in(&dir);
347        fs::create_dir_all(config.bin_dir()).await.unwrap();
348
349        handle_install_shim(&config, "shell/utils/shine-env-export", false)
350            .await
351            .unwrap();
352        let selected = crate::bin_links::command_path_for_name(
353            config.bin_dir(),
354            std::ffi::OsStr::new("shine-env-export"),
355        );
356        let sibling = crate::bin_links::command_path_for_name(
357            config.bin_dir(),
358            std::ffi::OsStr::new("shine-theme-sync"),
359        );
360        assert!(selected.exists());
361        assert!(!sibling.exists());
362
363        handle_uninstall_shim(&config, "shell/utils/shine-env-export", false, false, false)
364            .await
365            .unwrap();
366        assert!(!selected.exists());
367
368        fs::remove_dir_all(dir).await.unwrap();
369    }
370}