use crate::config::KopiConfig;
use crate::error::{KopiError, Result};
use crate::indicator::{ProgressConfig, ProgressFactory, ProgressStyle, StatusReporter};
use crate::locking::{InstalledScopeResolver, LockBackend, LockController, ScopedPackageLockGuard};
use crate::platform;
use crate::storage::formatting::format_size;
use crate::storage::{InstalledJdk, JdkRepository};
use crate::uninstall::cleanup::UninstallCleanup;
use crate::uninstall::error_formatting::format_multiple_jdk_matches_error;
use crate::uninstall::progress::ProgressReporter;
use log::{debug, info, warn};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use std::time::Duration;
pub mod batch;
pub mod cleanup;
pub mod error_formatting;
pub mod feedback;
pub mod post_check;
pub mod progress;
pub mod safety;
pub mod selection;
pub struct UninstallHandler<'a> {
config: &'a KopiConfig,
repository: &'a JdkRepository<'a>,
no_progress: bool,
}
impl<'a> UninstallHandler<'a> {
pub fn new(repository: &'a JdkRepository<'a>, no_progress: bool) -> Self {
let config = repository.config();
Self {
config,
repository,
no_progress,
}
}
pub fn recover_from_failures(&self, force: bool) -> Result<()> {
let lock_feedback = Arc::new(Mutex::new(ProgressFactory::create(self.no_progress)));
if let Ok(mut indicator) = lock_feedback.lock() {
indicator.start(ProgressConfig::new(ProgressStyle::Status));
}
let reporter = StatusReporter::with_shared_indicator(lock_feedback.clone());
let cleanup = UninstallCleanup::new(self.repository);
let actions = cleanup.detect_and_cleanup_partial_removals()?;
if actions.is_empty() {
reporter.step("No recovery actions needed.");
return Ok(());
}
reporter.operation(
"Found recovery actions",
&format!("{} actions", actions.len()),
);
for action in &actions {
reporter.step(&format!("- {action:?}"));
}
let result = cleanup.execute_cleanup(actions, force)?;
if result.is_success() {
reporter.success("Recovery completed successfully");
for success in result.successes {
reporter.step(&format!("✓ {success}"));
}
} else {
reporter.error("Recovery completed with errors");
for success in result.successes {
reporter.step(&format!("✓ {success}"));
}
for failure in result.failures {
reporter.step(&format!("✗ {failure}"));
}
}
Ok(())
}
pub fn uninstall_jdk(&self, version_spec: &str, force: bool, dry_run: bool) -> Result<()> {
info!("Uninstalling JDK {version_spec}");
let lock_feedback = Arc::new(Mutex::new(ProgressFactory::create(self.no_progress)));
if let Ok(mut indicator) = lock_feedback.lock() {
indicator.start(ProgressConfig::new(ProgressStyle::Status));
}
let reporter = StatusReporter::with_shared_indicator(lock_feedback.clone());
let jdks_to_remove = self.resolve_jdks_to_uninstall(version_spec)?;
if jdks_to_remove.is_empty() {
return Err(KopiError::JdkNotInstalled {
jdk_spec: version_spec.to_string(),
version: None,
distribution: None,
auto_install_enabled: false,
auto_install_failed: None,
user_declined: false,
install_in_progress: false,
});
}
let jdk = if jdks_to_remove.len() > 1 {
return Err(format_multiple_jdk_matches_error(
version_spec,
&jdks_to_remove,
));
} else {
jdks_to_remove.into_iter().next().unwrap()
};
let jdk_size = self.repository.get_jdk_size(&jdk.path)?;
if dry_run {
reporter.step(&format!(
"Would remove {}@{} ({})",
jdk.distribution,
jdk.version,
format_size(jdk_size)
));
return Ok(());
}
let controller = LockController::with_default_inspector(
self.config.kopi_home().to_path_buf(),
&self.config.locking,
);
let scope_resolver = InstalledScopeResolver::new(self.repository);
let lock_scope = scope_resolver.resolve(&jdk)?;
let scope_label = lock_scope.label();
reporter.step(&format!("Acquiring uninstall lock for {scope_label}"));
let acquisition =
controller.acquire_with_feedback(lock_scope.clone(), lock_feedback.clone())?;
let uninstall_lock_guard = ScopedPackageLockGuard::new(&controller, acquisition);
let backend_label = match uninstall_lock_guard.backend() {
LockBackend::Advisory => "advisory",
LockBackend::Fallback => "fallback",
};
info!("Uninstall lock acquired for {scope_label} using {backend_label} backend");
reporter.step(&format!("Using {backend_label} backend for {scope_label}"));
let active_summary =
safety::perform_safety_checks(self.config, self.repository, &jdk, force)?;
if force && active_summary.has_active_use() {
if let Some(global) = &active_summary.global {
warn!(
"--force removing {}@{} despite active global configuration {}",
jdk.distribution, jdk.version, global
);
reporter.step(&format!(
"Proceeding with --force: global default set via {global}"
));
}
if let Some(project) = &active_summary.project {
warn!(
"--force removing {}@{} despite active project configuration {}",
jdk.distribution, jdk.version, project
);
reporter.step(&format!(
"Proceeding with --force: project default set via {project}"
));
}
if !active_summary.processes.is_empty() {
let canonical_root = jdk.path.canonicalize().unwrap_or_else(|_| jdk.path.clone());
for process in &active_summary.processes {
let mut handles: Vec<String> = process
.handles
.iter()
.map(|handle| {
handle
.strip_prefix(&canonical_root)
.ok()
.map(|relative| {
let rendered = relative.display().to_string();
if rendered.is_empty() {
".".to_string()
} else {
rendered
}
})
.unwrap_or_else(|| handle.display().to_string())
})
.collect();
handles.sort();
let handle_summary = if handles.is_empty() {
"<no handles reported>".to_string()
} else {
handles.join(", ")
};
warn!(
"--force removing {}@{} despite PID {} ({}) with open handles: {}",
jdk.distribution,
jdk.version,
process.pid,
process.exe_path.display(),
handle_summary
);
reporter.step(&format!(
"Proceeding with --force: PID {} ({}) still open: {}",
process.pid,
process.exe_path.display(),
handle_summary
));
match platform::process::terminate_process(process.pid) {
Ok(()) => {
reporter.step(&format!(
"Terminated PID {} ({})",
process.pid,
process.exe_path.display()
));
}
Err(err) => {
warn!(
"Failed to terminate PID {} ({}): {err}",
process.pid,
process.exe_path.display()
);
reporter.step(&format!(
"Failed to terminate PID {} ({}): {err}",
process.pid,
process.exe_path.display()
));
}
}
}
std::thread::sleep(Duration::from_millis(200));
}
}
match self.remove_jdk_with_progress(&jdk, jdk_size) {
Ok(()) => {
uninstall_lock_guard.release()?;
reporter.success(&format!(
"Successfully uninstalled {}@{}",
jdk.distribution, jdk.version
));
reporter.step(&format!("Freed {} of disk space", format_size(jdk_size)));
Ok(())
}
Err(e) => {
warn!("Uninstall failed: {e}");
Err(e)
}
}
}
pub fn resolve_jdks_to_uninstall(&self, version_spec: &str) -> Result<Vec<InstalledJdk>> {
debug!("Resolving JDKs to uninstall for spec: {version_spec}");
use crate::version::VersionRequest;
let version_request = VersionRequest::from_str(version_spec)?;
self.repository.find_matching_jdks(&version_request)
}
fn remove_jdk_with_progress(&self, jdk: &InstalledJdk, size: u64) -> Result<()> {
info!("Removing JDK at {}", jdk.path.display());
let files_in_use = platform::file_ops::check_files_in_use(&jdk.path)?;
if !files_in_use.is_empty() {
warn!("Files may be in use:");
for file in &files_in_use {
warn!(" {file}");
}
}
if let Some(parent) = jdk.path.parent()
&& let Some(jdk_dir_name) = jdk.path.file_name().and_then(|n| n.to_str())
{
let meta_file = parent.join(format!("{jdk_dir_name}.meta.json"));
if meta_file.exists()
&& let Err(e) = std::fs::remove_file(&meta_file)
{
debug!(
"Failed to remove metadata file {}: {}",
meta_file.display(),
e
);
}
}
let pb = if size > 100 * 1024 * 1024 {
let mut progress_reporter = ProgressReporter::new(self.no_progress);
let pb = progress_reporter
.create_jdk_removal_spinner(&jdk.path.display().to_string(), &format_size(size));
pb.enable_steady_tick(Duration::from_millis(100));
Some(pb)
} else {
None
};
platform::file_ops::prepare_for_removal(&jdk.path)?;
let temp_path = self.prepare_atomic_removal(&jdk.path)?;
match self.finalize_removal(&temp_path) {
Ok(()) => {
if let Err(e) = platform::file_ops::post_removal_cleanup(&jdk.path) {
debug!("Post-removal cleanup failed: {e}");
}
if let Some(pb) = pb {
pb.finish_and_clear();
}
Ok(())
}
Err(e) => {
if let Err(rollback_err) = self.rollback_removal(&jdk.path, &temp_path) {
debug!("Failed to rollback removal: {rollback_err}");
}
if let Some(pb) = pb {
pb.finish_and_clear();
}
Err(e)
}
}
}
fn prepare_atomic_removal(&self, jdk_path: &PathBuf) -> Result<PathBuf> {
let parent = jdk_path.parent().ok_or_else(|| {
KopiError::SystemError("JDK path has no parent directory".to_string())
})?;
let temp_name = format!(
".{}.removing",
jdk_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
);
let temp_path = parent.join(temp_name);
std::fs::rename(jdk_path, &temp_path)?;
Ok(temp_path)
}
fn finalize_removal(&self, temp_path: &Path) -> Result<()> {
self.repository.remove_jdk(temp_path)
}
fn rollback_removal(&self, original_path: &PathBuf, temp_path: &PathBuf) -> Result<()> {
std::fs::rename(temp_path, original_path)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::KopiConfig;
use crate::locking::{
InstalledScopeResolver, LockController, LockTimeoutValue, ScopedPackageLockGuard,
};
use crate::paths::install;
use crate::version::Version;
use std::fs;
use std::str::FromStr;
use tempfile::TempDir;
struct TestSetup {
_temp_dir: TempDir,
config: KopiConfig,
}
impl TestSetup {
fn new() -> Self {
let temp_dir = TempDir::new().unwrap();
let config = KopiConfig::new(temp_dir.path().to_path_buf()).unwrap();
fs::create_dir_all(config.jdks_dir().unwrap()).unwrap();
TestSetup {
_temp_dir: temp_dir,
config,
}
}
fn create_mock_jdk(&self, distribution: &str, version: &str) -> PathBuf {
let jdk_path = self
.config
.jdks_dir()
.unwrap()
.join(format!("{distribution}-{version}"));
fs::create_dir_all(&jdk_path).unwrap();
fs::write(jdk_path.join("release"), "JAVA_VERSION=\"21\"").unwrap();
let bin_dir = install::bin_directory(&jdk_path);
fs::create_dir_all(&bin_dir).unwrap();
fs::write(bin_dir.join("java"), "#!/bin/sh\necho mock java").unwrap();
jdk_path
}
}
#[test]
fn test_resolve_jdks_by_version() {
let setup = TestSetup::new();
let repository = JdkRepository::new(&setup.config);
let handler = UninstallHandler::new(&repository, false);
setup.create_mock_jdk("temurin", "21.0.5+11");
setup.create_mock_jdk("temurin", "17.0.9+9");
setup.create_mock_jdk("corretto", "21.0.1");
let matches = handler.resolve_jdks_to_uninstall("21").unwrap();
assert_eq!(matches.len(), 2);
let matches = handler.resolve_jdks_to_uninstall("temurin@21").unwrap();
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].distribution, "temurin");
assert_eq!(matches[0].version.to_string(), "21.0.5+11");
let matches = handler.resolve_jdks_to_uninstall("11").unwrap();
assert!(matches.is_empty());
}
#[test]
fn test_atomic_removal() {
let setup = TestSetup::new();
let repository = JdkRepository::new(&setup.config);
let handler = UninstallHandler::new(&repository, false);
let jdk_path = setup.create_mock_jdk("temurin", "21.0.5+11");
assert!(jdk_path.exists());
let temp_path = handler.prepare_atomic_removal(&jdk_path).unwrap();
assert!(!jdk_path.exists());
assert!(temp_path.exists());
assert!(
temp_path
.file_name()
.unwrap()
.to_str()
.unwrap()
.starts_with(".")
);
handler.finalize_removal(&temp_path).unwrap();
assert!(!temp_path.exists());
}
#[test]
fn test_rollback_removal() {
let setup = TestSetup::new();
let repository = JdkRepository::new(&setup.config);
let handler = UninstallHandler::new(&repository, false);
let jdk_path = setup.create_mock_jdk("temurin", "21.0.5+11");
let original_exists = jdk_path.exists();
let temp_path = handler.prepare_atomic_removal(&jdk_path).unwrap();
assert!(!jdk_path.exists());
handler.rollback_removal(&jdk_path, &temp_path).unwrap();
assert_eq!(jdk_path.exists(), original_exists);
assert!(!temp_path.exists());
}
#[test]
fn uninstall_releases_lock_on_success() {
let setup = TestSetup::new();
let repository = JdkRepository::new(&setup.config);
let handler = UninstallHandler::new(&repository, true);
let jdk_path = setup.create_mock_jdk("temurin", "21.0.5+11");
handler
.uninstall_jdk("temurin@21.0.5+11", false, false)
.expect("uninstall should succeed");
assert!(!jdk_path.exists());
let controller = LockController::with_default_inspector(
setup.config.kopi_home().to_path_buf(),
&setup.config.locking,
);
let resolver = InstalledScopeResolver::new(&repository);
let installed = InstalledJdk::new(
"temurin".to_string(),
Version::from_str("21.0.5+11").unwrap(),
jdk_path.clone(),
false,
);
let scope = resolver.resolve(&installed).unwrap();
let reacquired = controller.try_acquire(scope).unwrap();
assert!(reacquired.is_some());
if let Some(handle) = reacquired {
controller.release(handle).unwrap();
}
}
#[test]
fn uninstall_errors_when_lock_times_out() {
let mut setup = TestSetup::new();
setup
.config
.locking
.set_timeout_value(LockTimeoutValue::from_secs(0));
let repository = JdkRepository::new(&setup.config);
let handler = UninstallHandler::new(&repository, true);
let jdk_path = setup.create_mock_jdk("temurin", "21.0.5+11");
let controller = LockController::with_default_inspector(
setup.config.kopi_home().to_path_buf(),
&setup.config.locking,
);
let resolver = InstalledScopeResolver::new(&repository);
let installed = InstalledJdk::new(
"temurin".to_string(),
Version::from_str("21.0.5+11").unwrap(),
jdk_path.clone(),
false,
);
let scope = resolver.resolve(&installed).unwrap();
let acquisition = controller.acquire(scope).unwrap();
let guard = ScopedPackageLockGuard::new(&controller, acquisition);
let result = handler.uninstall_jdk("temurin@21.0.5+11", false, false);
assert!(matches!(result, Err(KopiError::LockingTimeout { .. })));
assert!(jdk_path.exists());
guard.release().unwrap();
}
#[test]
fn uninstall_releases_lock_on_failure() {
let setup = TestSetup::new();
let repository = JdkRepository::new(&setup.config);
let handler = UninstallHandler::new(&repository, true);
let jdk_path = setup.create_mock_jdk("temurin", "21.0.5+11");
let removing_path = jdk_path.parent().unwrap().join(format!(
".{}.removing",
jdk_path.file_name().unwrap().to_str().unwrap()
));
fs::create_dir_all(&removing_path).unwrap();
fs::write(removing_path.join("marker"), "reserved").unwrap();
let result = handler.uninstall_jdk("temurin@21.0.5+11", false, false);
let err = result.expect_err("expected uninstall failure");
assert!(matches!(err, KopiError::Io(_)), "unexpected error: {err:?}");
assert!(jdk_path.exists());
let controller = LockController::with_default_inspector(
setup.config.kopi_home().to_path_buf(),
&setup.config.locking,
);
let resolver = InstalledScopeResolver::new(&repository);
let installed = InstalledJdk::new(
"temurin".to_string(),
Version::from_str("21.0.5+11").unwrap(),
jdk_path,
false,
);
let scope = resolver.resolve(&installed).unwrap();
let reacquired = controller.try_acquire(scope).unwrap();
assert!(reacquired.is_some());
if let Some(handle) = reacquired {
controller.release(handle).unwrap();
}
}
}