diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelper.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelper.kt new file mode 100644 index 00000000000..00cc5923195 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelper.kt @@ -0,0 +1,187 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts + +import com.intellij.util.io.createDirectories +import com.intellij.util.text.SemVer +import software.aws.toolkits.core.utils.deleteIfExists +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.exists +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.saveFileFromUrl +import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager +import java.nio.file.Path +import java.util.concurrent.atomic.AtomicInteger + +class ArtifactHelper(private val lspArtifactsPath: Path = DEFAULT_ARTIFACT_PATH, private val maxDownloadAttempts: Int = MAX_DOWNLOAD_ATTEMPTS) { + + companion object { + private val DEFAULT_ARTIFACT_PATH = getToolkitsCommonCacheRoot().resolve("aws").resolve("toolkits").resolve("language-servers") + private val logger = getLogger() + private const val MAX_DOWNLOAD_ATTEMPTS = 3 + } + private val currentAttempt = AtomicInteger(0) + + fun removeDelistedVersions(delistedVersions: List) { + val localFolders = getSubFolders(lspArtifactsPath) + + delistedVersions.forEach { delistedVersion -> + val versionToDelete = delistedVersion.serverVersion ?: return@forEach + + localFolders + .filter { folder -> folder.fileName.toString() == versionToDelete } + .forEach { folder -> + try { + folder.toFile().deleteRecursively() + logger.info { "Successfully deleted deListed version: ${folder.fileName}" } + } catch (e: Exception) { + logger.error(e) { "Failed to delete deListed version ${folder.fileName}: ${e.message}" } + } + } + } + } + + fun deleteOlderLspArtifacts(manifestVersionRanges: ArtifactManager.SupportedManifestVersionRange) { + val localFolders = getSubFolders(lspArtifactsPath) + + val validVersions = localFolders + .mapNotNull { localFolder -> + SemVer.parseFromText(localFolder.fileName.toString())?.let { semVer -> + if (semVer in manifestVersionRanges.startVersion..manifestVersionRanges.endVersion) { + localFolder to semVer + } else { + null + } + } + } + .sortedByDescending { (_, semVer) -> semVer } + + // Keep the latest 2 versions, delete others + validVersions.drop(2).forEach { (folder, _) -> + try { + folder.toFile().deleteRecursively() + logger.info { "Deleted older LSP artifact: ${folder.fileName}" } + } catch (e: Exception) { + logger.error(e) { "Failed to delete older LSP artifact: ${folder.fileName}" } + } + } + } + + fun getExistingLspArtifacts(versions: List, target: ManifestManager.VersionTarget?): Boolean { + if (versions.isEmpty() || target?.contents == null) return false + + val localLSPPath = lspArtifactsPath.resolve(versions.first().serverVersion.toString()) + if (!localLSPPath.exists()) return false + + val hasInvalidFiles = target.contents.any { content -> + content.filename?.let { filename -> + val filePath = localLSPPath.resolve(filename) + !filePath.exists() || !validateFileHash(filePath, content.hashes?.firstOrNull()) + } ?: false + } + + if (hasInvalidFiles) { + try { + localLSPPath.toFile().deleteRecursively() + logger.info { "Deleted mismatched LSP artifacts at: $localLSPPath" } + } catch (e: Exception) { + logger.error(e) { "Failed to delete mismatched LSP artifacts at: $localLSPPath" } + } + } + return !hasInvalidFiles + } + + fun tryDownloadLspArtifacts(versions: List, target: ManifestManager.VersionTarget?) { + val temporaryDownloadPath = lspArtifactsPath.resolve("temp") + val downloadPath = lspArtifactsPath.resolve(versions.first().serverVersion.toString()) + + while (currentAttempt.get() < maxDownloadAttempts) { + currentAttempt.incrementAndGet() + logger.info { "Attempt ${currentAttempt.get()} of $maxDownloadAttempts to download LSP artifacts" } + + try { + if (downloadLspArtifacts(temporaryDownloadPath, target)) { + moveFilesFromSourceToDestination(temporaryDownloadPath, downloadPath) + logger.info { "Successfully downloaded and moved LSP artifacts to $downloadPath" } + return + } + } catch (e: Exception) { + logger.error(e) { "Failed to download/move LSP artifacts on attempt ${currentAttempt.get()}" } + temporaryDownloadPath.toFile().deleteRecursively() + + if (currentAttempt.get() >= maxDownloadAttempts) { + throw LspException("Failed to download LSP artifacts after $maxDownloadAttempts attempts", LspException.ErrorCode.DOWNLOAD_FAILED) + } + } + } + } + + private fun downloadLspArtifacts(downloadPath: Path, target: ManifestManager.VersionTarget?): Boolean { + if (target == null || target.contents.isNullOrEmpty()) { + logger.warn { "No target contents available for download" } + return false + } + try { + downloadPath.createDirectories() + target.contents.forEach { content -> + if (content.url == null || content.filename == null) { + logger.warn { "Missing URL or filename in content" } + return@forEach + } + val filePath = downloadPath.resolve(content.filename) + val contentHash = content.hashes?.firstOrNull() ?: run { + logger.warn { "No hash available for ${content.filename}" } + return@forEach + } + downloadAndValidateFile(content.url, filePath, contentHash) + } + validateDownloadedFiles(downloadPath, target.contents) + } catch (e: Exception) { + logger.error(e) { "Failed to download LSP artifacts: ${e.message}" } + downloadPath.toFile().deleteRecursively() + return false + } + return true + } + + private fun downloadAndValidateFile(url: String, filePath: Path, expectedHash: String) { + try { + if (!filePath.exists()) { + logger.info { "Downloading file: ${filePath.fileName}" } + saveFileFromUrl(url, filePath) + } + if (!validateFileHash(filePath, expectedHash)) { + logger.warn { "Hash mismatch for ${filePath.fileName}, re-downloading" } + filePath.deleteIfExists() + saveFileFromUrl(url, filePath) + if (!validateFileHash(filePath, expectedHash)) { + throw LspException("Hash mismatch after re-download for ${filePath.fileName}", LspException.ErrorCode.HASH_MISMATCH) + } + } + } catch (e: Exception) { + throw IllegalStateException("Failed to download/validate file: ${filePath.fileName}", e) + } + } + + private fun validateFileHash(filePath: Path, expectedHash: String?): Boolean { + if (expectedHash == null) return false + val contentHash = generateSHA384Hash(filePath) + return "sha384:$contentHash" == expectedHash + } + + private fun validateDownloadedFiles(downloadPath: Path, contents: List) { + val missingFiles = contents + .mapNotNull { it.filename } + .filter { filename -> + !downloadPath.resolve(filename).exists() + } + if (missingFiles.isNotEmpty()) { + val errorMessage = "Missing required files: ${missingFiles.joinToString(", ")}" + logger.error { errorMessage } + throw LspException(errorMessage, LspException.ErrorCode.DOWNLOAD_FAILED) + } + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManager.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManager.kt new file mode 100644 index 00000000000..26cd96032df --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManager.kt @@ -0,0 +1,111 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts + +import com.intellij.util.text.SemVer +import org.assertj.core.util.VisibleForTesting +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager + +class ArtifactManager { + + data class SupportedManifestVersionRange( + val startVersion: SemVer, + val endVersion: SemVer, + ) + data class LSPVersions( + val deListedVersions: List, + val inRangeVersions: List, + ) + + private val manifestFetcher: ManifestFetcher + private val artifactHelper: ArtifactHelper + private val manifestVersionRanges: SupportedManifestVersionRange + + // Primary constructor with config + constructor( + manifestFetcher: ManifestFetcher = ManifestFetcher(), + artifactFetcher: ArtifactHelper = ArtifactHelper(), + manifestRange: SupportedManifestVersionRange?, + ) { + manifestVersionRanges = manifestRange ?: DEFAULT_VERSION_RANGE + this.manifestFetcher = manifestFetcher + this.artifactHelper = artifactFetcher + } + + // Secondary constructor with no parameters + constructor() : this(ManifestFetcher(), ArtifactHelper(), null) + + companion object { + private val DEFAULT_VERSION_RANGE = SupportedManifestVersionRange( + startVersion = SemVer("3.0.0", 3, 0, 0), + endVersion = SemVer("4.0.0", 4, 0, 0) + ) + private val logger = getLogger() + } + + fun fetchArtifact() { + val manifest = manifestFetcher.fetch() ?: throw LspException( + "Language Support is not available, as manifest is missing.", + LspException.ErrorCode.MANIFEST_FETCH_FAILED + ) + val lspVersions = getLSPVersionsFromManifestWithSpecifiedRange(manifest) + + this.artifactHelper.removeDelistedVersions(lspVersions.deListedVersions) + + if (lspVersions.inRangeVersions.isEmpty()) { + // No versions are found which are in the given range. + throw LspException("Language server versions not found in manifest.", LspException.ErrorCode.NO_COMPATIBLE_LSP_VERSION) + } + + // If there is an LSP Manifest with the same version + val target = getTargetFromLspManifest(lspVersions.inRangeVersions) + + // Get Local LSP files and check if we can re-use existing LSP Artifacts + if (!this.artifactHelper.getExistingLspArtifacts(lspVersions.inRangeVersions, target)) { + this.artifactHelper.tryDownloadLspArtifacts(lspVersions.inRangeVersions, target) + } + + this.artifactHelper.deleteOlderLspArtifacts(manifestVersionRanges) + } + + @VisibleForTesting + internal fun getLSPVersionsFromManifestWithSpecifiedRange(manifest: ManifestManager.Manifest): LSPVersions { + if (manifest.versions.isNullOrEmpty()) return LSPVersions(emptyList(), emptyList()) + + val (deListed, inRange) = manifest.versions.mapNotNull { version -> + version.serverVersion?.let { serverVersion -> + SemVer.parseFromText(serverVersion)?.let { semVer -> + when { + version.isDelisted != false -> Pair(version, true) // Is deListed + semVer in manifestVersionRanges.startVersion..manifestVersionRanges.endVersion -> Pair(version, false) // Is in range + else -> null + } + } + } + }.partition { it.second } + + return LSPVersions( + deListedVersions = deListed.map { it.first }, + inRangeVersions = inRange.map { it.first }.sortedByDescending { (_, semVer) -> semVer } + ) + } + + private fun getTargetFromLspManifest(versions: List): ManifestManager.VersionTarget { + val currentOS = getCurrentOS() + val currentArchitecture = getCurrentArchitecture() + + val currentTarget = versions.first().targets?.find { target -> + target.platform == currentOS && target.arch == currentArchitecture + } + if (currentTarget == null) { + logger.error { "Failed to obtain target for $currentOS and $currentArchitecture" } + throw LspException("Target not found in the current Version: ${versions.first().serverVersion}", LspException.ErrorCode.TARGET_NOT_FOUND) + } + logger.info { "Target found in the current Version: ${versions.first().serverVersion}" } + return currentTarget + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspException.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspException.kt new file mode 100644 index 00000000000..6c28a239002 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspException.kt @@ -0,0 +1,20 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts + +class LspException(message: String, private val errorCode: ErrorCode, cause: Throwable? = null) : Exception(message, cause) { + + enum class ErrorCode { + MANIFEST_FETCH_FAILED, + DOWNLOAD_FAILED, + HASH_MISMATCH, + TARGET_NOT_FOUND, + NO_COMPATIBLE_LSP_VERSION, + } + + override fun toString(): String = buildString { + append("LSP Error [$errorCode]: $message") + cause?.let { append(", Cause: ${it.message}") } + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspUtils.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspUtils.kt new file mode 100644 index 00000000000..6c5510af664 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspUtils.kt @@ -0,0 +1,68 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts + +import com.intellij.openapi.util.SystemInfo +import com.intellij.openapi.util.text.StringUtil +import com.intellij.util.io.DigestUtil +import com.intellij.util.system.CpuArch +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.StandardCopyOption +import java.security.MessageDigest +import kotlin.io.path.isDirectory +import kotlin.io.path.listDirectoryEntries + +fun getToolkitsCommonCacheRoot(): Path = when { + SystemInfo.isWindows -> { + Paths.get(System.getenv("LOCALAPPDATA")) + } + SystemInfo.isMac -> { + Paths.get(System.getProperty("user.home"), "Library", "Caches") + } + else -> { + Paths.get(System.getProperty("user.home"), ".cache") + } +} + +fun getCurrentOS(): String = when { + SystemInfo.isWindows -> "windows" + SystemInfo.isMac -> "darwin" + else -> "linux" +} + +fun getCurrentArchitecture() = when (CpuArch.CURRENT) { + CpuArch.X86_64 -> "x64" + CpuArch.ARM64 -> "arm64" + else -> "unknown" +} + +fun generateMD5Hash(filePath: Path): String { + val messageDigest = DigestUtil.md5() + DigestUtil.updateContentHash(messageDigest, filePath) + return StringUtil.toHexString(messageDigest.digest()) +} + +fun generateSHA384Hash(filePath: Path): String { + val messageDigest = MessageDigest.getInstance("SHA-384") + DigestUtil.updateContentHash(messageDigest, filePath) + return StringUtil.toHexString(messageDigest.digest()) +} + +fun getSubFolders(basePath: Path): List = try { + basePath.listDirectoryEntries() + .filter { it.isDirectory() } +} catch (e: Exception) { + emptyList() +} + +fun moveFilesFromSourceToDestination(sourceDir: Path, targetDir: Path) { + try { + Files.createDirectories(targetDir.parent) + Files.move(sourceDir, targetDir, StandardCopyOption.REPLACE_EXISTING) + } catch (e: Exception) { + throw IllegalStateException("Failed to move files from $sourceDir to $targetDir", e) + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcher.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcher.kt new file mode 100644 index 00000000000..0b7d41f8286 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcher.kt @@ -0,0 +1,111 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts + +import org.assertj.core.util.VisibleForTesting +import software.aws.toolkits.core.utils.deleteIfExists +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.exists +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.core.utils.readText +import software.aws.toolkits.jetbrains.core.getETagFromUrl +import software.aws.toolkits.jetbrains.core.getTextFromUrl +import software.aws.toolkits.jetbrains.core.saveFileFromUrl +import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager +import java.nio.file.Path + +class ManifestFetcher( + private val lspManifestUrl: String = DEFAULT_MANIFEST_URL, + private val manifestManager: ManifestManager = ManifestManager(), + private val lspManifestFilePath: Path = DEFAULT_MANIFEST_PATH, +) { + companion object { + private val logger = getLogger() + + private const val DEFAULT_MANIFEST_URL = + "https://aws-toolkit-language-servers.amazonaws.com/codewhisperer/0/manifest.json" + + private val DEFAULT_MANIFEST_PATH: Path = getToolkitsCommonCacheRoot() + .resolve("aws") + .resolve("toolkits") + .resolve("language-servers") + .resolve("jetbrains-lsp-manifest.json") + } + + /** + * Method which will be used to fetch latest manifest. + * */ + fun fetch(): ManifestManager.Manifest? { + val localManifest = fetchManifestFromLocal() + if (localManifest != null) { + return localManifest + } + return fetchManifestFromRemote() + } + + @VisibleForTesting + internal fun fetchManifestFromRemote(): ManifestManager.Manifest? { + val manifest: ManifestManager.Manifest? + try { + val manifestString = getTextFromUrl(lspManifestUrl) + manifest = manifestManager.readManifestFile(manifestString) ?: return null + } catch (e: Exception) { + logger.error(e) { "error fetching lsp manifest from remote URL ${e.message}" } + return null + } + if (manifest.isManifestDeprecated == true) { + logger.info { "Manifest is deprecated" } + return null + } + updateManifestCache() + logger.info { "Using manifest found from remote URL" } + return manifest + } + + private fun updateManifestCache() { + try { + saveFileFromUrl(lspManifestUrl, lspManifestFilePath) + } catch (e: Exception) { + logger.error(e) { "error occurred while saving lsp manifest to local cache ${e.message}" } + } + } + + @VisibleForTesting + internal fun fetchManifestFromLocal(): ManifestManager.Manifest? { + val localETag = getManifestETagFromLocal() + val remoteETag = getManifestETagFromUrl() + // If local and remote have same ETag, we can re-use the manifest file from local to fetch artifacts. + // If remote manifest is null or system is offline, re-use localManifest + if ((localETag != null && remoteETag != null && localETag == remoteETag) or (localETag != null && remoteETag == null)) { + try { + val manifestContent = lspManifestFilePath.readText() + val manifest = manifestManager.readManifestFile(manifestContent) + if (manifest != null) return manifest + lspManifestFilePath.deleteIfExists() // delete manifest if it fails to de-serialize + } catch (e: Exception) { + logger.error(e) { "error reading lsp manifest file from local ${e.message}" } + return null + } + } + return null + } + + private fun getManifestETagFromLocal(): String? { + if (lspManifestFilePath.exists()) { + return generateMD5Hash(lspManifestFilePath) + } + return null + } + + private fun getManifestETagFromUrl(): String? { + try { + val actualETag = getETagFromUrl(lspManifestUrl) + return actualETag.trim('"') + } catch (e: Exception) { + logger.error(e) { "error fetching ETag of lsp manifest from url." } + } + return null + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/manifest/ManifestManager.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/manifest/ManifestManager.kt index 4693db1004f..7ac2530eb91 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/manifest/ManifestManager.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/manifest/ManifestManager.kt @@ -3,8 +3,8 @@ package software.aws.toolkits.jetbrains.services.amazonq.project.manifest -import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import com.intellij.openapi.util.SystemInfo @@ -18,10 +18,9 @@ class ManifestManager { val currentVersion = "0.1.32" val currentOs = getOs() private val arch = CpuArch.CURRENT - private val mapper = jacksonObjectMapper() + private val mapper = jacksonObjectMapper().apply { configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) } data class TargetContent( - @JsonIgnoreProperties(ignoreUnknown = true) @JsonProperty("filename") val filename: String? = null, @JsonProperty("url") @@ -33,7 +32,6 @@ class ManifestManager { ) data class VersionTarget( - @JsonIgnoreProperties(ignoreUnknown = true) @JsonProperty("platform") val platform: String? = null, @JsonProperty("arch") @@ -41,8 +39,8 @@ class ManifestManager { @JsonProperty("contents") val contents: List? = emptyList(), ) + data class Version( - @JsonIgnoreProperties(ignoreUnknown = true) @JsonProperty("serverVersion") val serverVersion: String? = null, @JsonProperty("isDelisted") @@ -52,7 +50,6 @@ class ManifestManager { ) data class Manifest( - @JsonIgnoreProperties(ignoreUnknown = true) @JsonProperty("manifestSchemaVersion") val manifestSchemaVersion: String? = null, @JsonProperty("artifactId") @@ -67,7 +64,7 @@ class ManifestManager { fun getManifest(): Manifest? = fetchFromRemoteAndSave() - private fun readManifestFile(content: String): Manifest? { + fun readManifestFile(content: String): Manifest? { try { return mapper.readValue(content) } catch (e: Exception) { diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcherTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcherTest.kt new file mode 100644 index 00000000000..62e17089eef --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcherTest.kt @@ -0,0 +1,100 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts + +import io.mockk.every +import io.mockk.mockkStatic +import org.assertj.core.api.Assertions.assertThat +import org.jetbrains.annotations.TestOnly +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.never +import org.mockito.kotlin.reset +import org.mockito.kotlin.spy +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import software.aws.toolkits.jetbrains.core.getTextFromUrl +import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager + +@TestOnly +class ManifestFetcherTest { + + private lateinit var manifestFetcher: ManifestFetcher + private lateinit var manifest: ManifestManager.Manifest + private lateinit var manifestManager: ManifestManager + + @BeforeEach + fun setup() { + manifestFetcher = spy(ManifestFetcher()) + manifestManager = spy(ManifestManager()) + manifest = ManifestManager.Manifest() + } + + @Test + fun `should return null when both local and remote manifests are null`() { + whenever(manifestFetcher.fetchManifestFromLocal()).thenReturn(null) + whenever(manifestFetcher.fetchManifestFromRemote()).thenReturn(null) + + assertThat(manifestFetcher.fetch()).isNull() + } + + @Test + fun `should return valid result from local should not execute remote method`() { + reset(manifestFetcher) + whenever(manifestFetcher.fetchManifestFromLocal()).thenReturn(manifest) + + assertThat(manifestFetcher.fetch()).isNotNull().isEqualTo(manifest) + verify(manifestFetcher, atLeastOnce()).fetchManifestFromLocal() + verify(manifestFetcher, never()).fetchManifestFromRemote() + } + + @Test + fun `should return valid result from remote`() { + whenever(manifestFetcher.fetchManifestFromLocal()).thenReturn(null) + whenever(manifestFetcher.fetchManifestFromRemote()).thenReturn(manifest) + + assertThat(manifestFetcher.fetch()).isNotNull().isEqualTo(manifest) + verify(manifestFetcher, atLeastOnce()).fetchManifestFromLocal() + verify(manifestFetcher, atLeastOnce()).fetchManifestFromRemote() + } + + @Test + fun `fetchManifestFromRemote should return null due to invalid manifestString`() { + mockkStatic("software.aws.toolkits.jetbrains.core.HttpUtilsKt") + every { getTextFromUrl(any()) } returns "ManifestContent" + + whenever(manifestManager.readManifestFile("")).thenReturn(null) + + assertThat(manifestFetcher.fetchManifestFromRemote()).isNull() + } + + @Test + fun `fetchManifestFromRemote should return manifest and update manifest`() { + val validManifest = ManifestManager.Manifest(manifestSchemaVersion = "1.0") + mockkStatic("software.aws.toolkits.jetbrains.core.HttpUtilsKt") + + every { getTextFromUrl(any()) } returns "{ \"manifestSchemaVersion\": \"1.0\" }" + + val result = manifestFetcher.fetchManifestFromRemote() + assertThat(result).isNotNull().isEqualTo(validManifest) + } + + @Test + fun `fetchManifestFromRemote should return null if manifest is deprecated`() { + mockkStatic("software.aws.toolkits.jetbrains.core.HttpUtilsKt") + every { getTextFromUrl(any()) } returns "ManifestContent" + + val deprecatedManifest = ManifestManager.Manifest(isManifestDeprecated = true) + + whenever(manifestManager.readManifestFile("")).thenReturn(deprecatedManifest) + + assertThat(manifestFetcher.fetchManifestFromRemote()).isNull() + } + + @Test + fun `fetchManifestFromLocal should return null`() { + assertThat(manifestFetcher.fetchManifestFromLocal()).isNull() + } +} diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/DefaultRemoteResourceResolverProvider.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/DefaultRemoteResourceResolverProvider.kt index af45361ac3d..1caf2e8e463 100644 --- a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/DefaultRemoteResourceResolverProvider.kt +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/DefaultRemoteResourceResolverProvider.kt @@ -4,7 +4,6 @@ package software.aws.toolkits.jetbrains.core import com.intellij.openapi.application.PathManager -import com.intellij.util.io.HttpRequests import com.intellij.util.io.createDirectories import software.aws.toolkits.core.utils.DefaultRemoteResourceResolver import software.aws.toolkits.core.utils.UrlFetcher @@ -41,11 +40,7 @@ class DefaultRemoteResourceResolverProvider : RemoteResourceResolverProvider { } override fun getETag(url: String): String = - HttpRequests.head(url) - .userAgent("AWS Toolkit for JetBrains") - .connect { request -> - request.connection.headerFields["ETag"]?.firstOrNull().orEmpty() - } + getETagFromUrl(url) } } } diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/HttpUtils.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/HttpUtils.kt index 27958005fe4..8456be0f2bc 100644 --- a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/HttpUtils.kt +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/HttpUtils.kt @@ -24,3 +24,10 @@ fun writeJsonToUrl(url: String, jsonString: String, indicator: ProgressIndicator request.write(jsonString) request.readString(indicator) } + +fun getETagFromUrl(url: String): String = + HttpRequests.head(url) + .userAgent(AwsClientManager.getUserAgent()) + .connect { request -> + request.connection.headerFields["ETag"]?.firstOrNull().orEmpty() + }