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 match resolve_shim_target(config, explicit_kind, category).await? {
29 ShimResolution::Found(PresetKind::Shell) => {
30 Box::pin(shells::handle_install(
31 config,
32 Some(category),
33 replace_managed,
34 ))
35 .await
36 }
37 ShimResolution::Found(PresetKind::App) => {
38 Box::pin(apps::handle_install(
39 config,
40 Some(category),
41 false,
42 replace_managed,
43 ))
44 .await
45 }
46 ShimResolution::Conflict => bail_ambiguous(category),
47 ShimResolution::Missing => bail_shim_missing(category),
48 }
49}
50
51pub async fn handle_uninstall_shim(
52 config: &Config,
53 target: &str,
54 force: bool,
55 purge: bool,
56 dry_run: bool,
57) -> Result<()> {
58 let (explicit_kind, category) = parse_preset_target(target)?;
59 match resolve_shim_target(config, explicit_kind, category).await? {
60 ShimResolution::Found(PresetKind::Shell) => {
61 if force {
62 bail!("`--force` applies only to app presets");
63 }
64 Box::pin(shells::handle_uninstall(
65 config,
66 Some(category),
67 purge,
68 dry_run,
69 ))
70 .await
71 }
72 ShimResolution::Found(PresetKind::App) => {
73 Box::pin(apps::handle_uninstall(
74 config,
75 Some(category),
76 force,
77 purge,
78 dry_run,
79 ))
80 .await
81 }
82 ShimResolution::Conflict => bail_ambiguous(category),
83 ShimResolution::Missing => bail_shim_missing(category),
84 }
85}
86
87pub(crate) async fn resolve_preset_kind(
88 config: &Config,
89 target: &str,
90) -> Result<(PresetKind, String)> {
91 let (explicit_kind, category) = parse_preset_target(target)?;
92 match resolve_shim_target(config, explicit_kind, category).await? {
93 ShimResolution::Found(kind) => Ok((kind, category.to_string())),
94 ShimResolution::Conflict => bail_ambiguous(category),
95 ShimResolution::Missing => bail_shim_missing(category),
96 }
97}
98
99fn parse_preset_target(target: &str) -> Result<(Option<PresetKind>, &str)> {
100 let target = target.trim();
101 if target.is_empty() {
102 bail!("preset target must not be empty");
103 }
104 let (kind, category) = match target.split_once('/') {
105 Some(("app", category)) => (Some(PresetKind::App), category),
106 Some(("shell", category)) => (Some(PresetKind::Shell), category),
107 Some((kind, _)) => bail!(
108 "unsupported preset target kind `{kind}`; expected app/<category> or shell/<category>"
109 ),
110 None => (None, target),
111 };
112 if category.is_empty() || category.contains('/') {
113 bail!(
114 "invalid preset target `{target}`; expected app/<category>, shell/<category>, or a unique category name"
115 );
116 }
117 Ok((kind, category))
118}
119
120async fn resolve_shim_target(
121 config: &Config,
122 explicit_kind: Option<PresetKind>,
123 category: &str,
124) -> Result<ShimResolution> {
125 if let Some(kind) = explicit_kind {
126 let resolution = resolve_shim_category(config, category).await?;
127 return Ok(match (kind, resolution) {
128 (
129 PresetKind::Shell,
130 ShimResolution::Found(PresetKind::Shell) | ShimResolution::Conflict,
131 ) => ShimResolution::Found(PresetKind::Shell),
132 (
133 PresetKind::App,
134 ShimResolution::Found(PresetKind::App) | ShimResolution::Conflict,
135 ) => ShimResolution::Found(PresetKind::App),
136 _ => ShimResolution::Missing,
137 });
138 }
139 resolve_shim_category(config, category).await
140}
141
142async fn resolve_shim_category(config: &Config, category: &str) -> Result<ShimResolution> {
143 let shell_matches = if config.is_external_presets {
149 let shell_path = config.preset_path(std::path::Path::new("shell").join(category));
150 if shell_path.exists() {
151 shells::metadata::load_installed_categories(config, Some(category))
152 .await?
153 .len()
154 } else {
155 0
156 }
157 } else {
158 shells::metadata::load_embedded_categories(Some(category))?.len()
159 };
160 let app_matches = if config.is_external_presets {
161 let app_path = config.preset_path(std::path::Path::new("app").join(category));
162 if app_path.exists() {
163 apps::load_installed_categories(config, Some(category))
164 .await?
165 .len()
166 } else {
167 0
168 }
169 } else {
170 apps::load_embedded_categories(Some(category))?.len()
171 };
172
173 Ok(classify_shim_resolution(shell_matches > 0, app_matches > 0))
174}
175
176fn classify_shim_resolution(shell_matches: bool, app_matches: bool) -> ShimResolution {
177 match (shell_matches, app_matches) {
178 (true, false) => ShimResolution::Found(PresetKind::Shell),
179 (false, true) => ShimResolution::Found(PresetKind::App),
180 (true, true) => ShimResolution::Conflict,
181 (false, false) => ShimResolution::Missing,
182 }
183}
184
185fn bail_ambiguous<T>(category: &str) -> Result<T> {
186 bail!("ambiguous preset target `{category}`; use `app/{category}` or `shell/{category}`")
187}
188
189fn bail_shim_missing<T>(category: &str) -> Result<T> {
190 bail!(
191 "preset category not found in shell or app presets: {category}\nRun `shine shell list` or `shine app list` to see available categories."
192 )
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use tokio::fs;
199
200 async fn make_temp_dir() -> std::path::PathBuf {
201 crate::test_support::make_temp_dir("shine-shim-test").await
202 }
203
204 fn config_in(dir: &std::path::Path) -> Config {
205 crate::test_support::test_config(dir)
206 }
207
208 #[test]
209 fn classify_shim_resolution_handles_all_match_shapes() {
210 assert_eq!(
211 classify_shim_resolution(true, false),
212 ShimResolution::Found(PresetKind::Shell)
213 );
214 assert_eq!(
215 classify_shim_resolution(false, true),
216 ShimResolution::Found(PresetKind::App)
217 );
218 assert_eq!(
219 classify_shim_resolution(true, true),
220 ShimResolution::Conflict
221 );
222 assert_eq!(
223 classify_shim_resolution(false, false),
224 ShimResolution::Missing
225 );
226 }
227
228 #[test]
229 fn parse_preset_target_accepts_canonical_and_unique_shorthand_forms() {
230 assert_eq!(
231 parse_preset_target("app/starship").unwrap(),
232 (Some(PresetKind::App), "starship")
233 );
234 assert_eq!(
235 parse_preset_target("shell/proxy").unwrap(),
236 (Some(PresetKind::Shell), "proxy")
237 );
238 assert_eq!(parse_preset_target("proxy").unwrap(), (None, "proxy"));
239 assert!(parse_preset_target("sys/split-dns").is_err());
240 assert!(parse_preset_target("app/surge/file").is_err());
241 }
242
243 #[tokio::test]
244 async fn resolve_shim_category_matches_embedded_shell_category() {
245 let dir = make_temp_dir().await;
246 let config = config_in(&dir);
247
248 let resolution = resolve_shim_category(&config, "proxy").await.unwrap();
249
250 assert_eq!(resolution, ShimResolution::Found(PresetKind::Shell));
251 fs::remove_dir_all(dir).await.unwrap();
252 }
253
254 #[tokio::test]
255 async fn resolve_shim_category_matches_embedded_app_category() {
256 let dir = make_temp_dir().await;
257 let config = config_in(&dir);
258
259 let resolution = resolve_shim_category(&config, "starship").await.unwrap();
260
261 assert_eq!(resolution, ShimResolution::Found(PresetKind::App));
262 fs::remove_dir_all(dir).await.unwrap();
263 }
264
265 #[tokio::test]
266 async fn resolve_shim_category_reports_missing_category() {
267 let dir = make_temp_dir().await;
268 let config = config_in(&dir);
269
270 let resolution = resolve_shim_category(&config, "does-not-exist")
271 .await
272 .unwrap();
273
274 assert_eq!(resolution, ShimResolution::Missing);
275 fs::remove_dir_all(dir).await.unwrap();
276 }
277}