diff --git a/README.md b/README.md index 65c2511..fd5fd61 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,9 @@ Here is a common `settings.json` including the above mentioned configurations: "min_memory": "1G", // default: "1G" "max_memory": "2G", // default: unset (no -Xmx limit) - // Controls when to check for updates for JDTLS, Lombok, and Debugger - // - "always" (default): Always check for and download the latest version + // Controls when to check for updates for managed components + // - "always" (default): Check for the latest version at most once every 24 hours + // and reuse the last successfully resolved version between checks // - "once": Check for updates only if no local installation exists // - "never": Never check for updates, only use existing local installations (errors if missing) // @@ -96,6 +97,10 @@ Configuration, when you need it, goes under the `gradle-language-server` languag "gradleUserHome": null, // overrides GRADLE_USER_HOME "gradle_jvm_arguments": null, // e.g. "-Xmx2G" for the Gradle build + // Uses the same always/once/never policy as JDTLS. In "always" mode, + // successful version checks are cached for 24 hours. + "check_updates": "always", + // Path to a locally built gradle-lsp-bridge binary, overriding the // managed download. Primarily for development (see Developing Locally). "gradle_bridge_path": "/path/to/your/gradle-lsp-bridge" @@ -623,7 +628,7 @@ If changes are not picked up, clean JDTLS' cache (from a java file run the task ## Architecture Note -The extension uses two native binaries, both automatically downloaded from the [extension repository releases](https://github.com/zed-extensions/java/releases) and requiring no user configuration: +The extension uses two native binaries, both automatically downloaded from the [extension repository releases](https://github.com/zed-extensions/java/releases) and requiring no user configuration. Their managed versions are always bound to the extension version, so they do not use the 24-hour version cache and are not affected by `check_updates`. Explicit path settings and root-level development binaries installed with the `*-install` recipes still override the managed binaries: - **`java-lsp-proxy`** wraps the JDTLS process, enabling the extension to communicate with JDTLS for features like debug class resolution and classpath queries. - **`gradle-lsp-bridge`** bridges Zed to the Gradle Language Server and drives the bundled `gradle-server` over gRPC to supply the resolved build model (see [Gradle Build Files](#gradle-build-files)). It pulls in an async/gRPC stack, so it is kept as a separate binary from the deliberately lean JDTLS proxy. diff --git a/src/debugger.rs b/src/debugger.rs index dceb8fd..3fb73d5 100644 --- a/src/debugger.rs +++ b/src/debugger.rs @@ -14,8 +14,8 @@ use crate::{ downloadable::Downloadable, lsp, util::{ - ArgsStringOrList, create_path_if_not_exists, get_curr_dir, mark_checked_once, - path_to_string, should_use_local_or_download, + ArgsStringOrList, create_path_if_not_exists, get_curr_dir, path_to_string, + record_successful_update_check, should_use_local_or_download, update_check_path, }, }; @@ -114,6 +114,11 @@ impl Debugger { return Ok(path.clone()); } + if fs::metadata(&jar_path).is_ok_and(|stat| stat.is_file()) { + self.plugin_path = Some(jar_path.clone()); + return Ok(jar_path); + } + create_path_if_not_exists(prefix) .map_err(|err| format!("Failed to create debugger directory '{prefix}': {err}"))?; @@ -127,8 +132,10 @@ impl Debugger { format!("Failed to download java-debug fork from {JAVA_DEBUG_PLUGIN_FORK_URL}: {err}") })?; - // Mark the downloaded version for "Once" mode tracking - let _ = mark_checked_once(DEBUGGER_INSTALL_PATH, latest_version); + let _ = record_successful_update_check( + &update_check_path(DEBUGGER_INSTALL_PATH), + latest_version, + ); self.plugin_path = Some(jar_path.clone()); Ok(jar_path) @@ -234,8 +241,10 @@ impl Debugger { ) .map_err(|err| format!("Failed to download {url}: {err}"))?; - // Mark the downloaded version for "Once" mode tracking - let _ = mark_checked_once(DEBUGGER_INSTALL_PATH, latest_version); + let _ = record_successful_update_check( + &update_check_path(DEBUGGER_INSTALL_PATH), + latest_version, + ); } self.plugin_path = Some(jar_path.clone()); @@ -445,9 +454,13 @@ impl Downloadable for Debugger { return Ok(path); } - if let Some(path) = - should_use_local_or_download(configuration, self.find_local(), Self::INSTALL_PATH) - .map_err(|err| format!("Failed to resolve debugger installation: {err}"))? + if let Some(path) = should_use_local_or_download( + configuration, + self.find_local(), + Self::INSTALL_PATH, + &self.update_check_path(), + ) + .map_err(|err| format!("Failed to resolve debugger installation: {err}"))? { self.plugin_path = Some(path.clone()); return Ok(path); diff --git a/src/downloadable.rs b/src/downloadable.rs index ef1cf2d..b988c64 100644 --- a/src/downloadable.rs +++ b/src/downloadable.rs @@ -1,8 +1,17 @@ use std::path::PathBuf; -use zed_extension_api::{self as zed, LanguageServerId, Worktree, serde_json::Value}; - -use crate::util::should_use_local_or_download; +use zed_extension_api::{ + self as zed, LanguageServerId, LanguageServerInstallationStatus, Worktree, serde_json::Value, + set_language_server_installation_status, +}; + +use crate::{ + config::{CheckUpdates, get_check_updates}, + util::{ + fresh_cached_version, record_successful_update_check, should_use_local_or_download, + update_check_path, + }, +}; pub trait Downloadable { const INSTALL_PATH: &'static str; @@ -20,6 +29,51 @@ pub trait Downloadable { worktree: &Worktree, ) -> zed::Result; + /// Return this component's persistent update-check record path. + fn update_check_path(&self) -> PathBuf { + update_check_path(Self::INSTALL_PATH) + } + + /// Resolve the version to install, using a fresh cached version in `always` + /// mode or performing a remote version check when the cache cannot be used. + /// + /// The boolean indicates whether the update-check marker should be refreshed + /// after installation succeeds. + fn version_for_download( + &self, + language_server_id: &LanguageServerId, + configuration: &Option, + worktree: &Worktree, + ) -> zed::Result<(String, bool)> { + if get_check_updates(configuration) == CheckUpdates::Always + && let Some(version) = fresh_cached_version(&self.update_check_path()) + { + return Ok((version, false)); + } + + set_language_server_installation_status( + language_server_id, + &LanguageServerInstallationStatus::CheckingForUpdate, + ); + let version = self.fetch_latest_version(worktree); + set_language_server_installation_status( + language_server_id, + &LanguageServerInstallationStatus::None, + ); + version.map(|version| (version, true)) + } + + /// Persist a successful remote version check without failing installation + /// when the record itself cannot be written. + fn record_update_check(&self, version: &str) { + if let Err(err) = record_successful_update_check(&self.update_check_path(), version) { + println!( + "Failed to record update check for {}: {err}", + Self::INSTALL_PATH + ); + } + } + fn get_or_download( &mut self, language_server_id: &LanguageServerId, @@ -30,18 +84,29 @@ pub trait Downloadable { return Ok(PathBuf::from(path)); } - if let Some(path) = - should_use_local_or_download(configuration, self.find_local(), Self::INSTALL_PATH)? - { + if let Some(path) = should_use_local_or_download( + configuration, + self.find_local(), + Self::INSTALL_PATH, + &self.update_check_path(), + )? { return Ok(path); } let downloaded = self - .fetch_latest_version(worktree) - .and_then(|version| self.download(&version, language_server_id, worktree)); + .version_for_download(language_server_id, configuration, worktree) + .and_then(|(version, update_marker)| { + self.download(&version, language_server_id, worktree) + .map(|path| (path, version, update_marker)) + }); match downloaded { - Ok(path) => Ok(path), + Ok((path, version, update_marker)) => { + if update_marker { + self.record_update_check(&version); + } + Ok(path) + } // The version check or download failed (e.g. GitHub API rate // limiting) — an existing local installation is better than none. Err(err) => match self.find_local() { @@ -76,14 +141,18 @@ mod fallback_tests { #[test] fn test_check_updates_always_allows_download() { - let result = should_use_local_or_download(&None, None, "jdtls").unwrap(); + let result = + should_use_local_or_download(&None, None, "jdtls", &update_check_path("jdtls")) + .unwrap(); assert!(result.is_none(), "Always mode should allow download"); } #[test] fn test_check_updates_always_with_local_still_downloads() { let local = PathBuf::from("/mock/jdtls/1.44.0"); - let result = should_use_local_or_download(&None, Some(local), "jdtls").unwrap(); + let result = + should_use_local_or_download(&None, Some(local), "jdtls", &update_check_path("jdtls")) + .unwrap(); assert!(result.is_none(), "Always mode downloads even with local"); } @@ -91,14 +160,21 @@ mod fallback_tests { fn test_check_updates_never_with_local_uses_it() { let config = Some(json!({"check_updates": "never"})); let local = PathBuf::from("/mock/jdtls/1.44.0"); - let result = should_use_local_or_download(&config, Some(local.clone()), "jdtls").unwrap(); + let result = should_use_local_or_download( + &config, + Some(local.clone()), + "jdtls", + &update_check_path("jdtls"), + ) + .unwrap(); assert_eq!(result, Some(local)); } #[test] fn test_check_updates_never_without_local_is_error() { let config = Some(json!({"check_updates": "never"})); - let result = should_use_local_or_download(&config, None, "jdtls"); + let result = + should_use_local_or_download(&config, None, "jdtls", &update_check_path("jdtls")); assert!(result.is_err()); } @@ -106,13 +182,20 @@ mod fallback_tests { fn test_check_updates_once_with_local_uses_it() { let config = Some(json!({"check_updates": "once"})); let local = PathBuf::from("/mock/jdtls/1.44.0"); - let result = should_use_local_or_download(&config, Some(local.clone()), "jdtls").unwrap(); + let result = should_use_local_or_download( + &config, + Some(local.clone()), + "jdtls", + &update_check_path("jdtls"), + ) + .unwrap(); assert_eq!(result, Some(local)); } #[test] fn test_default_is_always() { - let result = should_use_local_or_download(&None, None, "test").unwrap(); + let result = + should_use_local_or_download(&None, None, "test", &update_check_path("test")).unwrap(); assert!(result.is_none(), "Default should be Always (None)"); } } diff --git a/src/gradle_bridge.rs b/src/gradle_bridge.rs index 51d3820..d8edc3d 100644 --- a/src/gradle_bridge.rs +++ b/src/gradle_bridge.rs @@ -1,4 +1,7 @@ -use std::{fs::metadata, path::PathBuf}; +use std::{ + fs::{self, metadata}, + path::PathBuf, +}; use zed_extension_api::{ self as zed, DownloadedFileType, LanguageServerId, LanguageServerInstallationStatus, Worktree, @@ -7,10 +10,9 @@ use zed_extension_api::{ use crate::{ config::get_gradle_bridge_path, - downloadable::Downloadable, util::{ - NATIVE_BIN_DIR, mark_checked_once, platform_asset_name, platform_exec_name, - remove_all_files_except, should_use_local_or_download, + NATIVE_BIN_DIR, extension_release_version, find_native_binary, platform_asset_name, + platform_exec_name, remove_all_directories_except, }, }; @@ -23,13 +25,11 @@ const GITHUB_REPO: &str = "zed-extensions/java"; /// `gradle-server.jar` over gRPC. Mirrors [`crate::proxy::Proxy`], but resolves /// its own asset name so the two binaries can be downloaded independently from /// the shared release. -pub struct GradleBridge { - cached_path: Option, -} +pub struct GradleBridge; impl GradleBridge { pub fn new() -> Self { - Self { cached_path: None } + Self } pub fn binary_path( @@ -41,127 +41,73 @@ impl GradleBridge { let path = self.get_or_download(language_server_id, configuration, worktree)?; Ok(path.to_string_lossy().to_string()) } -} - -impl Downloadable for GradleBridge { - const INSTALL_PATH: &'static str = BRIDGE_INSTALL_PATH; + /// Find a development override or the bridge matching the extension release. fn find_local(&self) -> Option { - let local_binary = PathBuf::from(BRIDGE_INSTALL_PATH).join(bridge_exec()); - if metadata(&local_binary).is_ok_and(|m| m.is_file()) { - return Some(local_binary); - } - - std::fs::read_dir(BRIDGE_INSTALL_PATH) - .ok()? - .filter_map(Result::ok) - .map(|e| e.path().join(bridge_exec())) - .filter(|p| metadata(p).is_ok_and(|m| m.is_file())) - .last() - } - - fn loaded(&self) -> bool { - self.cached_path.is_some() - } - - fn fetch_latest_version(&self, _worktree: &Worktree) -> zed::Result { - Ok(format!("v{}", env!("CARGO_PKG_VERSION"))) + find_native_binary(&bridge_exec()) } + /// Download and validate the bridge asset for the specified extension release. fn download( &mut self, version: &str, language_server_id: &LanguageServerId, - _worktree: &Worktree, ) -> zed::Result { let (name, file_type) = asset_name()?; let bin_path = format!("{BRIDGE_INSTALL_PATH}/{version}/{}", bridge_exec()); - if metadata(&bin_path).is_ok() { - self.cached_path = Some(bin_path.clone()); + if metadata(&bin_path).is_ok_and(|metadata| metadata.is_file()) { return Ok(PathBuf::from(bin_path)); } - - let release = zed::github_release_by_tag_name(GITHUB_REPO, version) - .map_err(|err| format!("Failed to fetch bridge release: {err}"))?; - - let asset = release - .assets - .iter() - .find(|a| a.name == name) - .ok_or_else(|| format!("No asset found matching {name:?}"))?; + if metadata(&bin_path).is_ok_and(|metadata| metadata.is_dir()) { + fs::remove_dir_all(&bin_path) + .map_err(|err| format!("Failed to remove invalid bridge path {bin_path}: {err}"))?; + } let version_dir = format!("{BRIDGE_INSTALL_PATH}/{version}"); + let download_url = + format!("https://github.com/{GITHUB_REPO}/releases/download/{version}/{name}"); set_language_server_installation_status( language_server_id, &LanguageServerInstallationStatus::Downloading, ); - zed::download_file(&asset.download_url, &version_dir, file_type) + zed::download_file(&download_url, &version_dir, file_type) .map_err(|err| format!("Failed to download bridge: {err}"))?; - let _ = zed::make_file_executable(&bin_path); + if !metadata(&bin_path).is_ok_and(|metadata| metadata.is_file()) { + return Err(format!( + "Downloaded bridge archive did not contain {bin_path}" + )); + } + zed::make_file_executable(&bin_path) + .map_err(|err| format!("Failed to make bridge executable at {bin_path}: {err}"))?; set_language_server_installation_status( language_server_id, &LanguageServerInstallationStatus::None, ); - let _ = remove_all_files_except(BRIDGE_INSTALL_PATH, version); - let _ = mark_checked_once(BRIDGE_INSTALL_PATH, version); + let _ = remove_all_directories_except(BRIDGE_INSTALL_PATH, version); - self.cached_path = Some(bin_path.clone()); Ok(PathBuf::from(bin_path)) } + /// Resolve an explicit override, reuse the extension-bound bridge, or download it. fn get_or_download( &mut self, language_server_id: &LanguageServerId, configuration: &Option, worktree: &Worktree, ) -> zed::Result { - if let Some(path) = self.user_configured_path(configuration, worktree) { - self.cached_path = Some(path.clone()); - return Ok(PathBuf::from(path)); - } - - // Respect the `check_updates` policy: - // Ok(Some) — use the local install, - // Ok(None) — policy allows a download (fall through), - // Err — Never / Once-exhausted with no local install: do NOT - // download; fall through to the PATH lookup as a last resort. - match should_use_local_or_download(configuration, self.find_local(), Self::INSTALL_PATH) { - Ok(Some(path)) => { - let s = path.to_string_lossy().to_string(); - self.cached_path = Some(s); - return Ok(path); - } - Ok(None) => { - if let Ok(version) = self.fetch_latest_version(worktree) - && let Ok(path) = self.download(&version, language_server_id, worktree) - { - return Ok(path); - } - } - Err(_) => { /* policy forbids download; skip to PATH fallback */ } - } - - if let Some(path) = worktree.which(bridge_exec().as_str()) { + if let Some(path) = get_gradle_bridge_path(configuration, worktree) { return Ok(PathBuf::from(path)); } - Err(format!("'{}' not found", bridge_exec())) - } - - fn user_configured_path( - &self, - configuration: &Option, - worktree: &Worktree, - ) -> Option { - if let Some(path) = get_gradle_bridge_path(configuration, worktree) { - return Some(path); + if let Some(path) = self.find_local() { + return Ok(path); } - None + self.download(&extension_release_version(), language_server_id) } } diff --git a/src/gradle_ls.rs b/src/gradle_ls.rs index 0f25ffe..0fc48b9 100644 --- a/src/gradle_ls.rs +++ b/src/gradle_ls.rs @@ -10,7 +10,7 @@ use zed_extension_api::{ use crate::{ downloadable::Downloadable, - util::{create_path_if_not_exists, mark_checked_once, remove_all_files_except}, + util::{create_path_if_not_exists, remove_all_files_except}, }; const INSTALL_PATH: &str = "gradle-ls"; @@ -111,7 +111,6 @@ impl Downloadable for GradleLs { // Remove old versions let _ = remove_all_files_except(INSTALL_PATH, version); - let _ = mark_checked_once(INSTALL_PATH, version); set_language_server_installation_status( language_server_id, diff --git a/src/jdk.rs b/src/jdk.rs index 1e01d07..f6e1d23 100644 --- a/src/jdk.rs +++ b/src/jdk.rs @@ -8,7 +8,7 @@ use zed_extension_api::{ use crate::{ downloadable::Downloadable, - util::{get_curr_dir, mark_checked_once, path_to_string, remove_all_files_except}, + util::{get_curr_dir, path_to_string, remove_all_files_except}, }; const JDK_DIR_ERROR: &str = "Failed to read into JDK install directory"; @@ -113,7 +113,6 @@ impl Downloadable for Jdk { .map_err(|err| format!("Failed to download Corretto JDK from {download_url}: {err}"))?; let _ = remove_all_files_except(&jdk_path, version); - let _ = mark_checked_once(Self::INSTALL_PATH, version); } self.cached_path = Some(install_path.clone()); diff --git a/src/jdtls.rs b/src/jdtls.rs index c243645..c1d6580 100644 --- a/src/jdtls.rs +++ b/src/jdtls.rs @@ -1,7 +1,8 @@ use std::{ env::current_dir, - fs::{metadata, read_dir}, + fs::{self, metadata, read_dir}, path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, }; use sha1::{Digest, Sha1}; @@ -20,7 +21,7 @@ use crate::{ jdk::Jdk, util::{ create_path_if_not_exists, get_curr_dir, get_java_exec_name, get_java_executable, - get_java_major_version, get_latest_versions_from_tag, mark_checked_once, path_to_string, + get_java_major_version, get_latest_versions_from_tag, path_to_string, remove_all_files_except, }, }; @@ -31,8 +32,6 @@ const LOMBOK_INSTALL_PATH: &str = "lombok"; const LOMBOK_REPO: &str = "projectlombok/lombok"; const JAVA_VERSION_ERROR: &str = "JDTLS requires at least Java version 21 to run. You can either specify a different JDK to use by configuring lsp.jdtls.settings.java_home to point to a different JDK, or set lsp.jdtls.settings.jdk_auto_download to true to let the extension automatically download one for you."; -const JDTLS_VERSION_ERROR: &str = "No version to fallback to"; - // --- Jdtls Component --- pub struct Jdtls { @@ -50,12 +49,15 @@ impl Downloadable for Jdtls { fn find_local(&self) -> Option { let prefix = PathBuf::from(JDTLS_INSTALL_PATH); + let binary_name = get_binary_name(); + let config_directory = get_shared_config_directory_name(); read_dir(&prefix) .map(|entries| { entries .filter_map(Result::ok) .map(|entry| entry.path()) .filter(|path| path.is_dir()) + .filter(|path| is_complete_jdtls_install(path, binary_name, config_directory)) .filter_map(|path| { let created_time = metadata(&path).and_then(|meta| meta.created()).ok()?; Some((path, created_time)) @@ -79,63 +81,62 @@ impl Downloadable for Jdtls { fn download( &mut self, - _version: &str, + version: &str, language_server_id: &LanguageServerId, - worktree: &Worktree, + _worktree: &Worktree, ) -> zed::Result { + let prefix = PathBuf::from(JDTLS_INSTALL_PATH); + let binary_name = get_binary_name(); + let config_directory = get_shared_config_directory_name(); + if let Some(build_path) = + installed_jdtls_version(&prefix, version, binary_name, config_directory) + { + self.cached_path = Some(build_path.clone()); + return Ok(build_path); + } + + let build_path = prefix.join(version); + set_language_server_installation_status( language_server_id, - &LanguageServerInstallationStatus::CheckingForUpdate, + &LanguageServerInstallationStatus::Downloading, ); + let latest_version_build = download_jdtls_milestone(version)?.trim_end().to_string(); - let (last, second_last) = get_latest_versions_from_tag(JDTLS_REPO, worktree) - .map_err(|err| format!("Failed to fetch JDTLS versions from {JDTLS_REPO}: {err}"))?; - - let (latest_version, latest_version_build) = download_jdtls_milestone(last.as_ref()) - .map_or_else( - |_| { - second_last - .ok_or(JDTLS_VERSION_ERROR.to_string()) - .and_then(|fallback| { - download_jdtls_milestone(&fallback) - .map(|milestone| (fallback, milestone.trim_end().to_string())) - }) - }, - |milestone| Ok((last, milestone.trim_end().to_string())), - )?; - - let prefix = PathBuf::from(JDTLS_INSTALL_PATH); - let build_directory = latest_version_build.replace(".tar.gz", ""); - let build_path = prefix.join(&build_directory); - let binary_path = build_path.join("bin").join(get_binary_name()); - - if !metadata(&binary_path).is_ok_and(|stat| stat.is_file()) { - set_language_server_installation_status( - language_server_id, - &LanguageServerInstallationStatus::Downloading, - ); - let download_url = format!( - "https://www.eclipse.org/downloads/download.php?file=/jdtls/milestones/{latest_version}/{latest_version_build}" - ); + let download_url = format!( + "https://www.eclipse.org/downloads/download.php?file=/jdtls/milestones/{version}/{latest_version_build}" + ); + let staging_path = jdtls_staging_path(&prefix, version)?; + let staging_binary_path = jdtls_binary_path(&staging_path, binary_name); + let staged = (|| { download_file( &download_url, - path_to_string(build_path.clone()) - .map_err(|err| format!("Invalid JDTLS build path {build_path:?}: {err}"))? + path_to_string(staging_path.clone()) + .map_err(|err| format!("Invalid JDTLS staging path {staging_path:?}: {err}"))? .as_str(), DownloadedFileType::GzipTar, ) .map_err(|err| format!("Failed to download JDTLS from {download_url}: {err}"))?; make_file_executable( - path_to_string(&binary_path) - .map_err(|err| format!("Invalid JDTLS binary path {binary_path:?}: {err}"))? + path_to_string(&staging_binary_path) + .map_err(|err| { + format!("Invalid JDTLS binary path {staging_binary_path:?}: {err}") + })? .as_str(), ) - .map_err(|err| format!("Failed to make JDTLS executable at {binary_path:?}: {err}"))?; - - let _ = remove_all_files_except(prefix, build_directory.as_str()); - let _ = mark_checked_once(JDTLS_INSTALL_PATH, &latest_version); + .map_err(|err| { + format!("Failed to make JDTLS executable at {staging_binary_path:?}: {err}") + })?; + + promote_staged_jdtls(&staging_path, &build_path, binary_name, config_directory) + })(); + if let Err(err) = staged { + let _ = fs::remove_dir_all(&staging_path); + return Err(err); } + let _ = remove_old_jdtls_installations(&prefix, version); + self.cached_path = Some(build_path.clone()); Ok(build_path) } @@ -214,9 +215,7 @@ impl Downloadable for Lombok { DownloadedFileType::Uncompressed, ) .map_err(|err| format!("Failed to download Lombok from {download_url}: {err}"))?; - let _ = remove_all_files_except(prefix, jar_name.as_str()); - let _ = mark_checked_once(LOMBOK_INSTALL_PATH, version); } self.cached_path = Some(jar_path.clone()); @@ -363,6 +362,110 @@ fn download_jdtls_milestone(version: &str) -> zed::Result { .map_err(|err| format!("Failed to get latest version's build (malformed response): {err}")) } +/// Return the launcher path expected inside an extracted JDTLS installation. +fn jdtls_binary_path(install_path: &Path, binary_name: &str) -> PathBuf { + install_path.join("bin").join(binary_name) +} + +/// Return the tag-specific installation path only when all required JDTLS files are present. +fn installed_jdtls_version( + prefix: &Path, + version: &str, + binary_name: &str, + config_directory: &str, +) -> Option { + let install_path = prefix.join(version); + is_complete_jdtls_install(&install_path, binary_name, config_directory).then_some(install_path) +} + +/// Validate the launcher, platform configuration, and Equinox launcher needed to start JDTLS. +fn is_complete_jdtls_install( + install_path: &Path, + binary_name: &str, + config_directory: &str, +) -> bool { + jdtls_binary_path(install_path, binary_name).is_file() + && install_path.join(config_directory).is_dir() + && find_equinox_launcher(install_path).is_ok() +} + +/// Select an unused hidden sibling directory for extracting a JDTLS archive +/// before it is promoted to the tag-specific installation directory. +fn jdtls_staging_path(prefix: &Path, version: &str) -> zed::Result { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|err| format!("System clock is before the Unix epoch: {err}"))? + .as_nanos(); + let version_hash = get_sha1_hex(version); + + (0..16) + .map(|attempt| prefix.join(format!(".{version_hash}.{nonce}.{attempt}.tmp"))) + .find(|path| !path.exists()) + .ok_or_else(|| format!("Failed to allocate a JDTLS staging directory for {version}")) +} + +/// Validate and atomically promote a staged JDTLS installation. +/// +/// A concurrently completed installation wins; its redundant staging directory +/// is removed instead of replacing the valid final directory. +fn promote_staged_jdtls( + staging_path: &Path, + install_path: &Path, + binary_name: &str, + config_directory: &str, +) -> zed::Result<()> { + if !is_complete_jdtls_install(staging_path, binary_name, config_directory) { + return Err(format!( + "Downloaded JDTLS installation is incomplete at {staging_path:?}" + )); + } + + if is_complete_jdtls_install(install_path, binary_name, config_directory) { + fs::remove_dir_all(staging_path) + .map_err(|err| format!("Failed to remove redundant JDTLS staging directory: {err}"))?; + return Ok(()); + } + + if install_path.exists() { + fs::remove_dir_all(install_path) + .map_err(|err| format!("Failed to remove incomplete JDTLS installation: {err}"))?; + } + + match fs::rename(staging_path, install_path) { + Ok(()) => Ok(()), + Err(_) if is_complete_jdtls_install(install_path, binary_name, config_directory) => { + fs::remove_dir_all(staging_path) + .map_err(|err| format!("Failed to remove redundant JDTLS staging directory: {err}")) + } + Err(err) => Err(format!( + "Failed to promote JDTLS staging directory {staging_path:?}: {err}" + )), + } +} + +/// Remove older completed JDTLS version directories while preserving the +/// selected version, update records, and hidden in-progress staging directories. +fn remove_old_jdtls_installations(prefix: &Path, version: &str) -> zed::Result<()> { + let entries = fs::read_dir(prefix) + .map_err(|err| format!("Failed to read JDTLS install directory {prefix:?}: {err}"))?; + + for entry in entries.filter_map(Result::ok) { + let file_name = entry.file_name(); + let Some(file_name) = file_name.to_str() else { + continue; + }; + if file_name != version + && !file_name.starts_with('.') + && entry.file_type().is_ok_and(|file_type| file_type.is_dir()) + && let Err(err) = fs::remove_dir_all(entry.path()) + { + println!("Failed to remove old JDTLS installation: {err}"); + } + } + + Ok(()) +} + fn find_equinox_launcher(jdtls_base_directory: &Path) -> Result { let plugins_dir = jdtls_base_directory.join("plugins"); @@ -439,13 +542,128 @@ fn get_sha1_hex(input: &str) -> String { hex::encode(result) } -fn get_shared_config_path(jdtls_base_directory: &Path) -> PathBuf { - let config_to_use = match current_platform() { +/// Return the JDTLS configuration directory bundled for the current platform. +fn get_shared_config_directory_name() -> &'static str { + match current_platform() { (Os::Mac, Architecture::Aarch64) => "config_mac_arm", (Os::Mac, _) => "config_mac", (Os::Linux, Architecture::Aarch64) => "config_linux_arm", (Os::Linux, _) => "config_linux", (Os::Windows, _) => "config_win", - }; - jdtls_base_directory.join(config_to_use) + } +} + +fn get_shared_config_path(jdtls_base_directory: &Path) -> PathBuf { + jdtls_base_directory.join(get_shared_config_directory_name()) +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicU64, Ordering}; + + use super::*; + use crate::util::{UPDATE_CHECK_MARKER, fresh_cached_version, record_successful_update_check}; + + static NEXT_TEMP_PATH: AtomicU64 = AtomicU64::new(0); + + fn temporary_jdtls_prefix(test_name: &str) -> PathBuf { + let id = NEXT_TEMP_PATH.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "zed-java-jdtls-{}-{id}-{test_name}", + std::process::id() + )) + } + + fn create_complete_install(install_path: &Path) { + let binary_path = jdtls_binary_path(install_path, "jdtls"); + fs::create_dir_all(binary_path.parent().unwrap()).unwrap(); + fs::write(binary_path, "").unwrap(); + fs::create_dir_all(install_path.join("config_linux")).unwrap(); + let launcher = install_path + .join("plugins") + .join("org.eclipse.equinox.launcher_1.0.0.jar"); + fs::create_dir_all(launcher.parent().unwrap()).unwrap(); + fs::write(launcher, "").unwrap(); + } + + #[test] + fn incomplete_jdtls_install_is_not_reused() { + let prefix = temporary_jdtls_prefix("incomplete"); + let version = "1.50.0"; + let install_path = prefix.join(version); + + let binary_path = jdtls_binary_path(&install_path, "jdtls"); + fs::create_dir_all(binary_path.parent().unwrap()).unwrap(); + fs::write(&binary_path, "").unwrap(); + + assert_eq!( + installed_jdtls_version(&prefix, version, "jdtls", "config_linux"), + None + ); + + create_complete_install(&install_path); + assert_eq!( + installed_jdtls_version(&prefix, version, "jdtls", "config_linux"), + Some(install_path) + ); + + let _ = fs::remove_dir_all(prefix); + } + + #[test] + fn complete_staging_install_replaces_incomplete_final_directory() { + let prefix = temporary_jdtls_prefix("promotion"); + let install_path = prefix.join("1.50.0"); + let staging_path = prefix.join(".staging.tmp"); + fs::create_dir_all(&install_path).unwrap(); + fs::write(install_path.join("partial"), "").unwrap(); + create_complete_install(&staging_path); + + promote_staged_jdtls(&staging_path, &install_path, "jdtls", "config_linux").unwrap(); + + assert!(!staging_path.exists()); + assert!(is_complete_jdtls_install( + &install_path, + "jdtls", + "config_linux" + )); + assert!(!install_path.join("partial").exists()); + let _ = fs::remove_dir_all(prefix); + } + + #[test] + fn cached_tag_resolves_complete_install_without_build_metadata() { + let prefix = temporary_jdtls_prefix("cache-flow"); + let version = "1.50.0"; + let update_check_path = prefix.join(UPDATE_CHECK_MARKER); + record_successful_update_check(&update_check_path, version).unwrap(); + let cached_version = fresh_cached_version(&update_check_path).unwrap(); + let install_path = prefix.join(version); + create_complete_install(&install_path); + + assert_eq!(cached_version, version); + assert_eq!( + installed_jdtls_version(&prefix, &cached_version, "jdtls", "config_linux"), + Some(install_path) + ); + let _ = fs::remove_dir_all(prefix); + } + + #[test] + fn jdtls_cleanup_preserves_active_staging_directories() { + let prefix = temporary_jdtls_prefix("cleanup"); + let current = prefix.join("1.50.0"); + let old = prefix.join("1.49.0"); + let staging = prefix.join(".staging.tmp"); + fs::create_dir_all(¤t).unwrap(); + fs::create_dir_all(&old).unwrap(); + fs::create_dir_all(&staging).unwrap(); + + remove_old_jdtls_installations(&prefix, "1.50.0").unwrap(); + + assert!(current.exists()); + assert!(!old.exists()); + assert!(staging.exists()); + let _ = fs::remove_dir_all(prefix); + } } diff --git a/src/proxy.rs b/src/proxy.rs index cb12cfb..d50aad8 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -1,4 +1,7 @@ -use std::{fs::metadata, path::PathBuf}; +use std::{ + fs::{self, metadata}, + path::PathBuf, +}; use zed_extension_api::{ self as zed, DownloadedFileType, LanguageServerId, LanguageServerInstallationStatus, Worktree, @@ -7,10 +10,9 @@ use zed_extension_api::{ use crate::{ config::get_lsp_proxy_path, - downloadable::Downloadable, util::{ - NATIVE_BIN_DIR, mark_checked_once, platform_asset_name, platform_exec_name, - remove_all_files_except, should_use_local_or_download, + NATIVE_BIN_DIR, extension_release_version, find_native_binary, platform_asset_name, + platform_exec_name, remove_all_directories_except, }, }; @@ -18,13 +20,11 @@ const PROXY_BINARY: &str = "java-lsp-proxy"; const PROXY_INSTALL_PATH: &str = NATIVE_BIN_DIR; const GITHUB_REPO: &str = "zed-extensions/java"; -pub struct Proxy { - cached_path: Option, -} +pub struct Proxy; impl Proxy { pub fn new() -> Self { - Self { cached_path: None } + Self } pub fn binary_path( @@ -36,131 +36,73 @@ impl Proxy { let path = self.get_or_download(language_server_id, configuration, worktree)?; Ok(path.to_string_lossy().to_string()) } -} - -impl Downloadable for Proxy { - const INSTALL_PATH: &'static str = PROXY_INSTALL_PATH; + /// Find a development override or the proxy matching the extension release. fn find_local(&self) -> Option { - let local_binary = PathBuf::from(PROXY_INSTALL_PATH).join(proxy_exec()); - if metadata(&local_binary).is_ok_and(|m| m.is_file()) { - return Some(local_binary); - } - - std::fs::read_dir(PROXY_INSTALL_PATH) - .ok()? - .filter_map(Result::ok) - .map(|e| e.path().join(proxy_exec())) - .filter(|p| metadata(p).is_ok_and(|m| m.is_file())) - .last() - } - - fn loaded(&self) -> bool { - self.cached_path.is_some() - } - - fn fetch_latest_version(&self, _worktree: &Worktree) -> zed::Result { - // The proxy is built and released together with the extension, so the - // matching release is the one tagged with the extension's own version. - Ok(format!("v{}", env!("CARGO_PKG_VERSION"))) + find_native_binary(&proxy_exec()) } + /// Download and validate the proxy asset for the specified extension release. fn download( &mut self, version: &str, language_server_id: &LanguageServerId, - _worktree: &Worktree, ) -> zed::Result { let (name, file_type) = asset_name()?; let bin_path = format!("{PROXY_INSTALL_PATH}/{version}/{}", proxy_exec()); - if metadata(&bin_path).is_ok() { - self.cached_path = Some(bin_path.clone()); + if metadata(&bin_path).is_ok_and(|metadata| metadata.is_file()) { return Ok(PathBuf::from(bin_path)); } - - let release = zed::github_release_by_tag_name(GITHUB_REPO, version) - .map_err(|err| format!("Failed to fetch proxy release {version}: {err}"))?; - - let asset = release - .assets - .iter() - .find(|a| a.name == name) - .ok_or_else(|| format!("No asset found matching {name:?}"))?; + if metadata(&bin_path).is_ok_and(|metadata| metadata.is_dir()) { + fs::remove_dir_all(&bin_path) + .map_err(|err| format!("Failed to remove invalid proxy path {bin_path}: {err}"))?; + } let version_dir = format!("{PROXY_INSTALL_PATH}/{version}"); + let download_url = + format!("https://github.com/{GITHUB_REPO}/releases/download/{version}/{name}"); set_language_server_installation_status( language_server_id, &LanguageServerInstallationStatus::Downloading, ); - zed::download_file(&asset.download_url, &version_dir, file_type) + zed::download_file(&download_url, &version_dir, file_type) .map_err(|err| format!("Failed to download proxy: {err}"))?; - let _ = zed::make_file_executable(&bin_path); + if !metadata(&bin_path).is_ok_and(|metadata| metadata.is_file()) { + return Err(format!( + "Downloaded proxy archive did not contain {bin_path}" + )); + } + zed::make_file_executable(&bin_path) + .map_err(|err| format!("Failed to make proxy executable at {bin_path}: {err}"))?; set_language_server_installation_status( language_server_id, &LanguageServerInstallationStatus::None, ); - let _ = remove_all_files_except(PROXY_INSTALL_PATH, version); - let _ = mark_checked_once(PROXY_INSTALL_PATH, version); + let _ = remove_all_directories_except(PROXY_INSTALL_PATH, version); - self.cached_path = Some(bin_path.clone()); Ok(PathBuf::from(bin_path)) } + /// Resolve an explicit override, reuse the extension-bound proxy, or download it. fn get_or_download( &mut self, language_server_id: &LanguageServerId, configuration: &Option, worktree: &Worktree, ) -> zed::Result { - if let Some(path) = self.user_configured_path(configuration, worktree) { - self.cached_path = Some(path.clone()); + if let Some(path) = get_lsp_proxy_path(configuration, worktree) { return Ok(PathBuf::from(path)); } - if let Some(path) = - should_use_local_or_download(configuration, self.find_local(), Self::INSTALL_PATH) - .unwrap_or(None) - { - let s = path.to_string_lossy().to_string(); - self.cached_path = Some(s); - return Ok(path); - } - - let downloaded = self - .fetch_latest_version(worktree) - .and_then(|version| self.download(&version, language_server_id, worktree)); - - let download_err = match downloaded { - Ok(path) => return Ok(path), - Err(err) => err, - }; - - // The version check or download failed (e.g. GitHub API rate - // limiting) — an existing local installation is better than none. if let Some(path) = self.find_local() { - println!("Failed to update proxy, falling back to local installation: {download_err}"); - let s = path.to_string_lossy().to_string(); - self.cached_path = Some(s); return Ok(path); } - if let Some(path) = worktree.which(proxy_exec().as_str()) { - return Ok(PathBuf::from(path)); - } - - Err(format!("'{}' not found: {download_err}", proxy_exec())) - } - - fn user_configured_path( - &self, - configuration: &Option, - worktree: &Worktree, - ) -> Option { - get_lsp_proxy_path(configuration, worktree) + self.download(&extension_release_version(), language_server_id) } } diff --git a/src/util.rs b/src/util.rs index fc234a2..4dcdc7f 100644 --- a/src/util.rs +++ b/src/util.rs @@ -3,8 +3,10 @@ use regex::Regex; use serde::{Deserialize, Serialize, Serializer}; use std::{ env::current_dir, - fs, + fs::{self, OpenOptions}, + io::Write, path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, }; use zed_extension_api::{ self as zed, Architecture, Command, DownloadedFileType, LanguageServerId, Os, Worktree, @@ -38,7 +40,14 @@ const NO_LOCAL_INSTALL_NEVER_ERROR: &str = const NO_LOCAL_INSTALL_ONCE_ERROR: &str = "Update check already performed once and no local installation found"; -const ONCE_CHECK_MARKER: &str = ".update_checked"; +pub const UPDATE_CHECK_MARKER: &str = ".update_checked"; +const UPDATE_CHECK_TTL_SECONDS: u64 = 24 * 60 * 60; + +#[derive(Debug, Deserialize, Serialize)] +struct UpdateCheckRecord { + version: String, + checked_at_unix_seconds: u64, +} /// Create a Path if it does not exist /// @@ -67,41 +76,110 @@ pub fn create_path_if_not_exists>(path: P) -> zed::Result<()> { } } -/// Check if update check has been performed once for a component -/// -/// # Arguments -/// -/// * [`component_name`] - The component directory name (e.g., "jdtls", "lombok") -/// -/// # Returns -/// -/// Returns true if the marker file exists, indicating a check was already performed -pub fn has_checked_once(component_name: &str) -> bool { - PathBuf::from(component_name) - .join(ONCE_CHECK_MARKER) - .exists() +/// Return the default update-check record path for a component install directory. +pub fn update_check_path(component_name: &str) -> PathBuf { + PathBuf::from(component_name).join(UPDATE_CHECK_MARKER) } -/// Mark that an update check has been performed for a component -/// -/// # Arguments -/// -/// * [`component_name`] - The component directory name (e.g., "jdtls", "lombok") -/// * [`version`] - The version that was downloaded -/// -/// # Returns -/// -/// Returns Ok(()) if the marker was created successfully -/// -/// # Errors -/// -/// Returns an error if the directory or marker file could not be created -pub fn mark_checked_once(component_name: &str, version: &str) -> zed::Result<()> { - let marker_path = PathBuf::from(component_name).join(ONCE_CHECK_MARKER); - create_path_if_not_exists(PathBuf::from(component_name)) - .map_err(|err| format!("Failed to create directory for {component_name}: {err}"))?; - fs::write(&marker_path, version) - .map_err(|err| format!("Failed to write marker file {marker_path:?}: {err}")) +/// Return whether an update-check marker exists, regardless of its format or contents. +pub fn has_checked_once(update_check_path: &Path) -> bool { + update_check_path.exists() +} + +/// Return the recorded version when its successful remote check is less than 24 hours old. +pub fn fresh_cached_version(update_check_path: &Path) -> Option { + let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs(); + fresh_cached_version_at(update_check_path, now) +} + +/// Return a cached version relative to an explicit time, primarily to make TTL +/// boundary behavior deterministic in tests. +fn fresh_cached_version_at(update_check_path: &Path, now: u64) -> Option { + let record = + serde_json::from_slice::(&fs::read(update_check_path).ok()?).ok()?; + let age = now.checked_sub(record.checked_at_unix_seconds)?; + + if !record.version.is_empty() && age < UPDATE_CHECK_TTL_SECONDS { + Some(record.version) + } else { + None + } +} + +/// Atomically record the version and current time of a successful remote update check. +pub fn record_successful_update_check(update_check_path: &Path, version: &str) -> zed::Result<()> { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|err| format!("System clock is before the Unix epoch: {err}"))? + .as_secs(); + record_successful_update_check_at(update_check_path, version, now) +} + +/// Serialize an update-check record using an explicit timestamp and replace the +/// previous record through a temporary sibling file. +fn record_successful_update_check_at( + update_check_path: &Path, + version: &str, + now: u64, +) -> zed::Result<()> { + if let Some(parent) = update_check_path.parent() { + create_path_if_not_exists(parent) + .map_err(|err| format!("Failed to create update-check directory {parent:?}: {err}"))?; + } + + let record = UpdateCheckRecord { + version: version.to_string(), + checked_at_unix_seconds: now, + }; + let contents = serde_json::to_vec(&record) + .map_err(|err| format!("Failed to serialize update-check record: {err}"))?; + replace_update_check_record(update_check_path, &contents, now) +} + +/// Write and sync a uniquely named temporary record before renaming it over the +/// destination, preventing interrupted writes from leaving partial JSON. +fn replace_update_check_record( + update_check_path: &Path, + contents: &[u8], + nonce: u64, +) -> zed::Result<()> { + let file_name = update_check_path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(UPDATE_CHECK_MARKER); + + for attempt in 0..16 { + let temporary_path = + update_check_path.with_file_name(format!("{file_name}.{nonce}.{attempt}.tmp")); + let file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary_path); + let mut file = match file { + Ok(file) => file, + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(err) => { + return Err(format!( + "Failed to create temporary update-check record {temporary_path:?}: {err}" + )); + } + }; + + let result = file.write_all(contents).and_then(|_| file.sync_all()); + drop(file); + let result = result.and_then(|_| fs::rename(&temporary_path, update_check_path)); + if let Err(err) = result { + let _ = fs::remove_file(&temporary_path); + return Err(format!( + "Failed to replace update-check record {update_check_path:?}: {err}" + )); + } + return Ok(()); + } + + Err(format!( + "Failed to allocate a temporary update-check record for {update_check_path:?}" + )) } /// Expand ~ on Unix-like systems @@ -203,11 +281,38 @@ pub fn get_java_exec_name() -> String { } /// The single install directory shared by every native binary the extension -/// downloads. They are versioned by the same release tag, -/// so they co-locate under `bin//` and survive each -/// other's `remove_all_files_except` cleanup. +/// downloads. Managed binaries co-locate under the extension release tag in +/// `bin//`; root-level binaries are local development overrides. pub const NATIVE_BIN_DIR: &str = "bin"; +/// Return the release tag corresponding to the extension package version. +pub fn extension_release_version() -> String { + format!("v{}", env!("CARGO_PKG_VERSION")) +} + +/// Find a native development override or the binary matching the current extension release. +pub fn find_native_binary(executable: &str) -> Option { + find_native_binary_in(Path::new(NATIVE_BIN_DIR), executable) +} + +/// Resolve a native binary within an explicit install directory for filesystem tests. +fn find_native_binary_in(install_dir: &Path, executable: &str) -> Option { + let preferred_paths = [ + install_dir.join(executable), + install_dir + .join(extension_release_version()) + .join(executable), + ]; + if let Some(path) = preferred_paths + .into_iter() + .find(|path| fs::metadata(path).is_ok_and(|metadata| metadata.is_file())) + { + return Some(path); + } + + None +} + /// The platform-specific executable file name for a binary /// (appends `.exe` on Windows). pub fn platform_exec_name(binary: &str) -> String { @@ -458,9 +563,28 @@ pub fn remove_all_files_except>(prefix: P, filename: &str) -> zed Ok(()) } +/// Remove all subdirectories except the named one, preserving root-level files. +pub fn remove_all_directories_except>( + prefix: P, + directory_name: &str, +) -> zed::Result<()> { + let entries = fs::read_dir(prefix).map_err(|err| format!("{DIR_ENTRY_LOAD_ERROR}: {err}"))?; + + for entry in entries.filter_map(Result::ok) { + if entry.file_name().to_str() != Some(directory_name) + && entry.file_type().is_ok_and(|file_type| file_type.is_dir()) + && let Err(err) = fs::remove_dir_all(entry.path()) + { + println!("{DIR_ENTRY_RM_ERROR}: {err}"); + } + } + + Ok(()) +} + /// Determine whether to use local component or download based on update mode /// -/// This function handles the common logic for all components (JDTLS, Lombok, Debugger): +/// This function handles the common update policy for managed components: /// 1. Apply update check mode (Never/Once/Always) /// 2. Find local installation if applicable /// @@ -468,6 +592,7 @@ pub fn remove_all_files_except>(prefix: P, filename: &str) -> zed /// * `configuration` - User configuration JSON /// * `local` - Optional path to local installation /// * `component_name` - Component name for error messages (e.g., "jdtls", "lombok", "debugger") +/// * `update_check_path` - Component-specific update-check record /// /// # Returns /// * `Ok(Some(PathBuf))` - Local installation should be used @@ -481,6 +606,7 @@ pub fn should_use_local_or_download( configuration: &Option, local: Option, component_name: &str, + update_check_path: &Path, ) -> zed::Result> { match get_check_updates(configuration) { CheckUpdates::Never => match local { @@ -496,7 +622,7 @@ pub fn should_use_local_or_download( } // If we've already checked once, don't check again - if has_checked_once(component_name) { + if has_checked_once(update_check_path) { return Err(format!( "{NO_LOCAL_INSTALL_ONCE_ERROR} for {component_name}" )); @@ -546,13 +672,231 @@ impl Serialize for ArgsStringOrList { #[cfg(test)] mod tests { + use std::sync::{ + Arc, Barrier, + atomic::{AtomicU64, Ordering}, + }; + + use serde_json::json; + use super::*; + static NEXT_TEMP_PATH: AtomicU64 = AtomicU64::new(0); + #[derive(Deserialize, Serialize)] struct ArgsWrapper { args: ArgsStringOrList, } + fn temporary_update_check_path(test_name: &str) -> PathBuf { + let id = NEXT_TEMP_PATH.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir() + .join(format!( + "zed-java-update-check-{}-{id}-{test_name}", + std::process::id() + )) + .join(UPDATE_CHECK_MARKER) + } + + fn remove_temporary_update_check(path: &Path) { + if let Some(parent) = path.parent() { + let _ = fs::remove_dir_all(parent); + } + } + + #[test] + fn update_check_is_fresh_for_less_than_24_hours() { + let path = temporary_update_check_path("fresh"); + record_successful_update_check_at(&path, "1.2.3", 1_000).unwrap(); + + assert_eq!( + fresh_cached_version_at(&path, 1_000 + UPDATE_CHECK_TTL_SECONDS - 1), + Some("1.2.3".to_string()) + ); + remove_temporary_update_check(&path); + } + + #[test] + fn update_check_expires_at_24_hours() { + let path = temporary_update_check_path("expired"); + record_successful_update_check_at(&path, "1.2.3", 1_000).unwrap(); + + assert_eq!( + fresh_cached_version_at(&path, 1_000 + UPDATE_CHECK_TTL_SECONDS), + None + ); + remove_temporary_update_check(&path); + } + + #[test] + fn future_update_check_timestamp_is_stale() { + let path = temporary_update_check_path("future"); + record_successful_update_check_at(&path, "1.2.3", 2_000).unwrap(); + + assert_eq!(fresh_cached_version_at(&path, 1_000), None); + remove_temporary_update_check(&path); + } + + #[test] + fn legacy_marker_is_stale_but_still_counts_for_once_mode() { + let path = temporary_update_check_path("legacy"); + create_path_if_not_exists(path.parent().unwrap()).unwrap(); + fs::write(&path, "1.2.3").unwrap(); + + assert_eq!(fresh_cached_version_at(&path, 1_000), None); + assert!(has_checked_once(&path)); + + let configuration = Some(json!({ "check_updates": "once" })); + assert!(should_use_local_or_download(&configuration, None, "test", &path).is_err()); + remove_temporary_update_check(&path); + } + + #[test] + fn once_mode_ignores_update_check_record_freshness() { + let path = temporary_update_check_path("once"); + record_successful_update_check_at(&path, "1.2.3", 1_000).unwrap(); + let configuration = Some(json!({ "check_updates": "once" })); + + assert!(should_use_local_or_download(&configuration, None, "test", &path).is_err()); + remove_temporary_update_check(&path); + } + + #[test] + fn successful_recheck_refreshes_unchanged_version_timestamp() { + let path = temporary_update_check_path("refresh"); + record_successful_update_check_at(&path, "1.2.3", 1_000).unwrap(); + record_successful_update_check_at(&path, "1.2.3", 2_000).unwrap(); + + let record = + serde_json::from_slice::(&fs::read(&path).unwrap()).unwrap(); + assert_eq!(record.version, "1.2.3"); + assert_eq!(record.checked_at_unix_seconds, 2_000); + remove_temporary_update_check(&path); + } + + #[test] + fn once_mode_uses_marker_presence_regardless_of_contents() { + let path = temporary_update_check_path("once-marker-contents"); + create_path_if_not_exists(path.parent().unwrap()).unwrap(); + + fs::write(&path, r#"{"version":"1.2.3""#).unwrap(); + assert!(has_checked_once(&path)); + assert_eq!(fresh_cached_version_at(&path, 1_000), None); + + fs::write(&path, "").unwrap(); + assert!(has_checked_once(&path)); + assert_eq!(fresh_cached_version_at(&path, 1_000), None); + remove_temporary_update_check(&path); + } + + #[test] + fn update_check_record_replacement_leaves_no_temporary_file() { + let path = temporary_update_check_path("atomic"); + record_successful_update_check_at(&path, "1.2.3", 1_000).unwrap(); + record_successful_update_check_at(&path, "1.2.4", 2_000).unwrap(); + + let record = + serde_json::from_slice::(&fs::read(&path).unwrap()).unwrap(); + assert_eq!(record.version, "1.2.4"); + assert_eq!( + fs::read_dir(path.parent().unwrap()).unwrap().count(), + 1, + "only the completed update-check record should remain" + ); + remove_temporary_update_check(&path); + } + + #[test] + fn concurrent_update_check_writers_leave_a_valid_record() { + let path = temporary_update_check_path("concurrent"); + let barrier = Arc::new(Barrier::new(8)); + let writers = (0..8) + .map(|writer| { + let path = path.clone(); + let barrier = Arc::clone(&barrier); + std::thread::spawn(move || { + barrier.wait(); + record_successful_update_check_at( + &path, + &format!("1.2.{writer}"), + 1_000 + writer, + ) + }) + }) + .collect::>(); + + for writer in writers { + writer.join().unwrap().unwrap(); + } + + let record = + serde_json::from_slice::(&fs::read(&path).unwrap()).unwrap(); + assert!(record.version.starts_with("1.2.")); + assert_eq!( + fs::read_dir(path.parent().unwrap()).unwrap().count(), + 1, + "concurrent writers must not leave temporary files" + ); + remove_temporary_update_check(&path); + } + + #[test] + fn native_binary_discovery_ignores_other_extension_versions() { + let root = temporary_update_check_path("native-binary") + .parent() + .unwrap() + .to_path_buf(); + let old_binary = root.join("v1.0.0").join("test-binary"); + create_path_if_not_exists(old_binary.parent().unwrap()).unwrap(); + fs::write(&old_binary, "").unwrap(); + + assert_eq!(find_native_binary_in(&root, "test-binary"), None); + + let current_binary = root.join(extension_release_version()).join("test-binary"); + create_path_if_not_exists(¤t_binary).unwrap(); + assert_eq!( + find_native_binary_in(&root, "test-binary"), + None, + "a directory at the binary path must not be accepted" + ); + fs::remove_dir_all(¤t_binary).unwrap(); + create_path_if_not_exists(current_binary.parent().unwrap()).unwrap(); + fs::write(¤t_binary, "").unwrap(); + assert_eq!( + find_native_binary_in(&root, "test-binary"), + Some(current_binary) + ); + + let development_binary = root.join("test-binary"); + fs::write(&development_binary, "").unwrap(); + assert_eq!( + find_native_binary_in(&root, "test-binary"), + Some(development_binary) + ); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn native_cleanup_removes_old_versions_and_preserves_root_files() { + let root = temporary_update_check_path("native-cleanup") + .parent() + .unwrap() + .to_path_buf(); + let current_dir = root.join(extension_release_version()); + let old_dir = root.join("v1.0.0"); + let development_binary = root.join("test-binary"); + create_path_if_not_exists(¤t_dir).unwrap(); + create_path_if_not_exists(&old_dir).unwrap(); + fs::write(&development_binary, "").unwrap(); + + remove_all_directories_except(&root, &extension_release_version()).unwrap(); + + assert!(current_dir.is_dir()); + assert!(!old_dir.exists()); + assert!(development_binary.is_file()); + let _ = fs::remove_dir_all(root); + } + #[test] fn github_token_prefers_github_token() { let env = vec![