use super::install::installed_source_commands;
use super::profile::{
remove_managed_shell_profile, remove_path_from_shell_config, write_managed_shell_profile,
};
use super::report::{remove_report_summary_parts, unlink_report_summary_parts};
use crate::config::Config;
use crate::output;
use anyhow::{Context, Result, bail};
use std::ffi::OsStr;
pub async fn handle_uninstall(
config: &Config,
target: Option<&str>,
purge: bool,
dry_run: bool,
) -> Result<()> {
crate::config::print_presets_note(config);
if dry_run {
println!(
"{}",
crate::colors::dim("[dry-run] No files will be modified.")
);
}
let selection = target
.map(super::metadata::parse_lifecycle_target)
.transpose()?;
if let Some(selection) = selection
&& let Some(command) = selection.command
{
return handle_uninstall_command(config, selection.category, command, purge, dry_run).await;
}
let category = selection.map(|selection| selection.category);
let managed_presets_root = match category {
Some(cat) => config.presets_dir().join("shell").join(cat),
None => config.presets_dir().to_path_buf(),
};
let managed_rendered_root = match category {
Some(cat) => config.rendered_dir().join("shell").join(cat),
None => config.rendered_dir().join("shell"),
};
let prefix = match category {
Some(cat) => format!("shell/{cat}"),
None => "shell".to_owned(),
};
let unlink_presets =
crate::bin_links::unlink_managed(config.bin_dir(), &managed_presets_root, dry_run).await?;
let unlink_rendered =
crate::bin_links::unlink_managed(config.bin_dir(), &managed_rendered_root, dry_run).await?;
let managed_installed_root = match category {
Some(cat) => config.installed_shell_dir().join(cat),
None => config.installed_shell_dir(),
};
let unlink_installed =
crate::bin_links::unlink_managed(config.bin_dir(), &managed_installed_root, dry_run)
.await?;
let unlink_report = crate::bin_links::UnlinkReport {
removed: [
unlink_presets.removed,
unlink_rendered.removed,
unlink_installed.removed,
]
.concat(),
skipped: [
unlink_presets.skipped,
unlink_rendered.skipped,
unlink_installed.skipped,
]
.concat(),
};
output::summary_line("Bin Links", &unlink_report_summary_parts(&unlink_report));
if !config.is_external_presets {
let remove_report =
crate::presets::remove_prefix(&prefix, config.presets_dir(), dry_run).await?;
output::summary_line(
"Shell Presets",
&remove_report_summary_parts(&remove_report),
);
}
if purge && !dry_run && !config.is_external_presets {
let purge_dir = match category {
Some(cat) => config.presets_dir().join("shell").join(cat),
None => config.presets_dir().join("shell"),
};
if purge_dir.exists() {
tokio::fs::remove_dir_all(&purge_dir)
.await
.with_context(|| format!("removing presets directory: {purge_dir:?}"))?;
}
if category.is_none() {
let _ = tokio::fs::remove_dir(config.presets_dir()).await;
let _ = tokio::fs::remove_dir(config.bin_dir()).await;
}
println!(
" {} {}",
crate::colors::symbol("✓"),
crate::colors::dim("managed directories purged (if empty)"),
);
}
if !dry_run && managed_rendered_root.exists() {
tokio::fs::remove_dir_all(&managed_rendered_root)
.await
.with_context(|| {
format!("removing rendered dir: {}", managed_rendered_root.display())
})?;
}
if !dry_run && managed_installed_root.exists() {
tokio::fs::remove_dir_all(&managed_installed_root)
.await
.with_context(|| {
format!(
"removing installed shell snapshot: {}",
managed_installed_root.display()
)
})?;
}
if !dry_run {
let mut manifest = super::deployment::ShellManifest::load(config).await?;
if let Some(category) = category {
manifest.remove_category(category);
} else {
manifest.entries.clear();
}
manifest.save(config).await?;
}
if !dry_run {
if category.is_none() {
remove_path_from_shell_config(config).await?;
remove_managed_shell_profile(config).await?;
} else {
let remaining_source_commands = installed_source_commands(config).await?;
write_managed_shell_profile(config, &remaining_source_commands).await?;
}
}
Ok(())
}
async fn handle_uninstall_command(
config: &Config,
category: &str,
command: &str,
purge: bool,
dry_run: bool,
) -> Result<()> {
let mut manifest = super::deployment::ShellManifest::load(config).await?;
let canonical = format!("shell/{category}/{command}");
let manifest_entry = manifest.find(&canonical).cloned();
let active_target = match super::metadata::load_active_target(
config,
super::metadata::ShellTarget {
category,
command: Some(command),
},
)
.await
{
Ok(categories) => Some(categories),
Err(error) if manifest_entry.is_none() => return Err(error),
Err(_) => None,
};
let mut managed_roots = vec![
config.presets_dir().join("shell").join(category),
config.rendered_dir().join("shell").join(category),
config.installed_shell_dir().join(category),
];
if let Some(overlay) = config.active_presets_overlay_dir() {
managed_roots.push(overlay.join("shell").join(category));
}
if let Some(entry) = &manifest_entry {
managed_roots.push(entry.source_path.clone());
managed_roots.push(entry.rendered_path.clone());
}
let unlink_report = crate::bin_links::unlink_managed_command(
config.bin_dir(),
OsStr::new(command),
&managed_roots,
dry_run,
)
.await?;
if manifest_entry.is_none() && unlink_report.removed.is_empty() {
if unlink_report.skipped.is_empty() {
bail!("shell command is not installed: {category}/{command}");
}
bail!(
"shell command entry is not managed by Shine: {}",
unlink_report.skipped[0].display()
);
}
output::summary_line("Bin Links", &unlink_report_summary_parts(&unlink_report));
let other_manifest_entry = manifest
.entries
.iter()
.any(|entry| entry.category == category && entry.command != command);
let other_link_exists =
match super::metadata::load_active_categories(config, Some(category)).await {
Ok(categories) => categories
.iter()
.flat_map(|category| &category.files)
.any(|file| {
file.command_name != command
&& crate::bin_links::command_path_for_name(
config.bin_dir(),
OsStr::new(&file.command_name),
)
.exists()
}),
Err(_) => false,
};
let category_still_installed = other_manifest_entry || other_link_exists;
if !dry_run {
let rendered_path = manifest_entry
.as_ref()
.map(|entry| entry.rendered_path.clone())
.or_else(|| {
active_target.and_then(|categories| {
categories.first().and_then(|category| {
category.files.first().map(|file| {
super::deployment::rendered_path(
config,
&category.name,
&file.source_rel,
)
})
})
})
});
if let Some(path) = rendered_path
&& path.starts_with(config.rendered_dir())
&& !manifest.entries.iter().any(|entry| {
(entry.category != category || entry.command != command)
&& entry.rendered_path == path
})
{
remove_file_if_present(&path).await?;
}
manifest.remove_target(category, command);
manifest.save(config).await?;
}
if !category_still_installed {
if !config.is_external_presets {
let report = crate::presets::remove_prefix(
&format!("shell/{category}"),
config.presets_dir(),
dry_run,
)
.await?;
output::summary_line("Shell Presets", &remove_report_summary_parts(&report));
}
if !dry_run {
remove_dir_if_present(&config.rendered_dir().join("shell").join(category)).await?;
remove_dir_if_present(&config.installed_shell_dir().join(category)).await?;
}
}
if purge && !dry_run && !config.is_external_presets && !category_still_installed {
let _ = tokio::fs::remove_dir(config.presets_dir().join("shell")).await;
let _ = tokio::fs::remove_dir(config.presets_dir()).await;
let _ = tokio::fs::remove_dir(config.bin_dir()).await;
}
if !dry_run {
let remaining_source_commands = installed_source_commands(config).await?;
write_managed_shell_profile(config, &remaining_source_commands).await?;
}
Ok(())
}
async fn remove_file_if_present(path: &std::path::Path) -> Result<()> {
match tokio::fs::remove_file(path).await {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error).with_context(|| format!("removing {}", path.display())),
}
}
async fn remove_dir_if_present(path: &std::path::Path) -> Result<()> {
match tokio::fs::remove_dir_all(path).await {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error).with_context(|| format!("removing {}", path.display())),
}
}
#[cfg(test)]
mod tests {
use super::super::ShellType;
use super::super::install::handle_install;
use super::super::profile::{append_path_to_shell_config, managed_shell_profile_path};
use super::*;
use std::path::PathBuf;
use tokio::fs;
async fn make_temp_dir() -> PathBuf {
crate::test_support::make_temp_dir("shine-shell").await
}
#[tokio::test]
async fn command_scoped_uninstall_preserves_installed_sibling() {
let dir = make_temp_dir().await;
let config = Config::new_for_test(&dir);
fs::create_dir_all(config.bin_dir()).await.unwrap();
handle_install(&config, Some("utils/shine-env-export"), false)
.await
.unwrap();
handle_install(&config, Some("utils/shine-theme-sync"), false)
.await
.unwrap();
handle_uninstall(&config, Some("utils/shine-env-export"), false, false)
.await
.unwrap();
let removed = crate::bin_links::command_path_for_name(
config.bin_dir(),
std::ffi::OsStr::new("shine-env-export"),
);
let sibling = crate::bin_links::command_path_for_name(
config.bin_dir(),
std::ffi::OsStr::new("shine-theme-sync"),
);
assert!(!removed.exists());
assert!(sibling.exists());
let manifest = crate::shells::deployment::ShellManifest::load(&config)
.await
.unwrap();
assert!(manifest.find("shell/utils/shine-env-export").is_none());
assert!(manifest.find("shell/utils/shine-theme-sync").is_some());
let profile = fs::read_to_string(managed_shell_profile_path(&config))
.await
.unwrap();
assert!(!profile.contains(&wrapper_marker("shine-env-export", &config.shell_type)));
assert!(profile.contains(&wrapper_marker("shine-theme-sync", &config.shell_type)));
fs::remove_dir_all(&dir).await.unwrap();
}
#[tokio::test]
async fn command_scoped_uninstall_preserves_shared_rendered_file() {
let dir = make_temp_dir().await;
let category = dir.join("presets/shell/custom");
fs::create_dir_all(&category).await.unwrap();
fs::write(
category.join("shine.toml"),
b"[[files]]\nsource = \"shared.sh\"\ntarget = \"one\"\ntransforms = [\"template\"]\n\n[[files]]\nsource = \"shared.sh\"\ntarget = \"two\"\ntransforms = [\"template\"]\n",
)
.await
.unwrap();
fs::write(category.join("shared.sh"), b"#!/bin/sh\necho shared\n")
.await
.unwrap();
let mut config = Config::new_for_test(&dir);
config.is_external_presets = true;
fs::create_dir_all(config.bin_dir()).await.unwrap();
handle_install(&config, Some("custom/one"), false)
.await
.unwrap();
handle_install(&config, Some("custom/two"), false)
.await
.unwrap();
let rendered = config.rendered_dir().join("shell/custom/shared.sh");
assert!(rendered.exists());
handle_uninstall(&config, Some("custom/one"), false, false)
.await
.unwrap();
assert!(
rendered.exists(),
"installed sibling still uses rendered file"
);
assert!(config.bin_dir().join("two").exists());
let manifest = crate::shells::deployment::ShellManifest::load(&config)
.await
.unwrap();
assert!(manifest.find("shell/custom/one").is_none());
assert_eq!(
manifest.find("shell/custom/two").unwrap().rendered_path,
rendered
);
fs::remove_dir_all(&dir).await.unwrap();
}
#[tokio::test]
async fn command_scoped_uninstall_dry_run_preserves_launcher_and_manifest() {
let dir = make_temp_dir().await;
let config = Config::new_for_test(&dir);
fs::create_dir_all(config.bin_dir()).await.unwrap();
handle_install(&config, Some("utils/shine-env-export"), false)
.await
.unwrap();
handle_uninstall(&config, Some("utils/shine-env-export"), false, true)
.await
.unwrap();
let command = crate::bin_links::command_path_for_name(
config.bin_dir(),
std::ffi::OsStr::new("shine-env-export"),
);
assert!(command.exists());
let manifest = crate::shells::deployment::ShellManifest::load(&config)
.await
.unwrap();
assert!(manifest.find("shell/utils/shine-env-export").is_some());
fs::remove_dir_all(&dir).await.unwrap();
}
#[tokio::test]
async fn command_scoped_uninstall_preserves_foreign_command_entry() {
let dir = make_temp_dir().await;
let config = Config::new_for_test(&dir);
fs::create_dir_all(config.bin_dir()).await.unwrap();
handle_install(&config, Some("utils/shine-env-export"), false)
.await
.unwrap();
let command = crate::bin_links::command_path_for_name(
config.bin_dir(),
std::ffi::OsStr::new("shine-env-export"),
);
crate::bin_links::unlink_managed_command(
config.bin_dir(),
std::ffi::OsStr::new("shine-env-export"),
&[config.presets_dir().join("shell/utils")],
false,
)
.await
.unwrap();
fs::write(&command, b"user-owned command\n").await.unwrap();
handle_uninstall(&config, Some("utils/shine-env-export"), false, false)
.await
.unwrap();
assert_eq!(
fs::read_to_string(&command).await.unwrap(),
"user-owned command\n"
);
let manifest = crate::shells::deployment::ShellManifest::load(&config)
.await
.unwrap();
assert!(manifest.find("shell/utils/shine-env-export").is_none());
fs::remove_dir_all(&dir).await.unwrap();
}
fn wrapper_marker(command: &str, shell: &ShellType) -> String {
match shell {
ShellType::PowerShell => format!("\nfunction {command} {{ . (Join-Path $shineBin"),
ShellType::Fish => format!("\nfunction {command}"),
_ => format!("\n{command}() {{ source"),
}
}
#[cfg(unix)]
#[tokio::test]
async fn uninstall_purge_removes_managed_dirs_but_not_config() {
let dir = make_temp_dir().await;
let config = Config::new_for_test(&dir);
fs::create_dir_all(config.presets_dir()).await.unwrap();
fs::create_dir_all(config.bin_dir()).await.unwrap();
handle_install(&config, None, false).await.unwrap();
handle_uninstall(&config, None, true, false).await.unwrap();
assert!(!config.bin_dir().exists(), "bin_dir should be purged");
assert!(
!config.presets_dir().join("shell").exists(),
"shell presets dir should be purged"
);
assert!(
config.presets_dir().parent().is_some(),
"shine root still accessible"
);
fs::remove_dir_all(&dir).await.unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn uninstall_dry_run_leaves_everything_intact() {
let dir = make_temp_dir().await;
let config = Config::new_for_test(&dir);
fs::create_dir_all(config.presets_dir()).await.unwrap();
fs::create_dir_all(config.bin_dir()).await.unwrap();
handle_install(&config, None, false).await.unwrap();
let preset_path = config.presets_dir().join("shell/proxy/set_proxy.sh");
assert!(preset_path.exists());
handle_uninstall(&config, None, false, true).await.unwrap();
assert!(preset_path.exists(), "dry-run must not remove preset files");
fs::remove_dir_all(&dir).await.unwrap();
}
#[tokio::test]
async fn remove_clears_sentinel_from_shell_config() {
let dir = make_temp_dir().await;
let config = Config::new_for_test(&dir);
append_path_to_shell_config(&config, false, &[])
.await
.unwrap();
remove_path_from_shell_config(&config).await.unwrap();
let config_path =
super::super::get_shell_config_path(&config.shell_type, &config.home_dir).unwrap();
let content = fs::read_to_string(&config_path).await.unwrap();
assert!(
!content.contains(super::super::SENTINEL_START),
"sentinel should be gone after remove"
);
}
#[tokio::test]
async fn remove_is_no_op_when_config_missing() {
let dir = make_temp_dir().await;
let config = Config::new_for_test(&dir);
remove_path_from_shell_config(&config).await.unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn uninstall_dry_run_does_not_modify_shell_config() {
let dir = make_temp_dir().await;
let config = Config::new_for_test(&dir);
fs::create_dir_all(config.presets_dir()).await.unwrap();
fs::create_dir_all(config.bin_dir()).await.unwrap();
handle_install(&config, None, false).await.unwrap();
let config_path =
super::super::get_shell_config_path(&config.shell_type, &config.home_dir).unwrap();
let before = fs::read_to_string(&config_path).await.unwrap();
let profile_path = managed_shell_profile_path(&config);
let profile_before = fs::read_to_string(&profile_path).await.unwrap();
handle_uninstall(&config, None, false, true).await.unwrap();
let after = fs::read_to_string(&config_path).await.unwrap();
assert_eq!(before, after, "dry-run must not touch shell config");
let profile_after = fs::read_to_string(&profile_path).await.unwrap();
assert_eq!(
profile_before, profile_after,
"dry-run must not touch managed shell profile"
);
fs::remove_dir_all(&dir).await.unwrap();
}
#[tokio::test]
async fn uninstall_category_keeps_agent_launcher_and_prunes_source_wrappers() {
let dir = make_temp_dir().await;
let config = Config::new_for_test(&dir);
fs::create_dir_all(config.presets_dir()).await.unwrap();
fs::create_dir_all(config.bin_dir()).await.unwrap();
handle_install(&config, Some("agent"), false).await.unwrap();
handle_install(&config, Some("proxy"), false).await.unwrap();
handle_uninstall(&config, Some("proxy"), false, false)
.await
.unwrap();
let profile = fs::read_to_string(managed_shell_profile_path(&config))
.await
.unwrap();
assert!(!profile.contains(&wrapper_marker("ccenv", &config.shell_type)));
assert!(
!profile.contains(&wrapper_marker("setproxy", &config.shell_type)),
"removed category wrapper should be pruned: {profile}"
);
assert!(
!profile.contains(&wrapper_marker("usetproxy", &config.shell_type)),
"removed category wrapper should be pruned: {profile}"
);
let ccenv = crate::bin_links::command_path_for_name(
config.bin_dir(),
std::ffi::OsStr::new("ccenv"),
);
assert!(ccenv.exists(), "remaining Bun launcher should be kept");
fs::remove_dir_all(&dir).await.unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn external_presets_uninstall_preserves_disk_scripts() {
let dir = make_temp_dir().await;
let cat_dir = dir.join("presets/shell/custom");
fs::create_dir_all(&cat_dir).await.unwrap();
let script = cat_dir.join("my_tool.sh");
fs::write(&script, b"#!/bin/bash\n# My tool.\necho hi\n")
.await
.unwrap();
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&script).await.unwrap().permissions();
perms.set_mode(perms.mode() | 0o111);
fs::set_permissions(&script, perms).await.unwrap();
let mut config = Config::new_for_test(&dir);
config.is_external_presets = true;
fs::create_dir_all(config.bin_dir()).await.unwrap();
handle_install(&config, Some("custom"), false)
.await
.unwrap();
assert!(config.bin_dir().join("my_tool").exists());
handle_uninstall(&config, Some("custom"), false, false)
.await
.unwrap();
assert!(script.exists(), "user script must not be deleted");
assert!(
!config.bin_dir().join("my_tool").exists(),
"bin link should be removed"
);
fs::remove_dir_all(&dir).await.unwrap();
}
}