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!(config.bin_dir().join("two").exists());
399        let manifest = crate::shells::deployment::ShellManifest::load(&config)
400            .await
401            .unwrap();
402        assert!(manifest.find("shell/custom/one").is_none());
403        assert_eq!(
404            manifest.find("shell/custom/two").unwrap().rendered_path,
405            rendered
406        );
407
408        fs::remove_dir_all(&dir).await.unwrap();
409    }
410
411    #[tokio::test]
412    async fn command_scoped_uninstall_dry_run_preserves_launcher_and_manifest() {
413        let dir = make_temp_dir().await;
414        let config = Config::new_for_test(&dir);
415        fs::create_dir_all(config.bin_dir()).await.unwrap();
416        handle_install(&config, Some("utils/shine-env-export"), false)
417            .await
418            .unwrap();
419
420        handle_uninstall(&config, Some("utils/shine-env-export"), false, true)
421            .await
422            .unwrap();
423
424        let command = crate::bin_links::command_path_for_name(
425            config.bin_dir(),
426            std::ffi::OsStr::new("shine-env-export"),
427        );
428        assert!(command.exists());
429        let manifest = crate::shells::deployment::ShellManifest::load(&config)
430            .await
431            .unwrap();
432        assert!(manifest.find("shell/utils/shine-env-export").is_some());
433
434        fs::remove_dir_all(&dir).await.unwrap();
435    }
436
437    #[tokio::test]
438    async fn command_scoped_uninstall_preserves_foreign_command_entry() {
439        let dir = make_temp_dir().await;
440        let config = Config::new_for_test(&dir);
441        fs::create_dir_all(config.bin_dir()).await.unwrap();
442        handle_install(&config, Some("utils/shine-env-export"), false)
443            .await
444            .unwrap();
445        let command = crate::bin_links::command_path_for_name(
446            config.bin_dir(),
447            std::ffi::OsStr::new("shine-env-export"),
448        );
449        crate::bin_links::unlink_managed_command(
450            config.bin_dir(),
451            std::ffi::OsStr::new("shine-env-export"),
452            &[config.presets_dir().join("shell/utils")],
453            false,
454        )
455        .await
456        .unwrap();
457        fs::write(&command, b"user-owned command\n").await.unwrap();
458
459        handle_uninstall(&config, Some("utils/shine-env-export"), false, false)
460            .await
461            .unwrap();
462
463        assert_eq!(
464            fs::read_to_string(&command).await.unwrap(),
465            "user-owned command\n"
466        );
467        let manifest = crate::shells::deployment::ShellManifest::load(&config)
468            .await
469            .unwrap();
470        assert!(manifest.find("shell/utils/shine-env-export").is_none());
471
472        fs::remove_dir_all(&dir).await.unwrap();
473    }
474
475    fn wrapper_marker(command: &str, shell: &ShellType) -> String {
476        match shell {
477            ShellType::PowerShell => format!("\nfunction {command} {{ . (Join-Path $shineBin"),
478            ShellType::Fish => format!("\nfunction {command}"),
479            _ => format!("\n{command}() {{ source"),
480        }
481    }
482
483    #[cfg(unix)]
484    #[tokio::test]
485    async fn uninstall_purge_removes_managed_dirs_but_not_config() {
486        let dir = make_temp_dir().await;
487        let config = Config::new_for_test(&dir);
488        fs::create_dir_all(config.presets_dir()).await.unwrap();
489        fs::create_dir_all(config.bin_dir()).await.unwrap();
490
491        handle_install(&config, None, false).await.unwrap();
492        handle_uninstall(&config, None, true, false).await.unwrap();
493
494        assert!(!config.bin_dir().exists(), "bin_dir should be purged");
495        assert!(
496            !config.presets_dir().join("shell").exists(),
497            "shell presets dir should be purged"
498        );
499        // config.toml must never be removed by uninstall
500        assert!(
501            config.presets_dir().parent().is_some(),
502            "shine root still accessible"
503        );
504
505        fs::remove_dir_all(&dir).await.unwrap();
506    }
507
508    #[cfg(unix)]
509    #[tokio::test]
510    async fn uninstall_dry_run_leaves_everything_intact() {
511        let dir = make_temp_dir().await;
512        let config = Config::new_for_test(&dir);
513        fs::create_dir_all(config.presets_dir()).await.unwrap();
514        fs::create_dir_all(config.bin_dir()).await.unwrap();
515
516        handle_install(&config, None, false).await.unwrap();
517        let preset_path = config.presets_dir().join("shell/proxy/set_proxy.sh");
518        assert!(preset_path.exists());
519
520        handle_uninstall(&config, None, false, true).await.unwrap();
521
522        assert!(preset_path.exists(), "dry-run must not remove preset files");
523
524        fs::remove_dir_all(&dir).await.unwrap();
525    }
526
527    #[tokio::test]
528    async fn remove_clears_sentinel_from_shell_config() {
529        let dir = make_temp_dir().await;
530        let config = Config::new_for_test(&dir);
531
532        append_path_to_shell_config(&config, false, &[])
533            .await
534            .unwrap();
535        remove_path_from_shell_config(&config).await.unwrap();
536
537        let config_path =
538            super::super::get_shell_config_path(&config.shell_type, &config.home_dir).unwrap();
539        let content = fs::read_to_string(&config_path).await.unwrap();
540        assert!(
541            !content.contains(super::super::SENTINEL_START),
542            "sentinel should be gone after remove"
543        );
544    }
545
546    #[tokio::test]
547    async fn remove_is_no_op_when_config_missing() {
548        let dir = make_temp_dir().await;
549        let config = Config::new_for_test(&dir);
550        // No install — config file doesn't exist
551        remove_path_from_shell_config(&config).await.unwrap();
552    }
553
554    #[cfg(unix)]
555    #[tokio::test]
556    async fn uninstall_dry_run_does_not_modify_shell_config() {
557        let dir = make_temp_dir().await;
558        let config = Config::new_for_test(&dir);
559        fs::create_dir_all(config.presets_dir()).await.unwrap();
560        fs::create_dir_all(config.bin_dir()).await.unwrap();
561
562        handle_install(&config, None, false).await.unwrap();
563        let config_path =
564            super::super::get_shell_config_path(&config.shell_type, &config.home_dir).unwrap();
565        let before = fs::read_to_string(&config_path).await.unwrap();
566        let profile_path = managed_shell_profile_path(&config);
567        let profile_before = fs::read_to_string(&profile_path).await.unwrap();
568
569        handle_uninstall(&config, None, false, true).await.unwrap();
570
571        let after = fs::read_to_string(&config_path).await.unwrap();
572        assert_eq!(before, after, "dry-run must not touch shell config");
573        let profile_after = fs::read_to_string(&profile_path).await.unwrap();
574        assert_eq!(
575            profile_before, profile_after,
576            "dry-run must not touch managed shell profile"
577        );
578
579        fs::remove_dir_all(&dir).await.unwrap();
580    }
581
582    #[tokio::test]
583    async fn uninstall_category_keeps_agent_launcher_and_prunes_source_wrappers() {
584        let dir = make_temp_dir().await;
585        let config = Config::new_for_test(&dir);
586        fs::create_dir_all(config.presets_dir()).await.unwrap();
587        fs::create_dir_all(config.bin_dir()).await.unwrap();
588
589        handle_install(&config, Some("agent"), false).await.unwrap();
590        handle_install(&config, Some("proxy"), false).await.unwrap();
591
592        handle_uninstall(&config, Some("proxy"), false, false)
593            .await
594            .unwrap();
595
596        let profile = fs::read_to_string(managed_shell_profile_path(&config))
597            .await
598            .unwrap();
599        assert!(!profile.contains(&wrapper_marker("ccenv", &config.shell_type)));
600        assert!(
601            !profile.contains(&wrapper_marker("setproxy", &config.shell_type)),
602            "removed category wrapper should be pruned: {profile}"
603        );
604        assert!(
605            !profile.contains(&wrapper_marker("usetproxy", &config.shell_type)),
606            "removed category wrapper should be pruned: {profile}"
607        );
608        let ccenv = crate::bin_links::command_path_for_name(
609            config.bin_dir(),
610            std::ffi::OsStr::new("ccenv"),
611        );
612        assert!(ccenv.exists(), "remaining Bun launcher should be kept");
613
614        fs::remove_dir_all(&dir).await.unwrap();
615    }
616
617    #[cfg(unix)]
618    #[tokio::test]
619    async fn external_presets_uninstall_preserves_disk_scripts() {
620        let dir = make_temp_dir().await;
621        let cat_dir = dir.join("presets/shell/custom");
622        fs::create_dir_all(&cat_dir).await.unwrap();
623        let script = cat_dir.join("my_tool.sh");
624        fs::write(&script, b"#!/bin/bash\n# My tool.\necho hi\n")
625            .await
626            .unwrap();
627        use std::os::unix::fs::PermissionsExt;
628        let mut perms = fs::metadata(&script).await.unwrap().permissions();
629        perms.set_mode(perms.mode() | 0o111);
630        fs::set_permissions(&script, perms).await.unwrap();
631
632        let mut config = Config::new_for_test(&dir);
633        config.is_external_presets = true;
634        fs::create_dir_all(config.bin_dir()).await.unwrap();
635
636        handle_install(&config, Some("custom"), false)
637            .await
638            .unwrap();
639        assert!(config.bin_dir().join("my_tool").exists());
640
641        handle_uninstall(&config, Some("custom"), false, false)
642            .await
643            .unwrap();
644
645        // User-owned script must survive uninstall.
646        assert!(script.exists(), "user script must not be deleted");
647        // Bin symlink should be gone.
648        assert!(
649            !config.bin_dir().join("my_tool").exists(),
650            "bin link should be removed"
651        );
652
653        fs::remove_dir_all(&dir).await.unwrap();
654    }
655}