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