use crate::cache;
use crate::cache::get_current_platform;
use crate::config::KopiConfig;
use crate::error::Result;
use crate::indicator::{ProgressConfig, ProgressFactory, ProgressStyle as IndicatorStyle};
use crate::version::parser::VersionParser;
use chrono::Local;
use clap::Subcommand;
use colored::*;
use comfy_table::{Cell, CellAlignment, Color, Table};
use std::collections::{HashMap, HashSet};
#[derive(Subcommand, Debug)]
pub enum CacheCommand {
Refresh,
Info,
Clear,
Search {
version: String,
#[arg(long, conflicts_with_all = ["detailed", "json"])]
compact: bool,
#[arg(long, conflicts_with_all = ["compact", "json"])]
detailed: bool,
#[arg(long, conflicts_with_all = ["compact", "detailed"])]
json: bool,
#[arg(long)]
lts_only: bool,
#[arg(long, conflicts_with = "distribution_version")]
java_version: bool,
#[arg(long, conflicts_with = "java_version")]
distribution_version: bool,
},
ListDistributions,
}
#[derive(Debug)]
struct SearchOptions {
version_string: String,
compact: bool,
detailed: bool,
json: bool,
lts_only: bool,
force_java_version: bool,
force_distribution_version: bool,
}
impl CacheCommand {
pub fn execute(self, config: &KopiConfig, no_progress: bool) -> Result<()> {
match self {
CacheCommand::Refresh => refresh_cache(config, no_progress),
CacheCommand::Info => show_cache_info(config, no_progress),
CacheCommand::Clear => clear_cache(config, no_progress),
CacheCommand::Search {
version,
compact,
detailed,
json,
lts_only,
java_version,
distribution_version,
} => {
let options = SearchOptions {
version_string: version,
compact,
detailed,
json,
lts_only,
force_java_version: java_version,
force_distribution_version: distribution_version,
};
search_cache(options, config)
}
CacheCommand::ListDistributions => list_distributions(config),
}
}
}
fn refresh_cache(config: &KopiConfig, no_progress: bool) -> Result<()> {
let provider = crate::metadata::provider::MetadataProvider::from_config(config)?;
let total_steps = 5 + provider.source_count();
let mut progress = ProgressFactory::create(no_progress);
let progress_config = ProgressConfig::new(IndicatorStyle::Count).with_total(total_steps as u64);
progress.start(progress_config);
let mut current_step = 0u64;
current_step += 1;
progress.update(current_step, Some(total_steps as u64));
progress.set_message("Initializing metadata refresh...".to_string());
let cache = match cache::fetch_and_cache_metadata_with_progress(
config,
progress.as_mut(),
&mut current_step,
) {
Ok(cache) => cache,
Err(e) => {
progress.error(format!("Failed to refresh cache: {e}"));
return Err(e);
}
};
progress.complete(Some("Cache refreshed successfully".to_string()));
progress.success("Cache refreshed successfully")?;
let dist_count = cache.distributions.len();
progress.println(&format!("{dist_count} distributions available"))?;
let total_packages: usize = cache.distributions.values().map(|d| d.packages.len()).sum();
progress.println(&format!("{total_packages} total JDK packages"))?;
Ok(())
}
fn show_cache_info(config: &KopiConfig, _no_progress: bool) -> Result<()> {
let cache_path = config.metadata_cache_path()?;
if !cache_path.exists() {
println!("{} No cache found", "✗".red());
println!(
"\n{}: Run {} to populate the cache with available JDK versions.",
"Solution".yellow().bold(),
"'kopi cache refresh'".cyan()
);
return Ok(());
}
let cache = cache::load_cache(&cache_path)?;
let metadata = std::fs::metadata(&cache_path)?;
let file_size = metadata.len();
println!("Cache Information:");
println!(" Location: {}", cache_path.display());
println!(" Size: {} KB", file_size / 1024);
println!(
" Last updated: {}",
cache
.last_updated
.with_timezone(&Local)
.format("%Y-%m-%d %H:%M:%S")
);
println!(" Distributions: {}", cache.distributions.len());
let total_packages: usize = cache.distributions.values().map(|d| d.packages.len()).sum();
println!(" Total JDK packages: {total_packages}");
Ok(())
}
fn clear_cache(config: &KopiConfig, no_progress: bool) -> Result<()> {
let cache_path = config.metadata_cache_path()?;
let progress = ProgressFactory::create(no_progress);
if cache_path.exists() {
std::fs::remove_file(&cache_path)?;
progress.success("Cache cleared successfully")?;
} else {
progress.println("No cache to clear")?;
}
Ok(())
}
fn search_cache(options: SearchOptions, config: &KopiConfig) -> Result<()> {
let SearchOptions {
version_string,
compact: _compact,
detailed,
json,
lts_only,
force_java_version,
force_distribution_version,
} = options;
let cache_path = config.metadata_cache_path()?;
let mut cache = if cache_path.exists() {
cache::load_cache(&cache_path)?
} else {
cache::MetadataCache::new()
};
let parser = VersionParser::new(config);
let parsed_request = match parser.parse(&version_string) {
Ok(req) => req,
Err(e) => {
if json {
println!("[]");
} else {
println!("{} {}", "✗".red(), e);
println!("\n{}", "Examples:".yellow().bold());
println!(
" {} - Search for Java 21 across all distributions",
"kopi cache search 21".cyan()
);
println!(
" {} - Search for specific distribution and version",
"kopi cache search corretto@21".cyan()
);
println!(
" {} - List all versions of a distribution",
"kopi cache search corretto".cyan()
);
println!(
" {} - Show latest version of each distribution",
"kopi cache search latest".cyan()
);
println!(
" {} - Search for JRE packages only",
"kopi cache search jre@17".cyan()
);
println!(
"\n{}: Use {} to see all available distributions",
"Tip".yellow().bold(),
"'kopi cache list-distributions'".cyan()
);
}
return Ok(());
}
};
if let Some(ref dist) = parsed_request.distribution {
let dist_id = dist.id();
let canonical_name = cache.get_canonical_name(dist_id).unwrap_or(dist_id);
if !cache.distributions.contains_key(canonical_name) {
if !json {
println!(
"Distribution '{dist_id}' not found in cache. Fetching from configured sources..."
);
}
let mut progress = crate::indicator::SilentProgress;
let mut current_step = 0u64;
match cache::fetch_and_cache_distribution(
canonical_name,
config,
&mut progress,
&mut current_step,
) {
Ok(updated_cache) => {
cache = updated_cache;
if !json {
println!(
"{} Distribution '{}' cached successfully",
"✓".green().bold(),
dist_id.cyan()
);
}
}
Err(e) => {
if json {
println!("[]");
} else {
println!(
"{} Failed to fetch distribution '{}': {}",
"✗".red(),
dist_id,
e
);
}
return Ok(());
}
}
}
}
let version_type = if force_java_version {
crate::cache::VersionSearchType::JavaVersion
} else if force_distribution_version {
crate::cache::VersionSearchType::DistributionVersion
} else {
crate::cache::VersionSearchType::Auto
};
let mut results = cache.search(&parsed_request, version_type)?;
if lts_only {
results.retain(|result| {
result
.package
.term_of_support
.as_ref()
.map(|tos| tos.to_lowercase() == "lts")
.unwrap_or(false)
});
}
if results.is_empty() {
if json {
println!("[]");
} else {
if lts_only {
println!(
"{} No matching LTS Java versions found for '{}'",
"✗".red(),
version_string.bright_blue()
);
} else {
println!(
"{} No matching Java versions found for '{}'",
"✗".red(),
version_string.bright_blue()
);
}
println!("\n{}", "Common causes:".yellow().bold());
println!(" - The cache might be outdated");
println!(" - The version might not exist");
println!(" - The distribution name might be incorrect");
println!("\n{}", "Try these:".yellow().bold());
println!(
" 1. {} - Update the cache with latest versions",
"kopi cache refresh".cyan()
);
println!(
" 2. {} - See all available distributions",
"kopi cache list-distributions".cyan()
);
println!(
" 3. {} - List all versions of a specific distribution",
"kopi cache search <distribution>".cyan()
);
}
return Ok(());
}
if json {
let json_output = serde_json::to_string_pretty(&results)?;
println!("{json_output}");
return Ok(());
}
let result_count = results.len();
if lts_only {
println!(
"Found {} LTS Java version{} matching '{}':\n",
result_count.to_string().cyan(),
if result_count == 1 { "" } else { "s" },
version_string.bright_blue()
);
} else {
println!(
"Found {} Java version{} matching '{}':\n",
result_count.to_string().cyan(),
if result_count == 1 { "" } else { "s" },
version_string.bright_blue()
);
}
let (current_arch, current_os, _) = get_current_platform();
let mut grouped: HashMap<String, Vec<_>> = HashMap::new();
for result in results {
grouped
.entry(result.distribution.clone())
.or_default()
.push(result);
}
let mut dist_names: Vec<String> = grouped.keys().cloned().collect();
dist_names.sort();
let has_javafx = grouped
.values()
.any(|results| results.iter().any(|r| r.package.javafx_bundled));
let mut table = Table::new();
table.load_preset(comfy_table::presets::UTF8_BORDERS_ONLY);
let mut headers = if detailed {
vec![
Cell::new("Distribution"),
Cell::new("Version"),
Cell::new("LTS"),
Cell::new("Status"),
Cell::new("Type"),
Cell::new("OS/Arch"),
Cell::new("LibC"),
Cell::new("Size"),
]
} else {
vec![
Cell::new("Distribution"),
Cell::new("Version"),
Cell::new("LTS"),
]
};
if has_javafx {
headers.push(Cell::new("JavaFX"));
}
table.set_header(headers);
let mut is_first_distribution = true;
for dist_name in dist_names {
if let Some(results) = grouped.get(&dist_name) {
let display_name = results
.first()
.map(|r| r.display_name.as_str())
.unwrap_or(&dist_name);
if !is_first_distribution {
let num_cols = if detailed {
8 + if has_javafx { 1 } else { 0 }
} else {
3 + if has_javafx { 1 } else { 0 }
};
let separator_row: Vec<Cell> =
(0..num_cols).map(|_| Cell::new("SEPARATOR")).collect();
table.add_row(separator_row);
}
is_first_distribution = false;
let mut sorted_results = results.clone();
sorted_results.sort_by(|a, b| {
use crate::models::package::PackageType;
if detailed {
match a.package.size.cmp(&b.package.size) {
std::cmp::Ordering::Equal => {} other => return other,
}
}
if let Some(ref requested_type) = parsed_request.package_type {
match (
a.package.package_type == *requested_type,
b.package.package_type == *requested_type,
) {
(true, false) => return std::cmp::Ordering::Less,
(false, true) => return std::cmp::Ordering::Greater,
_ => {} }
}
if parsed_request.package_type.is_none() {
match (a.package.package_type, b.package.package_type) {
(PackageType::Jdk, PackageType::Jre) => return std::cmp::Ordering::Less,
(PackageType::Jre, PackageType::Jdk) => return std::cmp::Ordering::Greater,
_ => {} }
}
b.package.version.cmp(&a.package.version)
});
let mut seen_compact_entries = HashSet::new();
let mut seen_detailed_entries = HashSet::new();
let mut is_first_row_in_distribution = true;
for result in sorted_results {
let package = &result.package;
let show_package = package.architecture.to_string() == current_arch
&& package.operating_system.to_string() == current_os;
if show_package {
let display_version = if package.version.build.is_some() {
format!("{} ({})", package.version.major(), package.version)
} else if package.version.patch().map(|p| p > 0).unwrap_or(false) {
format!(
"{}.{}.{}",
package.version.major(),
package.version.minor().unwrap_or(0),
package.version.patch().unwrap_or(0)
)
} else if package.version.minor().map(|m| m > 0).unwrap_or(false) {
format!(
"{}.{}",
package.version.major(),
package.version.minor().unwrap_or(0)
)
} else {
format!("{}", package.version.major())
};
let size_display = if package.size < 0 {
"Unknown".to_string()
} else {
format!("{} MB", package.size / (1024 * 1024))
};
let lts_display = package
.term_of_support
.as_ref()
.map(|tos| match tos.to_lowercase().as_str() {
"lts" => "LTS",
"sts" => "STS",
_ => "-",
})
.unwrap_or("-");
if detailed && !json {
let os_arch =
format!("{}/{}", package.operating_system, package.architecture);
let lib_c = package.lib_c_type.as_deref().unwrap_or("-");
let status_plain = package
.release_status
.as_ref()
.map(|rs| match rs.to_lowercase().as_str() {
"ga" => "GA",
"ea" => "EA",
_ => rs.as_str(),
})
.unwrap_or("-");
let detailed_key = format!(
"{}-{}-{}-{}-{}-{}-{}-{}",
dist_name,
display_version,
lts_display,
status_plain,
package.package_type,
os_arch,
lib_c,
package.javafx_bundled
);
if !seen_detailed_entries.insert(detailed_key) {
continue;
}
} else if !detailed && !json {
let compact_key = format!(
"{}-{}-{}",
display_version, lts_display, package.javafx_bundled
);
if !seen_compact_entries.insert(compact_key) {
continue;
}
}
let dist_cell = if is_first_row_in_distribution {
Cell::new(display_name)
} else {
Cell::new("")
};
is_first_row_in_distribution = false;
let mut row = if detailed {
let status_display_detail = package
.release_status
.as_ref()
.map(|rs| match rs.to_lowercase().as_str() {
"ga" => "GA",
"ea" => "EA",
_ => rs.as_str(),
})
.unwrap_or("-");
let os_arch =
format!("{}/{}", package.operating_system, package.architecture);
vec![
dist_cell,
Cell::new(display_version),
match lts_display {
"LTS" => Cell::new(lts_display).fg(Color::Green),
"STS" => Cell::new(lts_display).fg(Color::Yellow),
_ => Cell::new(lts_display).fg(Color::DarkGrey),
},
match status_display_detail {
"GA" => Cell::new(status_display_detail).fg(Color::Green),
"EA" => Cell::new(status_display_detail).fg(Color::Yellow),
_ => Cell::new(status_display_detail).fg(Color::DarkGrey),
},
Cell::new(package.package_type.to_string()),
Cell::new(os_arch),
Cell::new(package.lib_c_type.as_deref().unwrap_or("-")),
Cell::new(size_display.clone()),
]
} else {
vec![
dist_cell,
Cell::new(display_version),
match lts_display {
"LTS" => Cell::new(lts_display).fg(Color::Green),
"STS" => Cell::new(lts_display).fg(Color::Yellow),
_ => Cell::new(lts_display).fg(Color::DarkGrey),
},
]
};
if has_javafx {
row.push(
Cell::new(if package.javafx_bundled { "✓" } else { "" })
.set_alignment(CellAlignment::Center),
);
}
table.add_row(row);
}
}
}
}
if let Some(col) = table.column_mut(2) {
col.set_cell_alignment(CellAlignment::Center); }
if detailed {
if let Some(col) = table.column_mut(7) {
col.set_cell_alignment(CellAlignment::Right); }
if let Some(col) = table.column_mut(3) {
col.set_cell_alignment(CellAlignment::Center); }
}
if table.row_count() > 0 {
let table_str = format!("{table}");
let lines: Vec<&str> = table_str.lines().collect();
for line in lines.iter() {
if line.contains("SEPARATOR") {
if let Some(top_border) = lines.first() {
let separator = top_border.replace('┌', "├").replace('┐', "┤");
println!("{separator}");
}
} else {
println!("{line}");
}
}
}
Ok(())
}
fn list_distributions(config: &KopiConfig) -> Result<()> {
let cache_path = config.metadata_cache_path()?;
if !cache_path.exists() {
println!("{} No cache found", "✗".red());
println!(
"\n{}: Run {} to populate the cache with available distributions.",
"Solution".yellow().bold(),
"'kopi cache refresh'".cyan()
);
return Ok(());
}
let cache = cache::load_cache(&cache_path)?;
let (current_arch, current_os, _) = get_current_platform();
let mut distribution_info: HashMap<String, (String, usize)> = HashMap::new();
for (dist_key, distribution) in &cache.distributions {
let platform_packages: Vec<_> = distribution
.packages
.iter()
.filter(|package| {
package.architecture.to_string() == current_arch
&& package.operating_system.to_string() == current_os
})
.collect();
if !platform_packages.is_empty() {
let display_name = distribution.display_name.clone();
distribution_info.insert(dist_key.clone(), (display_name, platform_packages.len()));
}
}
if distribution_info.is_empty() {
println!("{} No distributions found for current platform", "✗".red());
println!(
"\n{}: Your platform ({}/{}) might not be supported or the cache is empty.",
"Note".yellow().bold(),
current_os.cyan(),
current_arch.cyan()
);
println!(
"\n{}: Run {} to refresh the cache.",
"Solution".yellow().bold(),
"'kopi cache refresh'".cyan()
);
return Ok(());
}
println!("Available distributions in cache:\n");
let mut table = Table::new();
table.load_preset(comfy_table::presets::UTF8_BORDERS_ONLY);
table.set_header(vec![
Cell::new("Distribution"),
Cell::new("Display Name"),
Cell::new("Versions"),
]);
let mut sorted_distributions: Vec<(String, (String, usize))> =
distribution_info.into_iter().collect();
sorted_distributions.sort_by(|a, b| a.0.cmp(&b.0));
let mut total_versions = 0;
for (dist_key, (display_name, count)) in sorted_distributions {
table.add_row(vec![
Cell::new(&dist_key),
Cell::new(&display_name),
Cell::new(count.to_string()).set_alignment(CellAlignment::Right),
]);
total_versions += count;
}
println!("{table}");
println!(
"\nTotal: {} distributions with {} versions for {}/{}",
table.row_count(),
total_versions,
current_os,
current_arch
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::env;
use tempfile::TempDir;
#[test]
#[serial]
fn test_cache_info_no_cache() {
let temp_dir = TempDir::new().unwrap();
unsafe {
env::set_var("KOPI_HOME", temp_dir.path());
}
let config = crate::config::KopiConfig::new(temp_dir.path().to_path_buf()).unwrap();
let result = show_cache_info(&config, false);
assert!(result.is_ok());
unsafe {
env::remove_var("KOPI_HOME");
}
}
#[test]
#[serial]
fn test_clear_cache_no_cache() {
let temp_dir = TempDir::new().unwrap();
unsafe {
env::set_var("KOPI_HOME", temp_dir.path());
}
let config = crate::config::KopiConfig::new(temp_dir.path().to_path_buf()).unwrap();
let result = clear_cache(&config, false);
assert!(result.is_ok());
unsafe {
env::remove_var("KOPI_HOME");
}
}
#[test]
#[serial]
fn test_list_distributions_no_cache() {
let temp_dir = TempDir::new().unwrap();
unsafe {
env::set_var("KOPI_HOME", temp_dir.path());
}
let config = crate::config::KopiConfig::new(temp_dir.path().to_path_buf()).unwrap();
let result = list_distributions(&config);
assert!(result.is_ok());
unsafe {
env::remove_var("KOPI_HOME");
}
}
#[test]
#[serial]
fn test_search_cache_with_lts_filter_no_cache() {
let temp_dir = TempDir::new().unwrap();
unsafe {
env::set_var("KOPI_HOME", temp_dir.path());
}
let options = SearchOptions {
version_string: "21".to_string(),
compact: false,
detailed: false,
json: false,
lts_only: true,
force_java_version: false,
force_distribution_version: false,
};
let config = crate::config::KopiConfig::new(temp_dir.path().to_path_buf()).unwrap();
let result = search_cache(options, &config);
assert!(result.is_ok());
unsafe {
env::remove_var("KOPI_HOME");
}
}
#[test]
fn test_search_cache_version_only_no_default_distribution() {
use crate::config::KopiConfig;
use crate::version::parser::VersionParser;
let config = KopiConfig::new(std::env::temp_dir()).unwrap();
let parser = VersionParser::new(&config);
let parsed = parser.parse("21").unwrap();
assert!(parsed.version.is_some());
assert_eq!(parsed.distribution, None); }
#[test]
#[serial]
fn test_search_cache_with_synonym_resolution() {
use crate::cache::{DistributionCache, MetadataCache};
use crate::models::distribution::Distribution as JdkDistribution;
use crate::models::metadata::JdkMetadata;
use crate::models::package::{ArchiveType, ChecksumType, PackageType};
use crate::models::platform::{Architecture, OperatingSystem};
use crate::version::Version;
use std::str::FromStr;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
unsafe {
env::set_var("KOPI_HOME", temp_dir.path());
}
let mut cache = MetadataCache::new();
cache
.synonym_map
.insert("sapmachine".to_string(), "sap_machine".to_string());
cache
.synonym_map
.insert("sap-machine".to_string(), "sap_machine".to_string());
cache
.synonym_map
.insert("sap_machine".to_string(), "sap_machine".to_string());
let jdk_metadata = JdkMetadata {
id: "sap-test-id".to_string(),
distribution: "sap_machine".to_string(),
version: Version::new(21, 0, 7),
distribution_version: Version::from_str("21.0.7").unwrap(),
architecture: Architecture::X64,
operating_system: OperatingSystem::Linux,
package_type: PackageType::Jdk,
archive_type: ArchiveType::TarGz,
javafx_bundled: false,
download_url: Some("https://example.com/sap-download".to_string()),
checksum: None,
checksum_type: Some(ChecksumType::Sha256),
size: 100000000,
lib_c_type: None,
term_of_support: Some("lts".to_string()),
release_status: Some("ga".to_string()),
latest_build_available: None,
};
let dist = DistributionCache {
distribution: JdkDistribution::SapMachine,
display_name: "SAP Machine".to_string(),
packages: vec![jdk_metadata],
};
cache.distributions.insert("sap_machine".to_string(), dist);
let cache_path = temp_dir.path().join("cache").join("metadata.json");
cache.save(&cache_path).unwrap();
let options = SearchOptions {
version_string: "sapmachine@21".to_string(),
compact: false,
detailed: false,
json: true,
lts_only: false,
force_java_version: false,
force_distribution_version: false,
};
let config = crate::config::KopiConfig::new(temp_dir.path().to_path_buf()).unwrap();
let result = search_cache(options, &config);
assert!(result.is_ok(), "Search should succeed with synonym");
unsafe {
env::remove_var("KOPI_HOME");
}
}
#[test]
#[serial]
fn test_cache_refresh_with_progress() {
let temp_dir = TempDir::new().unwrap();
unsafe {
env::set_var("KOPI_HOME", temp_dir.path());
}
let config = crate::config::KopiConfig::new(temp_dir.path().to_path_buf()).unwrap();
let cache_path = config.metadata_cache_path().unwrap();
std::fs::create_dir_all(cache_path.parent().unwrap()).unwrap();
std::fs::write(
&cache_path,
r#"{"version":3,"last_updated":"2024-01-01T00:00:00Z","distributions":{},"synonym_map":{}}"#,
).unwrap();
let result = show_cache_info(&config, false);
assert!(result.is_ok());
unsafe {
env::remove_var("KOPI_HOME");
}
}
#[test]
fn test_progress_indicator_integration() {
use crate::indicator::{ProgressConfig, ProgressFactory, ProgressStyle as IndicatorStyle};
let mut progress = ProgressFactory::create(false);
let config = ProgressConfig::new(IndicatorStyle::Count);
progress.start(config);
progress.complete(Some("Test complete".to_string()));
let mut silent_progress = ProgressFactory::create(true);
let config = ProgressConfig::new(IndicatorStyle::Count);
silent_progress.start(config);
silent_progress.complete(None);
}
}