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, bail};
9use std::ffi::OsStr;
10
11pub async fn handle_uninstall(
12    config: &Config,
13    target: Option<&str>,
14    purge: bool,
15    dry_run: bool,
16) -> Result<()> {
17    crate::config::print_presets_note(config);
18    if dry_run {
19        println!(
20            "{}",
21            crate::colors::dim("[dry-run] No files will be modified.")
22        );
23    }
24    let selection = target
25        .map(super::metadata::parse_lifecycle_target)
26        .transpose()?;
27    if let Some(selection) = selection
28        && let Some(command) = selection.command
29    {
30        return handle_uninstall_command(config, selection.category, command, purge, dry_run).await;
31    }
32    let category = selection.map(|selection| selection.category);
33
34    // When a category is given, scope removal to that category's subdirectory.
35    let managed_presets_root = match category {
36        Some(cat) => config.presets_dir().join("shell").join(cat),
37        None => config.presets_dir().to_path_buf(),
38    };
39    let managed_rendered_root = match category {
40        Some(cat) => config.rendered_dir().join("shell").join(cat),
41        None => config.rendered_dir().join("shell"),
42    };
43    let prefix = match category {
44        Some(cat) => format!("shell/{cat}"),
45        None => "shell".to_owned(),
46    };
47
48    // Remove symlinks pointing to presets_dir (old-style) or rendered_dir (new-style).
49    let unlink_presets =
50        crate::bin_links::unlink_managed(config.bin_dir(), &managed_presets_root, dry_run).await?;
51    let unlink_rendered =
52        crate::bin_links::unlink_managed(config.bin_dir(), &managed_rendered_root, dry_run).await?;
53    let managed_installed_root = match category {
54        Some(cat) => config.installed_shell_dir().join(cat),
55        None => config.installed_shell_dir(),
56    };
57    let unlink_installed =
58        crate::bin_links::unlink_managed(config.bin_dir(), &managed_installed_root, dry_run)
59            .await?;
60    let unlink_report = crate::bin_links::UnlinkReport {
61        removed: [
62            unlink_presets.removed,
63            unlink_rendered.removed,
64            unlink_installed.removed,
65        ]
66        .concat(),
67        skipped: [
68            unlink_presets.skipped,
69            unlink_rendered.skipped,
70            unlink_installed.skipped,
71        ]
72        .concat(),
73    };
74    output::summary_line("Bin Links", &unlink_report_summary_parts(&unlink_report));
75
76    // When the user has a custom presets directory, the source files are theirs —
77    // only remove the embedded-managed files when using the default directory.
78    if !config.is_external_presets {
79        let remove_report =
80            crate::presets::remove_prefix(&prefix, config.presets_dir(), dry_run).await?;
81        output::summary_line(
82            "Shell Presets",
83            &remove_report_summary_parts(&remove_report),
84        );
85    }
86
87    // Only purge managed directories when using the default presets directory.
88    // Never delete a user-configured external folder.
89    if purge && !dry_run && !config.is_external_presets {
90        let purge_dir = match category {
91            Some(cat) => config.presets_dir().join("shell").join(cat),
92            None => config.presets_dir().join("shell"),
93        };
94        if purge_dir.exists() {
95            tokio::fs::remove_dir_all(&purge_dir)
96                .await
97                .with_context(|| format!("removing presets directory: {purge_dir:?}"))?;
98        }
99        if category.is_none() {
100            // remove_dir only succeeds if empty — treat non-empty as benign
101            let _ = tokio::fs::remove_dir(config.presets_dir()).await;
102            let _ = tokio::fs::remove_dir(config.bin_dir()).await;
103        }
104        println!(
105            "  {}  {}",
106            crate::colors::symbol("✓"),
107            crate::colors::dim("managed directories purged (if empty)"),
108        );
109    }
110
111    // Remove rendered_dir files — always shine-managed regardless of external-presets mode.
112    if !dry_run && managed_rendered_root.exists() {
113        tokio::fs::remove_dir_all(&managed_rendered_root)
114            .await
115            .with_context(|| {
116                format!("removing rendered dir: {}", managed_rendered_root.display())
117            })?;
118    }
119
120    if !dry_run && managed_installed_root.exists() {
121        tokio::fs::remove_dir_all(&managed_installed_root)
122            .await
123            .with_context(|| {
124                format!(
125                    "removing installed shell snapshot: {}",
126                    managed_installed_root.display()
127                )
128            })?;
129    }
130
131    if !dry_run {
132        let mut manifest = super::deployment::ShellManifest::load(config).await?;
133        if let Some(category) = category {
134            manifest.remove_category(category);
135        } else {
136            manifest.entries.clear();
137        }
138        manifest.save(config).await?;
139    }
140
141    if !dry_run {
142        if category.is_none() {
143            // Only remove the PATH sentinel when uninstalling all shell presets.
144            remove_path_from_shell_config(config).await?;
145            remove_managed_shell_profile(config).await?;
146        } else {
147            let remaining_source_commands = installed_source_commands(config).await?;
148            write_managed_shell_profile(config, &remaining_source_commands).await?;
149        }
150    }
151
152    Ok(())
153}
154
155async fn handle_uninstall_command(
156    config: &Config,
157    category: &str,
158    command: &str,
159    purge: bool,
160    dry_run: bool,
161) -> Result<()> {
162    let mut manifest = super::deployment::ShellManifest::load(config).await?;
163    let canonical = format!("shell/{category}/{command}");
164    let manifest_entry = manifest.find(&canonical).cloned();
165    let active_target = match super::metadata::load_active_target(
166        config,
167        super::metadata::ShellTarget {
168            category,
169            command: Some(command),
170        },
171    )
172    .await
173    {
174        Ok(categories) => Some(categories),
175        Err(error) if manifest_entry.is_none() => return Err(error),
176        Err(_) => None,
177    };
178
179    let mut managed_roots = vec![
180        config.presets_dir().join("shell").join(category),
181        config.rendered_dir().join("shell").join(category),
182        config.installed_shell_dir().join(category),
183    ];
184    if let Some(overlay) = config.active_presets_overlay_dir() {
185        managed_roots.push(overlay.join("shell").join(category));
186    }
187    if let Some(entry) = &manifest_entry {
188        // A live source or overlay may have moved since installation. The exact
189        // recorded targets are still valid ownership evidence for removing this
190        // launcher, without broadening removal to their user-owned parent tree.
191        managed_roots.push(entry.source_path.clone());
192        managed_roots.push(entry.rendered_path.clone());
193    }
194    let unlink_report = crate::bin_links::unlink_managed_command(
195        config.bin_dir(),
196        OsStr::new(command),
197        &managed_roots,
198        dry_run,
199    )
200    .await?;
201    if manifest_entry.is_none() && unlink_report.removed.is_empty() {
202        if unlink_report.skipped.is_empty() {
203            bail!("shell command is not installed: {category}/{command}");
204        }
205        bail!(
206            "shell command entry is not managed by Shine: {}",
207            unlink_report.skipped[0].display()
208        );
209    }
210    output::summary_line("Bin Links", &unlink_report_summary_parts(&unlink_report));
211
212    let other_manifest_entry = manifest
213        .entries
214        .iter()
215        .any(|entry| entry.category == category && entry.command != command);
216    let other_link_exists =
217        match super::metadata::load_active_categories(config, Some(category)).await {
218            Ok(categories) => categories
219                .iter()
220                .flat_map(|category| &category.files)
221                .any(|file| {
222                    file.command_name != command
223                        && crate::bin_links::command_path_for_name(
224                            config.bin_dir(),
225                            OsStr::new(&file.command_name),
226                        )
227                        .exists()
228                }),
229            Err(_) => false,
230        };
231    let category_still_installed = other_manifest_entry || other_link_exists;
232
233    if !dry_run {
234        let rendered_path = manifest_entry
235            .as_ref()
236            .map(|entry| entry.rendered_path.clone())
237            .or_else(|| {
238                active_target.and_then(|categories| {
239                    categories.first().and_then(|category| {
240                        category.files.first().map(|file| {
241                            super::deployment::rendered_path(
242                                config,
243                                &category.name,
244                                &file.source_rel,
245                            )
246                        })
247                    })
248                })
249            });
250        if let Some(path) = rendered_path
251            && path.starts_with(config.rendered_dir())
252            && !manifest.entries.iter().any(|entry| {
253                (entry.category != category || entry.command != command)
254                    && entry.rendered_path == path
255            })
256        {
257            remove_file_if_present(&path).await?;
258        }
259        manifest.remove_target(category, command);
260        manifest.save(config).await?;
261    }
262
263    if !category_still_installed {
264        if !config.is_external_presets {
265            let report = crate::presets::remove_prefix(
266                &format!("shell/{category}"),
267                config.presets_dir(),
268                dry_run,
269            )
270            .await?;
271            output::summary_line("Shell Presets", &remove_report_summary_parts(&report));
272        }
273        if !dry_run {
274            remove_dir_if_present(&config.rendered_dir().join("shell").join(category)).await?;
275            remove_dir_if_present(&config.installed_shell_dir().join(category)).await?;
276        }
277    }
278
279    if purge && !dry_run && !config.is_external_presets && !category_still_installed {
280        let _ = tokio::fs::remove_dir(config.presets_dir().join("shell")).await;
281        let _ = tokio::fs::remove_dir(config.presets_dir()).await;
282        let _ = tokio::fs::remove_dir(config.bin_dir()).await;
283    }
284
285    if !dry_run {
286        let remaining_source_commands = installed_source_commands(config).await?;
287        write_managed_shell_profile(config, &remaining_source_commands).await?;
288    }
289    Ok(())
290}
291
292async fn remove_file_if_present(path: &std::path::Path) -> Result<()> {
293    match tokio::fs::remove_file(path).await {
294        Ok(()) => Ok(()),
295        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
296        Err(error) => Err(error).with_context(|| format!("removing {}", path.display())),
297    }
298}
299
300async fn remove_dir_if_present(path: &std::path::Path) -> Result<()> {
301    match tokio::fs::remove_dir_all(path).await {
302        Ok(()) => Ok(()),
303        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
304        Err(error) => Err(error).with_context(|| format!("removing {}", path.display())),
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::super::ShellType;
311    use super::super::install::handle_install;
312    use super::super::profile::{append_path_to_shell_config, managed_shell_profile_path};
313    use super::*;
314    use std::path::PathBuf;
315    use tokio::fs;
316
317    async fn make_temp_dir() -> PathBuf {
318        crate::test_support::make_temp_dir("shine-shell").await
319    }
320
321    #[tokio::test]
322    async fn command_scoped_uninstall_preserves_installed_sibling() {
323        let dir = make_temp_dir().await;
324        let config = Config::new_for_test(&dir);
325        fs::create_dir_all(config.bin_dir()).await.unwrap();
326
327        handle_install(&config, Some("utils/shine-env-export"), false)
328            .await
329            .unwrap();
330        handle_install(&config, Some("utils/shine-theme-sync"), false)
331            .await
332            .unwrap();
333        handle_uninstall(&config, Some("utils/shine-env-export"), false, false)
334            .await
335            .unwrap();
336
337        let removed = crate::bin_links::command_path_for_name(
338            config.bin_dir(),
339            std::ffi::OsStr::new("shine-env-export"),
340        );
341        let sibling = crate::bin_links::command_path_for_name(
342            config.bin_dir(),
343            std::ffi::OsStr::new("shine-theme-sync"),
344        );
345        assert!(!removed.exists());
346        assert!(sibling.exists());
347
348        let manifest = crate::shells::deployment::ShellManifest::load(&config)
349            .await
350            .unwrap();
351        assert!(manifest.find("shell/utils/shine-env-export").is_none());
352        assert!(manifest.find("shell/utils/shine-theme-sync").is_some());
353
354        let profile = fs::read_to_string(managed_shell_profile_path(&config))
355            .await
356            .unwrap();
357        assert!(!profile.contains(&wrapper_marker("shine-env-export", &config.shell_type)));
358        assert!(profile.contains(&wrapper_marker("shine-theme-sync", &config.shell_type)));
359
360        fs::remove_dir_all(&dir).await.unwrap();
361    }
362
363    #[tokio::test]
364    async fn command_scoped_uninstall_preserves_shared_rendered_file() {
365        let dir = make_temp_dir().await;
366        let category = dir.join("presets/shell/custom");
367        fs::create_dir_all(&category).await.unwrap();
368        fs::write(
369            category.join("shine.toml"),
370            b"[[files]]\nsource = \"shared.sh\"\ntarget = \"one\"\ntransforms = [\"template\"]\n\n[[files]]\nsource = \"shared.sh\"\ntarget = \"two\"\ntransforms = [\"template\"]\n",
371        )
372        .await
373        .unwrap();
374        fs::write(category.join("shared.sh"), b"#!/bin/sh\necho shared\n")
375            .await
376            .unwrap();
377        let mut config = Config::new_for_test(&dir);
378        config.is_external_presets = true;
379        fs::create_dir_all(config.bin_dir()).await.unwrap();
380
381        handle_install(&config, Some("custom/one"), false)
382            .await
383            .unwrap();
384        handle_install(&config, Some("custom/two"), false)
385            .await
386            .unwrap();
387        let rendered = config.rendered_dir().join("shell/custom/shared.sh");
388        assert!(rendered.exists());
389
390        handle_uninstall(&config, Some("custom/one"), false, false)
391            .await
392            .unwrap();
393
394        assert!(
395            rendered.exists(),
396            "installed sibling still uses rendered file"
397        );
398        assert!(
399            crate::bin_links::command_path_for_name(config.bin_dir(), std::ffi::OsStr::new("two"))
400                .exists()
401        );
402        let manifest = crate::shells::deployment::ShellManifest::load(&config)
403            .await
404            .unwrap();
405        assert!(manifest.find("shell/custom/one").is_none());
406        assert_eq!(
407            manifest.find("shell/custom/two").unwrap().rendered_path,
408            rendered
409        );
410
411        fs::remove_dir_all(&dir).await.unwrap();
412    }
413
414    #[tokio::test]
415    async fn command_scoped_uninstall_dry_run_preserves_launcher_and_manifest() {
416        let dir = make_temp_dir().await;
417        let config = Config::new_for_test(&dir);
418        fs::create_dir_all(config.bin_dir()).await.unwrap();
419        handle_install(&config, Some("utils/shine-env-export"), false)
420            .await
421            .unwrap();
422
423        handle_uninstall(&config, Some("utils/shine-env-export"), false, true)
424            .await
425            .unwrap();
426
427        let command = crate::bin_links::command_path_for_name(
428            config.bin_dir(),
429            std::ffi::OsStr::new("shine-env-export"),
430        );
431        assert!(command.exists());
432        let manifest = crate::shells::deployment::ShellManifest::load(&config)
433            .await
434            .unwrap();
435        assert!(manifest.find("shell/utils/shine-env-export").is_some());
436
437        fs::remove_dir_all(&dir).await.unwrap();
438    }
439
440    #[tokio::test]
441    async fn command_scoped_uninstall_preserves_foreign_command_entry() {
442        let dir = make_temp_dir().await;
443        let config = Config::new_for_test(&dir);
444        fs::create_dir_all(config.bin_dir()).await.unwrap();
445        handle_install(&config, Some("utils/shine-env-export"), false)
446            .await
447            .unwrap();
448        let command = crate::bin_links::command_path_for_name(
449            config.bin_dir(),
450            std::ffi::OsStr::new("shine-env-export"),
451        );
452        crate::bin_links::unlink_managed_command(
453            config.bin_dir(),
454            std::ffi::OsStr::new("shine-env-export"),
455            &[config.presets_dir().join("shell/utils")],
456            false,
457        )
458        .await
459        .unwrap();
460        fs::write(&command, b"user-owned command\n").await.unwrap();
461
462        handle_uninstall(&config, Some("utils/shine-env-export"), false, false)
463            .await
464            .unwrap();
465
466        assert_eq!(
467            fs::read_to_string(&command).await.unwrap(),
468            "user-owned command\n"
469        );
470        let manifest = crate::shells::deployment::ShellManifest::load(&config)
471            .await
472            .unwrap();
473        assert!(manifest.find("shell/utils/shine-env-export").is_none());
474
475        fs::remove_dir_all(&dir).await.unwrap();
476    }
477
478    fn wrapper_marker(command: &str, shell: &ShellType) -> String {
479        match shell {
480            ShellType::PowerShell => format!("\nfunction {command} {{ . (Join-Path $shineBin"),
481            ShellType::Fish => format!("\nfunction {command}"),
482            _ => format!("\n{command}() {{ source"),
483        }
484    }
485
486    #[cfg(unix)]
487    #[tokio::test]
488    async fn uninstall_purge_removes_managed_dirs_but_not_config() {
489        let dir = make_temp_dir().await;
490        let config = Config::new_for_test(&dir);
491        fs::create_dir_all(config.presets_dir()).await.unwrap();
492        fs::create_dir_all(config.bin_dir()).await.unwrap();
493
494        handle_install(&config, None, false).await.unwrap();
495        handle_uninstall(&config, None, true, false).await.unwrap();
496
497        assert!(!config.bin_dir().exists(), "bin_dir should be purged");
498        assert!(
499            !config.presets_dir().join("shell").exists(),
500            "shell presets dir should be purged"
501        );
502        // config.toml must never be removed by uninstall
503        assert!(
504            config.presets_dir().parent().is_some(),
505            "shine root still accessible"
506        );
507
508        fs::remove_dir_all(&dir).await.unwrap();
509    }
510
511    #[cfg(unix)]
512    #[tokio::test]
513    async fn uninstall_dry_run_leaves_everything_intact() {
514        let dir = make_temp_dir().await;
515        let config = Config::new_for_test(&dir);
516        fs::create_dir_all(config.presets_dir()).await.unwrap();
517        fs::create_dir_all(config.bin_dir()).await.unwrap();
518
519        handle_install(&config, None, false).await.unwrap();
520        let preset_path = config.presets_dir().join("shell/proxy/set_proxy.sh");
521        assert!(preset_path.exists());
522
523        handle_uninstall(&config, None, false, true).await.unwrap();
524
525        assert!(preset_path.exists(), "dry-run must not remove preset files");
526
527        fs::remove_dir_all(&dir).await.unwrap();
528    }
529
530    #[tokio::test]
531    async fn remove_clears_sentinel_from_shell_config() {
532        let dir = make_temp_dir().await;
533        let config = Config::new_for_test(&dir);
534
535        append_path_to_shell_config(&config, false, &[])
536            .await
537            .unwrap();
538        remove_path_from_shell_config(&config).await.unwrap();
539
540        let config_path =
541            super::super::get_shell_config_path(&config.shell_type, &config.home_dir).unwrap();
542        let content = fs::read_to_string(&config_path).await.unwrap();
543        assert!(
544            !content.contains(super::super::SENTINEL_START),
545            "sentinel should be gone after remove"
546        );
547    }
548
549    #[tokio::test]
550    async fn remove_is_no_op_when_config_missing() {
551        let dir = make_temp_dir().await;
552        let config = Config::new_for_test(&dir);
553        // No install — config file doesn't exist
554        remove_path_from_shell_config(&config).await.unwrap();
555    }
556
557    #[cfg(unix)]
558    #[tokio::test]
559    async fn uninstall_dry_run_does_not_modify_shell_config() {
560        let dir = make_temp_dir().await;
561        let config = Config::new_for_test(&dir);
562        fs::create_dir_all(config.presets_dir()).await.unwrap();
563        fs::create_dir_all(config.bin_dir()).await.unwrap();
564
565        handle_install(&config, None, false).await.unwrap();
566        let config_path =
567            super::super::get_shell_config_path(&config.shell_type, &config.home_dir).unwrap();
568        let before = fs::read_to_string(&config_path).await.unwrap();
569        let profile_path = managed_shell_profile_path(&config);
570        let profile_before = fs::read_to_string(&profile_path).await.unwrap();
571
572        handle_uninstall(&config, None, false, true).await.unwrap();
573
574        let after = fs::read_to_string(&config_path).await.unwrap();
575        assert_eq!(before, after, "dry-run must not touch shell config");
576        let profile_after = fs::read_to_string(&profile_path).await.unwrap();
577        assert_eq!(
578            profile_before, profile_after,
579            "dry-run must not touch managed shell profile"
580        );
581
582        fs::remove_dir_all(&dir).await.unwrap();
583    }
584
585    #[tokio::test]
586    async fn uninstall_category_keeps_agent_launcher_and_prunes_source_wrappers() {
587        let dir = make_temp_dir().await;
588        let config = Config::new_for_test(&dir);
589        fs::create_dir_all(config.presets_dir()).await.unwrap();
590        fs::create_dir_all(config.bin_dir()).await.unwrap();
591
592        handle_install(&config, Some("agent"), false).await.unwrap();
593        handle_install(&config, Some("proxy"), false).await.unwrap();
594
595        handle_uninstall(&config, Some("proxy"), false, false)
596            .await
597            .unwrap();
598
599        let profile = fs::read_to_string(managed_shell_profile_path(&config))
600            .await
601            .unwrap();
602        assert!(!profile.contains(&wrapper_marker("ccenv", &config.shell_type)));
603        assert!(
604            !profile.contains(&wrapper_marker("setproxy", &config.shell_type)),
605            "removed category wrapper should be pruned: {profile}"
606        );
607        assert!(
608            !profile.contains(&wrapper_marker("usetproxy", &config.shell_type)),
609            "removed category wrapper should be pruned: {profile}"
610        );
611        let ccenv = crate::bin_links::command_path_for_name(
612            config.bin_dir(),
613            std::ffi::OsStr::new("ccenv"),
614        );
615        assert!(ccenv.exists(), "remaining Bun launcher should be kept");
616
617        fs::remove_dir_all(&dir).await.unwrap();
618    }
619
620    #[cfg(unix)]
621    #[tokio::test]
622    async fn external_presets_uninstall_preserves_disk_scripts() {
623        let dir = make_temp_dir().await;
624        let cat_dir = dir.join("presets/shell/custom");
625        fs::create_dir_all(&cat_dir).await.unwrap();
626        let script = cat_dir.join("my_tool.sh");
627        fs::write(&script, b"#!/bin/bash\n# My tool.\necho hi\n")
628            .await
629            .unwrap();
630        use std::os::unix::fs::PermissionsExt;
631        let mut perms = fs::metadata(&script).await.unwrap().permissions();
632        perms.set_mode(perms.mode() | 0o111);
633        fs::set_permissions(&script, perms).await.unwrap();
634
635        let mut config = Config::new_for_test(&dir);
636        config.is_external_presets = true;
637        fs::create_dir_all(config.bin_dir()).await.unwrap();
638
639        handle_install(&config, Some("custom"), false)
640            .await
641            .unwrap();
642        assert!(config.bin_dir().join("my_tool").exists());
643
644        handle_uninstall(&config, Some("custom"), false, false)
645            .await
646            .unwrap();
647
648        // User-owned script must survive uninstall.
649        assert!(script.exists(), "user script must not be deleted");
650        // Bin symlink should be gone.
651        assert!(
652            !config.bin_dir().join("my_tool").exists(),
653            "bin link should be removed"
654        );
655
656        fs::remove_dir_all(&dir).await.unwrap();
657    }
658}