use crate::archive::extract_archive;
use crate::cache::{self, MetadataCache};
use crate::config::KopiConfig;
use crate::download::download_jdk;
use crate::error::{KopiError, Result};
use crate::models::distribution::Distribution;
use crate::models::metadata::JdkMetadata;
use crate::platform::{
get_current_architecture, get_current_os, get_platform_description, matches_foojay_libc_type,
};
use crate::security::verify_checksum;
use crate::shim::discovery::{discover_distribution_tools, discover_jdk_tools};
use crate::shim::installer::ShimInstaller;
use crate::storage::JdkRepository;
use crate::version::parser::VersionParser;
use log::{debug, info, trace, warn};
use std::str::FromStr;
use std::time::Duration;
pub struct InstallCommand<'a> {
config: &'a KopiConfig,
}
impl<'a> InstallCommand<'a> {
pub fn new(config: &'a KopiConfig) -> Result<Self> {
Ok(Self { config })
}
fn ensure_fresh_cache(&self, javafx_bundled: bool) -> Result<MetadataCache> {
let cache_path = self.config.metadata_cache_path()?;
let max_age = Duration::from_secs(self.config.cache.max_age_hours * 3600);
let should_refresh = if cache_path.exists() {
match cache::load_cache(&cache_path) {
Ok(cache) => {
if self.config.cache.auto_refresh {
cache.is_stale(max_age)
} else {
false
}
}
Err(e) => {
warn!("Failed to load existing cache: {e}");
true
}
}
} else {
debug!("No cache found, will fetch from API");
true
};
if should_refresh && self.config.cache.auto_refresh {
info!("Refreshing package cache...");
match cache::fetch_and_cache_metadata(javafx_bundled, self.config) {
Ok(cache) => Ok(cache),
Err(e) => {
if cache_path.exists() {
if let Ok(cache) = cache::load_cache(&cache_path) {
warn!("Failed to refresh cache: {e}. Using existing cache.");
return Ok(cache);
}
}
Err(KopiError::MetadataFetch(format!(
"Failed to fetch metadata: {e}"
)))
}
}
} else {
cache::load_cache(&cache_path)
}
}
pub fn execute(
&self,
version_spec: &str,
force: bool,
dry_run: bool,
no_progress: bool,
timeout_secs: Option<u64>,
javafx_bundled: bool,
) -> Result<()> {
info!("Installing JDK {version_spec}");
debug!(
"Install options: force={force}, dry_run={dry_run}, no_progress={no_progress}, \
timeout={timeout_secs:?}, javafx_bundled={javafx_bundled}"
);
let parser = VersionParser::new(self.config);
let version_request = parser.parse(version_spec)?;
trace!("Parsed version request: {version_request:?}");
let version = version_request.version.as_ref().ok_or_else(|| {
KopiError::InvalidVersionFormat(
"Install command requires a specific version. Use 'kopi cache search' to browse \
available versions."
.to_string(),
)
})?;
VersionParser::validate_version_semantics(version)?;
let distribution = if let Some(dist) = version_request.distribution.clone() {
dist
} else {
Distribution::from_str(&self.config.default_distribution)
.unwrap_or(Distribution::Temurin)
};
println!("Installing {} {}...", distribution.name(), version);
debug!("Searching for {} version {}", distribution.name(), version);
let package =
self.find_matching_package(&distribution, version, &version_request, javafx_bundled)?;
trace!("Found package: {package:?}");
let jdk_metadata = self.convert_package_to_metadata(package.clone())?;
let repository = JdkRepository::new(self.config);
let installation_dir = repository.jdk_install_path(
&distribution,
&jdk_metadata.distribution_version.to_string(),
)?;
if dry_run {
println!(
"Would install {} {} to {}",
distribution.name(),
jdk_metadata.distribution_version,
installation_dir.display()
);
return Ok(());
}
if installation_dir.exists() && !force {
return Err(KopiError::AlreadyExists(format!(
"{} {} is already installed. Use --force to reinstall.",
distribution.name(),
jdk_metadata.distribution_version
)));
}
if jdk_metadata.distribution.to_lowercase() != distribution.id() {
warn!(
"Requested {} but found {} package",
distribution.name(),
jdk_metadata.distribution
);
}
let mut jdk_metadata_with_checksum = jdk_metadata.clone();
if jdk_metadata_with_checksum.checksum.is_none() {
debug!(
"Fetching checksum for package ID: {}",
jdk_metadata_with_checksum.id
);
match crate::cache::fetch_package_checksum(&jdk_metadata_with_checksum.id, self.config)
{
Ok((checksum, checksum_type)) => {
info!("Fetched checksum: {checksum} (type: {checksum_type:?})");
jdk_metadata_with_checksum.checksum = Some(checksum);
jdk_metadata_with_checksum.checksum_type = Some(checksum_type);
}
Err(e) => {
warn!(
"Failed to fetch checksum: {e}. Proceeding without checksum verification."
);
}
}
}
println!(
"Downloading {} {} (id: {})...",
jdk_metadata_with_checksum.distribution,
jdk_metadata_with_checksum.version,
jdk_metadata_with_checksum.id
);
info!(
"Downloading from {}",
jdk_metadata_with_checksum
.download_url
.as_ref()
.unwrap_or(&"<URL not available>".to_string())
);
let download_result = download_jdk(&jdk_metadata_with_checksum, no_progress, timeout_secs)?;
let download_path = download_result.path();
debug!("Downloaded to {download_path:?}");
if let Some(checksum) = &jdk_metadata_with_checksum.checksum {
if let Some(checksum_type) = jdk_metadata_with_checksum.checksum_type {
println!("Verifying checksum...");
verify_checksum(download_path, checksum, checksum_type)?;
}
}
let context = if force && installation_dir.exists() {
repository.remove_jdk(&installation_dir)?;
repository.prepare_jdk_installation(
&distribution,
&jdk_metadata_with_checksum.distribution_version.to_string(),
)?
} else {
repository.prepare_jdk_installation(
&distribution,
&jdk_metadata_with_checksum.distribution_version.to_string(),
)?
};
println!("Extracting archive...");
info!("Extracting archive to {:?}", context.temp_path);
extract_archive(download_path, &context.temp_path)?;
debug!("Extraction completed");
debug!("Finalizing installation");
let final_path = repository.finalize_installation(context)?;
info!("JDK installed to {final_path:?}");
repository.save_jdk_metadata(
&distribution,
&jdk_metadata_with_checksum.distribution_version.to_string(),
&package,
)?;
println!(
"Successfully installed {} {} to {}",
distribution.name(),
jdk_metadata_with_checksum.distribution_version,
final_path.display()
);
if self.config.shims.auto_create_shims {
debug!("Auto-creating shims for newly installed JDK");
let mut tools = discover_jdk_tools(&final_path)?;
debug!("Discovered {} standard JDK tools", tools.len());
let extra_tools = discover_distribution_tools(&final_path, Some(distribution.id()))?;
if !extra_tools.is_empty() {
debug!(
"Discovered {} distribution-specific tools",
extra_tools.len()
);
tools.extend(extra_tools);
}
if !tools.is_empty() {
println!("\nCreating shims...");
let shim_installer = ShimInstaller::new(self.config.kopi_home());
let created_shims = shim_installer.create_missing_shims(&tools)?;
if !created_shims.is_empty() {
println!("Created {} new shims:", created_shims.len());
for shim in &created_shims {
println!(" - {shim}");
}
} else {
debug!("All shims already exist");
}
}
}
if VersionParser::is_lts_version(version.major()) {
println!(
"Note: {} is an LTS (Long Term Support) version.",
version.major()
);
}
println!("\nTo use this JDK, run: kopi use {version_spec}");
Ok(())
}
fn find_matching_package(
&self,
distribution: &Distribution,
version: &crate::version::Version,
version_request: &crate::version::parser::ParsedVersionRequest,
javafx_bundled: bool,
) -> Result<crate::models::api::Package> {
let arch = get_current_architecture();
let os = get_current_os();
let mut cache = self.ensure_fresh_cache(javafx_bundled)?;
if let Some(mut jdk_metadata) = cache.lookup(
distribution,
&version.to_string(),
&arch,
&os,
version_request.package_type.as_ref(),
Some(javafx_bundled),
) {
debug!(
"Found exact package match: {} {}",
distribution.name(),
version
);
if !jdk_metadata.is_complete() {
debug!("Metadata is incomplete, fetching package details...");
let provider = crate::metadata::MetadataProvider::from_config(self.config)?;
provider.ensure_complete(&mut jdk_metadata)?;
}
return Ok(self.convert_metadata_to_package(&jdk_metadata));
}
if self.config.cache.refresh_on_miss {
info!("Package not found in cache, refreshing...");
match cache::fetch_and_cache_metadata(javafx_bundled, self.config) {
Ok(new_cache) => {
cache = new_cache;
if let Some(mut jdk_metadata) = cache.lookup(
distribution,
&version.to_string(),
&arch,
&os,
version_request.package_type.as_ref(),
Some(javafx_bundled),
) {
debug!(
"Found package after refresh: {} {}",
distribution.name(),
version
);
if !jdk_metadata.is_complete() {
debug!("Metadata is incomplete, fetching package details...");
let provider =
crate::metadata::MetadataProvider::from_config(self.config)?;
provider.ensure_complete(&mut jdk_metadata)?;
}
return Ok(self.convert_metadata_to_package(&jdk_metadata));
}
}
Err(e) => {
warn!("Failed to refresh cache on miss: {e}");
}
}
}
let available_versions = cache
.distributions
.get(distribution.id())
.map(|dist| {
let mut versions: Vec<String> = dist
.packages
.iter()
.filter(|pkg| {
pkg.architecture.to_string() == arch
&& pkg.operating_system.to_string() == os
})
.map(|pkg| pkg.version.to_string())
.collect();
versions.sort();
versions.dedup();
versions
})
.unwrap_or_default();
Err(KopiError::VersionNotAvailable(format!(
"{} {} not found. Available versions: {}",
distribution.name(),
version,
if available_versions.is_empty() {
"none for your platform".to_string()
} else {
available_versions.join(", ")
}
)))
}
fn convert_package_to_metadata(
&self,
package: crate::models::api::Package,
) -> Result<JdkMetadata> {
let arch = get_current_architecture();
let os = get_current_os();
if let Some(ref lib_c_type) = package.lib_c_type {
if !matches_foojay_libc_type(lib_c_type) {
return Err(KopiError::VersionNotAvailable(format!(
"JDK lib_c_type '{}' is not compatible with kopi's platform '{}'",
lib_c_type,
get_platform_description()
)));
}
}
Ok(JdkMetadata {
id: package.id,
distribution: package.distribution.clone(),
version: crate::version::Version::from_str(&package.java_version)?,
distribution_version: crate::version::Version::from_str(&package.distribution_version)
.unwrap_or_else(|_| {
crate::version::Version::from_str(&package.java_version)
.unwrap_or(crate::version::Version::new(package.major_version, 0, 0))
}),
architecture: crate::models::platform::Architecture::from_str(&arch)?,
operating_system: crate::models::platform::OperatingSystem::from_str(&os)?,
package_type: crate::models::package::PackageType::from_str(&package.package_type)?,
archive_type: crate::models::package::ArchiveType::from_str(&package.archive_type)?,
download_url: Some(package.links.pkg_download_redirect),
checksum: None, checksum_type: None,
size: package.size,
lib_c_type: package.lib_c_type,
javafx_bundled: package.javafx_bundled,
term_of_support: package.term_of_support,
release_status: package.release_status,
latest_build_available: package.latest_build_available,
})
}
fn convert_metadata_to_package(&self, metadata: &JdkMetadata) -> crate::models::api::Package {
let pkg_info_uri = format!("https://api.foojay.io/disco/v3.0/packages/{}", metadata.id);
crate::models::api::Package {
id: metadata.id.clone(),
archive_type: metadata.archive_type.to_string(),
distribution: metadata.distribution.clone(),
major_version: metadata.version.major(),
java_version: metadata.version.to_string(),
distribution_version: metadata.distribution_version.to_string(),
jdk_version: metadata.version.major(),
directly_downloadable: true,
filename: format!(
"{}-{}-{}-{}.{}",
metadata.distribution,
metadata.version,
metadata.operating_system,
metadata.architecture,
metadata.archive_type.extension()
),
links: crate::models::api::Links {
pkg_download_redirect: metadata.download_url.clone().unwrap_or_default(),
pkg_info_uri: Some(pkg_info_uri),
},
free_use_in_production: true,
tck_tested: "unknown".to_string(),
size: metadata.size,
operating_system: metadata.operating_system.to_string(),
lib_c_type: metadata.lib_c_type.clone(),
package_type: metadata.package_type.to_string(),
javafx_bundled: metadata.javafx_bundled,
term_of_support: None,
release_status: None,
latest_build_available: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::KopiConfig;
use crate::error::KopiError;
#[test]
fn test_parse_version_spec() {
let config = KopiConfig::new(std::env::temp_dir()).unwrap();
let cmd = InstallCommand::new(&config);
assert!(cmd.is_ok());
let parser = VersionParser::new(&config);
let version_request = parser.parse("21").unwrap();
assert!(version_request.version.is_some());
assert_eq!(version_request.version.unwrap().major(), 21);
assert_eq!(version_request.distribution, None);
}
#[test]
fn test_dry_run_prevents_installation() {
let config = KopiConfig::new(std::env::temp_dir()).unwrap();
let cmd = InstallCommand::new(&config);
assert!(cmd.is_ok());
}
#[test]
fn test_parse_version_with_distribution() {
let config = KopiConfig::new(std::env::temp_dir()).unwrap();
let parser = VersionParser::new(&config);
let version_request = parser.parse("corretto@17").unwrap();
assert!(version_request.version.is_some());
assert_eq!(version_request.version.unwrap().major(), 17);
assert_eq!(version_request.distribution, Some(Distribution::Corretto));
}
#[test]
fn test_get_current_architecture() {
let arch = get_current_architecture();
assert!(!arch.is_empty());
assert_ne!(arch, "unknown");
}
#[test]
fn test_get_current_os() {
let os = get_current_os();
assert!(!os.is_empty());
assert_ne!(os, "unknown");
}
#[test]
fn test_convert_metadata_to_package() {
use crate::models::package::{ArchiveType, ChecksumType, PackageType};
use crate::models::platform::{Architecture, OperatingSystem};
use crate::version::Version;
use std::str::FromStr;
let config = KopiConfig::new(std::env::temp_dir()).unwrap();
let cmd = InstallCommand::new(&config).unwrap();
let metadata = JdkMetadata {
id: "test-id".to_string(),
distribution: "temurin".to_string(),
version: Version::new(21, 0, 1),
distribution_version: Version::from_str("21.0.1+12").unwrap(),
architecture: Architecture::X64,
operating_system: OperatingSystem::Linux,
package_type: PackageType::Jdk,
archive_type: ArchiveType::TarGz,
download_url: Some("https://example.com/download".to_string()),
checksum: Some("abc123".to_string()),
checksum_type: Some(ChecksumType::Sha256),
size: 100000000,
lib_c_type: None,
javafx_bundled: false,
term_of_support: None,
release_status: None,
latest_build_available: None,
};
let package = cmd.convert_metadata_to_package(&metadata);
assert_eq!(package.id, "test-id");
assert_eq!(package.distribution, "temurin");
assert_eq!(package.major_version, 21);
assert_eq!(package.java_version, "21.0.1");
assert_eq!(package.distribution_version, "21.0.1+12");
assert_eq!(package.archive_type, "tar.gz");
assert_eq!(package.operating_system, "linux");
assert_eq!(package.size, 100000000);
assert!(package.directly_downloadable);
}
#[test]
fn test_invalid_version_format_error() {
let config = KopiConfig::new(std::env::temp_dir()).unwrap();
let parser = VersionParser::new(&config);
let result = parser.parse("@@@invalid");
assert!(result.is_err());
match result {
Err(KopiError::InvalidVersionFormat(_)) => {}
_ => panic!("Expected InvalidVersionFormat error"),
}
}
#[test]
fn test_version_not_available_error() {
let error = KopiError::VersionNotAvailable("temurin 999".to_string());
let error_str = error.to_string();
assert!(error_str.contains("not available"));
}
#[test]
fn test_already_exists_error() {
let error = KopiError::AlreadyExists("temurin 21 is already installed".to_string());
let error_str = error.to_string();
assert!(error_str.contains("already installed"));
}
#[test]
fn test_network_error_handling() {
let error = KopiError::NetworkError("Connection timeout".to_string());
let error_str = error.to_string();
assert!(error_str.contains("Network error"));
}
#[test]
fn test_permission_denied_error() {
let error = KopiError::PermissionDenied("/opt/kopi".to_string());
let error_str = error.to_string();
assert!(error_str.contains("Permission denied"));
}
#[test]
fn test_disk_space_error() {
let error = KopiError::DiskSpaceError("Only 100MB available, need 500MB".to_string());
let error_str = error.to_string();
assert!(error_str.contains("disk space"));
}
#[test]
fn test_checksum_mismatch_error() {
let error = KopiError::ChecksumMismatch;
let error_str = error.to_string();
assert!(error_str.contains("Checksum verification failed"));
}
}