Skip to main content

cli/shells/
uninstall.rs

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    // When a category is given, scope removal to that category's subdirectory.
25    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    // Remove symlinks pointing to presets_dir (old-style) or rendered_dir (new-style).
39    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    // When the user has a custom presets directory, the source files are theirs —
67    // only remove the embedded-managed files when using the default directory.
68    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    // Only purge managed directories when using the default presets directory.
78    // Never delete a user-configured external folder.
79    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            // remove_dir only succeeds if empty — treat non-empty as benign
91            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    // Remove rendered_dir files — always shine-managed regardless of external-presets mode.
102    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            // Only remove the PATH sentinel when uninstalling all shell presets.
134            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        // config.toml must never be removed by uninstall
183        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        // No install — config file doesn't exist
234        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        // User-owned script must survive uninstall.
329        assert!(script.exists(), "user script must not be deleted");
330        // Bin symlink should be gone.
331        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}