1use 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 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}