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    // Keep the external-source existence guards so a miss remains 0 matches
204    // instead of propagating the active loader's not-found error. The loader
205    // must still use the active snapshot: built-in presets can have an overlay
206    // with categories that do not exist in the embedded namespace.
207    let shell_path = config.preset_path(std::path::Path::new("shell").join(category));
208    let shell_matches = if config.is_external_presets && !shell_path.exists() {
209        0
210    } else {
211        shells::metadata::load_active_categories(config, Some(category))
212            .await?
213            .len()
214    };
215    let app_path = config.preset_path(std::path::Path::new("app").join(category));
216    let app_matches = if config.is_external_presets && !app_path.exists() {
217        0
218    } else {
219        apps::load_active_categories(config, Some(category))
220            .await?
221            .len()
222    };
223
224    Ok(classify_shim_resolution(shell_matches > 0, app_matches > 0))
225}
226
227fn classify_shim_resolution(shell_matches: bool, app_matches: bool) -> ShimResolution {
228    match (shell_matches, app_matches) {
229        (true, false) => ShimResolution::Found(PresetKind::Shell),
230        (false, true) => ShimResolution::Found(PresetKind::App),
231        (true, true) => ShimResolution::Conflict,
232        (false, false) => ShimResolution::Missing,
233    }
234}
235
236fn bail_ambiguous<T>(category: &str) -> Result<T> {
237    bail!("ambiguous preset target `{category}`; use `app/{category}` or `shell/{category}`")
238}
239
240fn bail_shim_missing<T>(category: &str) -> Result<T> {
241    bail!(
242        "preset category not found in shell or app presets: {category}\nRun `shine shell list` or `shine app list` to see available categories."
243    )
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use tokio::fs;
250
251    async fn make_temp_dir() -> std::path::PathBuf {
252        crate::test_support::make_temp_dir("shine-shim-test").await
253    }
254
255    fn config_in(dir: &std::path::Path) -> Config {
256        crate::test_support::test_config(dir)
257    }
258
259    #[test]
260    fn classify_shim_resolution_handles_all_match_shapes() {
261        assert_eq!(
262            classify_shim_resolution(true, false),
263            ShimResolution::Found(PresetKind::Shell)
264        );
265        assert_eq!(
266            classify_shim_resolution(false, true),
267            ShimResolution::Found(PresetKind::App)
268        );
269        assert_eq!(
270            classify_shim_resolution(true, true),
271            ShimResolution::Conflict
272        );
273        assert_eq!(
274            classify_shim_resolution(false, false),
275            ShimResolution::Missing
276        );
277    }
278
279    #[test]
280    fn parse_preset_target_accepts_canonical_and_unique_shorthand_forms() {
281        assert_eq!(
282            parse_preset_target("app/starship").unwrap(),
283            (Some(PresetKind::App), "starship")
284        );
285        assert_eq!(
286            parse_preset_target("shell/proxy").unwrap(),
287            (Some(PresetKind::Shell), "proxy")
288        );
289        assert_eq!(
290            parse_preset_target("shell/utils/shine-env-export").unwrap(),
291            (Some(PresetKind::Shell), "utils/shine-env-export")
292        );
293        assert_eq!(parse_preset_target("proxy").unwrap(), (None, "proxy"));
294        assert!(parse_preset_target("sys/split-dns").is_err());
295        assert!(parse_preset_target("app/surge/file").is_err());
296        assert!(parse_preset_target("shell/utils/tool/extra").is_err());
297    }
298
299    #[tokio::test]
300    async fn resolve_shim_category_matches_embedded_shell_category() {
301        let dir = make_temp_dir().await;
302        let config = config_in(&dir);
303
304        let resolution = resolve_shim_category(&config, "proxy").await.unwrap();
305
306        assert_eq!(resolution, ShimResolution::Found(PresetKind::Shell));
307        fs::remove_dir_all(dir).await.unwrap();
308    }
309
310    #[tokio::test]
311    async fn resolve_shim_category_matches_embedded_app_category() {
312        let dir = make_temp_dir().await;
313        let config = config_in(&dir);
314
315        let resolution = resolve_shim_category(&config, "starship").await.unwrap();
316
317        assert_eq!(resolution, ShimResolution::Found(PresetKind::App));
318        fs::remove_dir_all(dir).await.unwrap();
319    }
320
321    #[tokio::test]
322    async fn resolve_shim_category_reports_missing_category() {
323        let dir = make_temp_dir().await;
324        let config = config_in(&dir);
325
326        let resolution = resolve_shim_category(&config, "does-not-exist")
327            .await
328            .unwrap();
329
330        assert_eq!(resolution, ShimResolution::Missing);
331        fs::remove_dir_all(dir).await.unwrap();
332    }
333
334    #[tokio::test]
335    async fn resolve_shim_category_matches_overlay_only_categories_with_embedded_base() {
336        let dir = make_temp_dir().await;
337        let overlay = dir.join("overlay");
338        fs::create_dir_all(overlay.join("shell/custom-shell"))
339            .await
340            .unwrap();
341        fs::write(
342            overlay.join("shell/custom-shell/shine.toml"),
343            "description = 'Overlay Shell'\n[[files]]\nsource = 'custom.sh'\ntarget = 'custom'\n[files.permissions]\nschema_version = 1\n",
344        )
345        .await
346        .unwrap();
347        fs::write(overlay.join("shell/custom-shell/custom.sh"), "#!/bin/sh\n")
348            .await
349            .unwrap();
350        fs::create_dir_all(overlay.join("app/custom-app"))
351            .await
352            .unwrap();
353        fs::write(
354            overlay.join("app/custom-app/shine.toml"),
355            "metadata_schema_version = 2\ndescription = 'Overlay App'\ndest = '~/.config/custom-app'\n[permissions]\nschema_version = 1\n[[files]]\nsource = 'config.toml'\ntarget = 'config.toml'\n",
356        )
357        .await
358        .unwrap();
359        fs::write(
360            overlay.join("app/custom-app/config.toml"),
361            "enabled = true\n",
362        )
363        .await
364        .unwrap();
365        let config = config_in(&dir).with_presets_overlay_dir_override(Some(overlay));
366
367        assert_eq!(
368            resolve_shim_category(&config, "custom-shell")
369                .await
370                .unwrap(),
371            ShimResolution::Found(PresetKind::Shell)
372        );
373        assert_eq!(
374            resolve_shim_category(&config, "custom-app").await.unwrap(),
375            ShimResolution::Found(PresetKind::App)
376        );
377
378        fs::remove_dir_all(dir).await.unwrap();
379    }
380
381    #[tokio::test]
382    async fn canonical_shell_command_target_installs_and_uninstalls_one_command() {
383        let dir = make_temp_dir().await;
384        let config = config_in(&dir);
385        fs::create_dir_all(config.bin_dir()).await.unwrap();
386
387        handle_install_shim(&config, "shell/utils/shine-env-export", false)
388            .await
389            .unwrap();
390        let selected = crate::bin_links::command_path_for_name(
391            config.bin_dir(),
392            std::ffi::OsStr::new("shine-env-export"),
393        );
394        let sibling = crate::bin_links::command_path_for_name(
395            config.bin_dir(),
396            std::ffi::OsStr::new("shine-theme-sync"),
397        );
398        assert!(selected.exists());
399        assert!(!sibling.exists());
400
401        handle_uninstall_shim(&config, "shell/utils/shine-env-export", false, false, false)
402            .await
403            .unwrap();
404        assert!(!selected.exists());
405
406        fs::remove_dir_all(dir).await.unwrap();
407    }
408}