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