1use super::install::installed_source_commands;
2use super::profile::{
3 remove_managed_shell_profile, remove_path_from_shell_config, write_managed_shell_profile,
4};
5use super::report::{remove_report_summary_parts, unlink_report_summary_parts};
6use crate::config::Config;
7use crate::output;
8use anyhow::{Context, Result};
9
10pub async fn handle_uninstall(
11 config: &Config,
12 category: Option<&str>,
13 purge: bool,
14 dry_run: bool,
15) -> Result<()> {
16 crate::config::print_presets_note(config);
17 if dry_run {
18 println!(
19 "{}",
20 crate::colors::dim("[dry-run] No files will be modified.")
21 );
22 }
23
24 let managed_presets_root = match category {
26 Some(cat) => config.presets_dir().join("shell").join(cat),
27 None => config.presets_dir().to_path_buf(),
28 };
29 let managed_rendered_root = match category {
30 Some(cat) => config.rendered_dir().join("shell").join(cat),
31 None => config.rendered_dir().join("shell"),
32 };
33 let prefix = match category {
34 Some(cat) => format!("shell/{cat}"),
35 None => "shell".to_owned(),
36 };
37
38 let unlink_presets =
40 crate::bin_links::unlink_managed(config.bin_dir(), &managed_presets_root, dry_run).await?;
41 let unlink_rendered =
42 crate::bin_links::unlink_managed(config.bin_dir(), &managed_rendered_root, dry_run).await?;
43 let managed_installed_root = match category {
44 Some(cat) => config.installed_shell_dir().join(cat),
45 None => config.installed_shell_dir(),
46 };
47 let unlink_installed =
48 crate::bin_links::unlink_managed(config.bin_dir(), &managed_installed_root, dry_run)
49 .await?;
50 let unlink_report = crate::bin_links::UnlinkReport {
51 removed: [
52 unlink_presets.removed,
53 unlink_rendered.removed,
54 unlink_installed.removed,
55 ]
56 .concat(),
57 skipped: [
58 unlink_presets.skipped,
59 unlink_rendered.skipped,
60 unlink_installed.skipped,
61 ]
62 .concat(),
63 };
64 output::summary_line("Bin Links", &unlink_report_summary_parts(&unlink_report));
65
66 if !config.is_external_presets {
69 let remove_report =
70 crate::presets::remove_prefix(&prefix, config.presets_dir(), dry_run).await?;
71 output::summary_line(
72 "Shell Presets",
73 &remove_report_summary_parts(&remove_report),
74 );
75 }
76
77 if purge && !dry_run && !config.is_external_presets {
80 let purge_dir = match category {
81 Some(cat) => config.presets_dir().join("shell").join(cat),
82 None => config.presets_dir().join("shell"),
83 };
84 if purge_dir.exists() {
85 tokio::fs::remove_dir_all(&purge_dir)
86 .await
87 .with_context(|| format!("removing presets directory: {purge_dir:?}"))?;
88 }
89 if category.is_none() {
90 let _ = tokio::fs::remove_dir(config.presets_dir()).await;
92 let _ = tokio::fs::remove_dir(config.bin_dir()).await;
93 }
94 println!(
95 " {} {}",
96 crate::colors::symbol("✓"),
97 crate::colors::dim("managed directories purged (if empty)"),
98 );
99 }
100
101 if !dry_run && managed_rendered_root.exists() {
103 tokio::fs::remove_dir_all(&managed_rendered_root)
104 .await
105 .with_context(|| {
106 format!("removing rendered dir: {}", managed_rendered_root.display())
107 })?;
108 }
109
110 if !dry_run && managed_installed_root.exists() {
111 tokio::fs::remove_dir_all(&managed_installed_root)
112 .await
113 .with_context(|| {
114 format!(
115 "removing installed shell snapshot: {}",
116 managed_installed_root.display()
117 )
118 })?;
119 }
120
121 if !dry_run {
122 let mut manifest = super::deployment::ShellManifest::load(config).await?;
123 if let Some(category) = category {
124 manifest.remove_category(category);
125 } else {
126 manifest.entries.clear();
127 }
128 manifest.save(config).await?;
129 }
130
131 if !dry_run {
132 if category.is_none() {
133 remove_path_from_shell_config(config).await?;
135 remove_managed_shell_profile(config).await?;
136 } else {
137 let remaining_source_commands = installed_source_commands(config).await?;
138 write_managed_shell_profile(config, &remaining_source_commands).await?;
139 }
140 }
141
142 Ok(())
143}
144
145#[cfg(test)]
146mod tests {
147 use super::super::ShellType;
148 use super::super::install::handle_install;
149 use super::super::profile::{append_path_to_shell_config, managed_shell_profile_path};
150 use super::*;
151 use std::path::PathBuf;
152 use tokio::fs;
153
154 async fn make_temp_dir() -> PathBuf {
155 crate::test_support::make_temp_dir("shine-shell").await
156 }
157
158 fn wrapper_marker(command: &str, shell: &ShellType) -> String {
159 match shell {
160 ShellType::PowerShell => format!("\nfunction {command} {{ . (Join-Path $shineBin"),
161 ShellType::Fish => format!("\nfunction {command}"),
162 _ => format!("\n{command}() {{ source"),
163 }
164 }
165
166 #[cfg(unix)]
167 #[tokio::test]
168 async fn uninstall_purge_removes_managed_dirs_but_not_config() {
169 let dir = make_temp_dir().await;
170 let config = Config::new_for_test(&dir);
171 fs::create_dir_all(config.presets_dir()).await.unwrap();
172 fs::create_dir_all(config.bin_dir()).await.unwrap();
173
174 handle_install(&config, None, false).await.unwrap();
175 handle_uninstall(&config, None, true, false).await.unwrap();
176
177 assert!(!config.bin_dir().exists(), "bin_dir should be purged");
178 assert!(
179 !config.presets_dir().join("shell").exists(),
180 "shell presets dir should be purged"
181 );
182 assert!(
184 config.presets_dir().parent().is_some(),
185 "shine root still accessible"
186 );
187
188 fs::remove_dir_all(&dir).await.unwrap();
189 }
190
191 #[cfg(unix)]
192 #[tokio::test]
193 async fn uninstall_dry_run_leaves_everything_intact() {
194 let dir = make_temp_dir().await;
195 let config = Config::new_for_test(&dir);
196 fs::create_dir_all(config.presets_dir()).await.unwrap();
197 fs::create_dir_all(config.bin_dir()).await.unwrap();
198
199 handle_install(&config, None, false).await.unwrap();
200 let preset_path = config.presets_dir().join("shell/proxy/set_proxy.sh");
201 assert!(preset_path.exists());
202
203 handle_uninstall(&config, None, false, true).await.unwrap();
204
205 assert!(preset_path.exists(), "dry-run must not remove preset files");
206
207 fs::remove_dir_all(&dir).await.unwrap();
208 }
209
210 #[tokio::test]
211 async fn remove_clears_sentinel_from_shell_config() {
212 let dir = make_temp_dir().await;
213 let config = Config::new_for_test(&dir);
214
215 append_path_to_shell_config(&config, false, &[])
216 .await
217 .unwrap();
218 remove_path_from_shell_config(&config).await.unwrap();
219
220 let config_path =
221 super::super::get_shell_config_path(&config.shell_type, &config.home_dir).unwrap();
222 let content = fs::read_to_string(&config_path).await.unwrap();
223 assert!(
224 !content.contains(super::super::SENTINEL_START),
225 "sentinel should be gone after remove"
226 );
227 }
228
229 #[tokio::test]
230 async fn remove_is_no_op_when_config_missing() {
231 let dir = make_temp_dir().await;
232 let config = Config::new_for_test(&dir);
233 remove_path_from_shell_config(&config).await.unwrap();
235 }
236
237 #[cfg(unix)]
238 #[tokio::test]
239 async fn uninstall_dry_run_does_not_modify_shell_config() {
240 let dir = make_temp_dir().await;
241 let config = Config::new_for_test(&dir);
242 fs::create_dir_all(config.presets_dir()).await.unwrap();
243 fs::create_dir_all(config.bin_dir()).await.unwrap();
244
245 handle_install(&config, None, false).await.unwrap();
246 let config_path =
247 super::super::get_shell_config_path(&config.shell_type, &config.home_dir).unwrap();
248 let before = fs::read_to_string(&config_path).await.unwrap();
249 let profile_path = managed_shell_profile_path(&config);
250 let profile_before = fs::read_to_string(&profile_path).await.unwrap();
251
252 handle_uninstall(&config, None, false, true).await.unwrap();
253
254 let after = fs::read_to_string(&config_path).await.unwrap();
255 assert_eq!(before, after, "dry-run must not touch shell config");
256 let profile_after = fs::read_to_string(&profile_path).await.unwrap();
257 assert_eq!(
258 profile_before, profile_after,
259 "dry-run must not touch managed shell profile"
260 );
261
262 fs::remove_dir_all(&dir).await.unwrap();
263 }
264
265 #[tokio::test]
266 async fn uninstall_category_keeps_agent_launcher_and_prunes_source_wrappers() {
267 let dir = make_temp_dir().await;
268 let config = Config::new_for_test(&dir);
269 fs::create_dir_all(config.presets_dir()).await.unwrap();
270 fs::create_dir_all(config.bin_dir()).await.unwrap();
271
272 handle_install(&config, Some("agent"), false).await.unwrap();
273 handle_install(&config, Some("proxy"), false).await.unwrap();
274
275 handle_uninstall(&config, Some("proxy"), false, false)
276 .await
277 .unwrap();
278
279 let profile = fs::read_to_string(managed_shell_profile_path(&config))
280 .await
281 .unwrap();
282 assert!(!profile.contains(&wrapper_marker("ccenv", &config.shell_type)));
283 assert!(
284 !profile.contains(&wrapper_marker("setproxy", &config.shell_type)),
285 "removed category wrapper should be pruned: {profile}"
286 );
287 assert!(
288 !profile.contains(&wrapper_marker("usetproxy", &config.shell_type)),
289 "removed category wrapper should be pruned: {profile}"
290 );
291 let ccenv = crate::bin_links::command_path_for_name(
292 config.bin_dir(),
293 std::ffi::OsStr::new("ccenv"),
294 );
295 assert!(ccenv.exists(), "remaining Bun launcher should be kept");
296
297 fs::remove_dir_all(&dir).await.unwrap();
298 }
299
300 #[cfg(unix)]
301 #[tokio::test]
302 async fn external_presets_uninstall_preserves_disk_scripts() {
303 let dir = make_temp_dir().await;
304 let cat_dir = dir.join("presets/shell/custom");
305 fs::create_dir_all(&cat_dir).await.unwrap();
306 let script = cat_dir.join("my_tool.sh");
307 fs::write(&script, b"#!/bin/bash\n# My tool.\necho hi\n")
308 .await
309 .unwrap();
310 use std::os::unix::fs::PermissionsExt;
311 let mut perms = fs::metadata(&script).await.unwrap().permissions();
312 perms.set_mode(perms.mode() | 0o111);
313 fs::set_permissions(&script, perms).await.unwrap();
314
315 let mut config = Config::new_for_test(&dir);
316 config.is_external_presets = true;
317 fs::create_dir_all(config.bin_dir()).await.unwrap();
318
319 handle_install(&config, Some("custom"), false)
320 .await
321 .unwrap();
322 assert!(config.bin_dir().join("my_tool").exists());
323
324 handle_uninstall(&config, Some("custom"), false, false)
325 .await
326 .unwrap();
327
328 assert!(script.exists(), "user script must not be deleted");
330 assert!(
332 !config.bin_dir().join("my_tool").exists(),
333 "bin link should be removed"
334 );
335
336 fs::remove_dir_all(&dir).await.unwrap();
337 }
338}