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