diff --git a/src/main/java/dev/ayagmar/quarkusforge/api/CatalogData.java b/src/main/java/dev/ayagmar/quarkusforge/api/CatalogData.java index 568598ed..3aab4bcc 100644 --- a/src/main/java/dev/ayagmar/quarkusforge/api/CatalogData.java +++ b/src/main/java/dev/ayagmar/quarkusforge/api/CatalogData.java @@ -7,11 +7,13 @@ public record CatalogData( MetadataDto metadata, List extensions, CatalogSource source, + MetadataSource metadataSource, boolean stale, String detailMessage) { public CatalogData { metadata = Objects.requireNonNull(metadata); source = Objects.requireNonNull(source); + metadataSource = Objects.requireNonNull(metadataSource); extensions = List.copyOf(Objects.requireNonNull(extensions)); detailMessage = detailMessage == null ? "" : detailMessage.strip(); @@ -21,10 +23,38 @@ public record CatalogData( if (source != CatalogSource.CACHE && stale) { throw new IllegalArgumentException("Only cache-sourced catalog data can be stale"); } + if (source == CatalogSource.CACHE && metadataSource != MetadataSource.CACHE) { + throw new IllegalArgumentException("Cache-sourced catalog data must use cache metadata"); + } + if (source == CatalogSource.LIVE && metadataSource == MetadataSource.CACHE) { + throw new IllegalArgumentException("Live catalog data cannot use cache metadata"); + } + } + + public CatalogData( + MetadataDto metadata, + List extensions, + CatalogSource source, + boolean stale, + String detailMessage) { + this( + metadata, + extensions, + source, + source == CatalogSource.CACHE ? MetadataSource.CACHE : MetadataSource.LIVE, + stale, + detailMessage); } /** Returns the source label with a "[stale]" suffix when applicable. */ public String sourceLabel() { return stale ? source.label() + " [stale]" : source.label(); } + + /** Returns the metadata source label with a "[stale]" suffix when applicable. */ + public String metadataSourceLabel() { + return metadataSource == MetadataSource.CACHE && stale + ? metadataSource.label() + " [stale]" + : metadataSource.label(); + } } diff --git a/src/main/java/dev/ayagmar/quarkusforge/api/CatalogDataService.java b/src/main/java/dev/ayagmar/quarkusforge/api/CatalogDataService.java index 3fa4f52e..2eb3a2c5 100644 --- a/src/main/java/dev/ayagmar/quarkusforge/api/CatalogDataService.java +++ b/src/main/java/dev/ayagmar/quarkusforge/api/CatalogDataService.java @@ -54,24 +54,34 @@ private CatalogData toLiveCatalogData( throw new ApiContractException("Catalog load returned no extensions"); } - CacheWriteOutcome writeOutcome = snapshotCache.write(metadataSelection.metadata(), extensions); String detailMessage = metadataSelection.detailMessage(); - if (!writeOutcome.written()) { - String cacheWriteDetail = - writeOutcome.rejected() - ? "Live catalog loaded; cache update skipped (%s)".formatted(writeOutcome.detail()) - : "Live catalog loaded; cache update failed (%s)".formatted(writeOutcome.detail()); - detailMessage = - detailMessage.isBlank() ? cacheWriteDetail : detailMessage + " | " + cacheWriteDetail; + MetadataSource metadataSource = + metadataSelection.liveMetadata() ? MetadataSource.LIVE : MetadataSource.SNAPSHOT; + if (metadataSelection.liveMetadata()) { + CacheWriteOutcome writeOutcome = + snapshotCache.write(metadataSelection.metadata(), extensions); + if (!writeOutcome.written()) { + String cacheWriteDetail = + writeOutcome.rejected() + ? "Live catalog loaded; cache update skipped (%s)".formatted(writeOutcome.detail()) + : "Live catalog loaded; cache update failed (%s)".formatted(writeOutcome.detail()); + detailMessage = + detailMessage.isBlank() ? cacheWriteDetail : detailMessage + " | " + cacheWriteDetail; + } } return new CatalogData( - metadataSelection.metadata(), extensions, CatalogSource.LIVE, false, detailMessage); + metadataSelection.metadata(), + extensions, + CatalogSource.LIVE, + metadataSource, + false, + detailMessage); } private CompletableFuture loadMetadataSelection() { return apiClient .fetchMetadata() - .thenApply(metadata -> new MetadataSelection(metadata, "")) + .thenApply(metadata -> new MetadataSelection(metadata, true, "")) .exceptionally(this::fallbackMetadataSelection); } @@ -81,6 +91,7 @@ private MetadataSelection fallbackMetadataSelection(Throwable throwable) { MetadataDto snapshotMetadata = MetadataSnapshotLoader.loadDefault(); return new MetadataSelection( snapshotMetadata, + false, "Live metadata unavailable (%s); using bundled metadata snapshot" .formatted(ErrorMessageMapper.simpleError(cause))); } catch (RuntimeException snapshotFailure) { diff --git a/src/main/java/dev/ayagmar/quarkusforge/api/MetadataSelection.java b/src/main/java/dev/ayagmar/quarkusforge/api/MetadataSelection.java index c0e631e2..d31c9c4c 100644 --- a/src/main/java/dev/ayagmar/quarkusforge/api/MetadataSelection.java +++ b/src/main/java/dev/ayagmar/quarkusforge/api/MetadataSelection.java @@ -2,7 +2,7 @@ import java.util.Objects; -record MetadataSelection(MetadataDto metadata, String detailMessage) { +record MetadataSelection(MetadataDto metadata, boolean liveMetadata, String detailMessage) { MetadataSelection { metadata = Objects.requireNonNull(metadata); detailMessage = detailMessage == null ? "" : detailMessage.strip(); diff --git a/src/main/java/dev/ayagmar/quarkusforge/api/MetadataSource.java b/src/main/java/dev/ayagmar/quarkusforge/api/MetadataSource.java new file mode 100644 index 00000000..1e44ddeb --- /dev/null +++ b/src/main/java/dev/ayagmar/quarkusforge/api/MetadataSource.java @@ -0,0 +1,17 @@ +package dev.ayagmar.quarkusforge.api; + +public enum MetadataSource { + LIVE("live"), + SNAPSHOT("bundled snapshot"), + CACHE("cache"); + + private final String label; + + MetadataSource(String label) { + this.label = label; + } + + public String label() { + return label; + } +} diff --git a/src/main/java/dev/ayagmar/quarkusforge/api/QuarkusApiClient.java b/src/main/java/dev/ayagmar/quarkusforge/api/QuarkusApiClient.java index 0d1ba30a..d0b1d054 100644 --- a/src/main/java/dev/ayagmar/quarkusforge/api/QuarkusApiClient.java +++ b/src/main/java/dev/ayagmar/quarkusforge/api/QuarkusApiClient.java @@ -28,8 +28,6 @@ import java.util.function.Supplier; public final class QuarkusApiClient implements AutoCloseable { - private static final List FALLBACK_BUILD_TOOLS = - List.of("maven", "gradle", "gradle-kotlin-dsl"); private static final Duration MAX_RETRY_DELAY = Duration.ofSeconds(30); private final ApiTransport transport; @@ -100,8 +98,7 @@ public CompletableFuture fetchMetadata() { sendWithRetry(() -> transport.sendStringAsync(openApiRequest), 1) .thenApply(this::assertSuccessful) .thenApply(ApiStringResponse::body) - .thenApply(ApiPayloadParser::parseBuildToolsFromOpenApiPayload) - .exceptionally(ignored -> fallbackBuildTools()); + .thenApply(ApiPayloadParser::parseBuildToolsFromOpenApiPayload); return streamsMetadataFuture.thenCombine(buildToolsFuture, QuarkusApiClient::toMetadata); } @@ -138,14 +135,6 @@ private static MetadataDto toMetadata(StreamsMetadata streamsMetadata, List fallbackBuildTools() { - try { - return MetadataSnapshotLoader.loadDefault().buildTools(); - } catch (RuntimeException ignored) { - return FALLBACK_BUILD_TOOLS; - } - } - public CompletableFuture downloadProjectZipToFile( GenerationRequest generationRequest, Path destinationFile) { URI uri = GenerationQueryBuilder.build(baseUri, generationRequest); diff --git a/src/main/java/dev/ayagmar/quarkusforge/application/DefaultStartupStateService.java b/src/main/java/dev/ayagmar/quarkusforge/application/DefaultStartupStateService.java index 713137a5..b735c792 100644 --- a/src/main/java/dev/ayagmar/quarkusforge/application/DefaultStartupStateService.java +++ b/src/main/java/dev/ayagmar/quarkusforge/application/DefaultStartupStateService.java @@ -33,6 +33,19 @@ private static CliPrefill mergePrefill( CliPrefill requestedPrefill, CliPrefill storedPrefill, CliPrefill defaults) { CliPrefill safeRequestedPrefill = Objects.requireNonNull(requestedPrefill, "requestedPrefill must not be null"); + boolean coordinatesOverridden = + overridesStoredOrDefault( + safeRequestedPrefill.groupId(), storedPrefill, CliPrefill::groupId, defaults) + || overridesStoredOrDefault( + safeRequestedPrefill.artifactId(), storedPrefill, CliPrefill::artifactId, defaults); + String packageName = + coordinatesOverridden && !hasText(safeRequestedPrefill.packageName()) + ? defaults.packageName() + : preferRequested( + safeRequestedPrefill.packageName(), + storedPrefill, + CliPrefill::packageName, + defaults); return new CliPrefill( preferRequested( safeRequestedPrefill.groupId(), storedPrefill, CliPrefill::groupId, defaults), @@ -40,8 +53,7 @@ private static CliPrefill mergePrefill( safeRequestedPrefill.artifactId(), storedPrefill, CliPrefill::artifactId, defaults), preferRequested( safeRequestedPrefill.version(), storedPrefill, CliPrefill::version, defaults), - preferRequested( - safeRequestedPrefill.packageName(), storedPrefill, CliPrefill::packageName, defaults), + packageName, preferRequested( safeRequestedPrefill.outputDirectory(), storedPrefill, @@ -72,6 +84,16 @@ private static String preferRequested( return field.read(defaults); } + private static boolean overridesStoredOrDefault( + String requestedValue, CliPrefill storedPrefill, PrefillField field, CliPrefill defaults) { + if (!hasText(requestedValue)) { + return false; + } + String requested = requestedValue.strip(); + String baseline = preferRequested(null, storedPrefill, field, defaults); + return !requested.equals(baseline); + } + private static boolean hasText(String value) { return value != null && !value.isBlank(); } diff --git a/src/main/java/dev/ayagmar/quarkusforge/archive/ProjectArchiveService.java b/src/main/java/dev/ayagmar/quarkusforge/archive/ProjectArchiveService.java index 1c3e0e80..e7bcdb44 100644 --- a/src/main/java/dev/ayagmar/quarkusforge/archive/ProjectArchiveService.java +++ b/src/main/java/dev/ayagmar/quarkusforge/archive/ProjectArchiveService.java @@ -110,10 +110,8 @@ public CompletableFuture downloadAndExtract( } progressListener.accept(ProgressStep.EXTRACTING_ARCHIVE); ExtractionResult result = - zipExtractor.extract(archivePath, outputDirectory, overwritePolicy); - if (cancelled.getAsBoolean()) { - throw new CancellationException("Generation cancelled during extraction"); - } + zipExtractor.extract( + archivePath, outputDirectory, overwritePolicy, cancelled); return result.extractedRoot(); }, extractionExecutor); diff --git a/src/main/java/dev/ayagmar/quarkusforge/archive/SafeZipExtractor.java b/src/main/java/dev/ayagmar/quarkusforge/archive/SafeZipExtractor.java index 300c2a74..eeb15fe3 100644 --- a/src/main/java/dev/ayagmar/quarkusforge/archive/SafeZipExtractor.java +++ b/src/main/java/dev/ayagmar/quarkusforge/archive/SafeZipExtractor.java @@ -20,6 +20,8 @@ import java.util.Objects; import java.util.Set; import java.util.UUID; +import java.util.concurrent.CancellationException; +import java.util.function.BooleanSupplier; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; @@ -37,9 +39,19 @@ public SafeZipExtractor(ArchiveSafetyPolicy safetyPolicy) { public ExtractionResult extract( Path zipFile, Path outputDirectory, OverwritePolicy overwritePolicy) { + return extract(zipFile, outputDirectory, overwritePolicy, () -> false); + } + + public ExtractionResult extract( + Path zipFile, + Path outputDirectory, + OverwritePolicy overwritePolicy, + BooleanSupplier cancelled) { Objects.requireNonNull(zipFile); Objects.requireNonNull(outputDirectory); Objects.requireNonNull(overwritePolicy); + Objects.requireNonNull(cancelled); + throwIfCancelled(cancelled); Map metadataByEntry = ZipCentralDirectoryReader.read(zipFile); validateEntryMetadata(metadataByEntry); @@ -65,7 +77,11 @@ public ExtractionResult extract( Path stagingContent = stagingRoot.resolve("content"); try { Files.createDirectories(stagingContent); - ExtractionResult stagedResult = extractIntoStaging(zipFile, stagingContent, metadataByEntry); + ExtractionResult stagedResult = + extractIntoStaging(zipFile, stagingContent, metadataByEntry, cancelled); + if (cancelled.getAsBoolean()) { + throw new CancellationException("Generation cancelled during extraction"); + } Path extractedRoot = stagedResult.extractedRoot(); applyOverwritePolicy(extractedRoot, absoluteTarget, overwritePolicy); return new ExtractionResult( @@ -129,7 +145,10 @@ private void validateEntryMetadata(Map metadataByEntry } private ExtractionResult extractIntoStaging( - Path zipFile, Path stagingContent, Map metadataByEntry) + Path zipFile, + Path stagingContent, + Map metadataByEntry, + BooleanSupplier cancelled) throws IOException { Set seenEntries = new LinkedHashSet<>(); Set topLevelSegments = new LinkedHashSet<>(); @@ -139,6 +158,7 @@ private ExtractionResult extractIntoStaging( try (ZipInputStream zipInputStream = new ZipInputStream(Files.newInputStream(zipFile))) { ZipEntry zipEntry; while ((zipEntry = zipInputStream.getNextEntry()) != null) { + throwIfCancelled(cancelled); String normalizedName = normalizeEntryName(zipEntry.getName()); ZipEntryMetadata metadata = metadataByEntry.get(normalizedName); if (metadata == null) { @@ -176,7 +196,8 @@ private ExtractionResult extractIntoStaging( destination, normalizedName, metadata.uncompressedSize(), - extractedBytes); + extractedBytes, + cancelled); long copied = copyResult.entryBytes(); extractedBytes = copyResult.totalExtractedBytes(); @@ -255,7 +276,8 @@ private CopyResult copyEntry( Path destination, String entryName, long expectedUncompressedSize, - long extractedBytesSoFar) + long extractedBytesSoFar, + BooleanSupplier cancelled) throws IOException { long copied = 0L; long totalExtractedBytes = extractedBytesSoFar; @@ -265,6 +287,7 @@ private CopyResult copyEntry( destination, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { int read; while ((read = inputStream.read(buffer)) != -1) { + throwIfCancelled(cancelled); long nextCopied = safeAdd(copied, read); if (nextCopied > expectedUncompressedSize) { throw new ArchiveException( @@ -290,6 +313,12 @@ private CopyResult copyEntry( return new CopyResult(copied, totalExtractedBytes); } + private static void throwIfCancelled(BooleanSupplier cancelled) { + if (cancelled.getAsBoolean()) { + throw new CancellationException("Generation cancelled during extraction"); + } + } + static String normalizeEntryName(String rawName) { if (rawName == null || rawName.isBlank()) { throw new ArchiveException("ZIP entry name must not be blank"); diff --git a/src/main/java/dev/ayagmar/quarkusforge/headless/HeadlessGenerationService.java b/src/main/java/dev/ayagmar/quarkusforge/headless/HeadlessGenerationService.java index c282d22b..41304b9f 100644 --- a/src/main/java/dev/ayagmar/quarkusforge/headless/HeadlessGenerationService.java +++ b/src/main/java/dev/ayagmar/quarkusforge/headless/HeadlessGenerationService.java @@ -120,7 +120,7 @@ public int run(GenerateCommand command, boolean globalDryRun, boolean verbose) { if (!validatedState.canSubmit()) { return validationFailure( validatedState.validation(), - catalogData.sourceLabel(), + catalogData.metadataSourceLabel(), catalogData.detailMessage(), diagnostics); } @@ -157,6 +157,7 @@ private CatalogData loadCatalogData(Duration catalogTimeout, DiagnosticLogger di diagnostics.info( "catalog.load.success", of("source", catalogData.sourceLabel()), + of("metadataSource", catalogData.metadataSourceLabel()), of("stale", catalogData.stale()), of("detail", catalogData.detailMessage())); return catalogData; @@ -188,7 +189,7 @@ private static int extensionValidationFailure( of("errorCount", validationException.errors().size())); HeadlessOutputPrinter.printValidationErrors( new ValidationReport(validationException.errors()), - catalogData.sourceLabel(), + catalogData.metadataSourceLabel(), catalogData.detailMessage()); return ExitCodes.VALIDATION; } @@ -203,8 +204,14 @@ private int handleDryRun( "generate.dry-run.validated", of("extensionCount", extensionIds.size()), of("catalogSource", catalogData.sourceLabel()), + of("metadataSource", catalogData.metadataSourceLabel()), of("stale", catalogData.stale())); - HeadlessOutputPrinter.printDryRunSummary(request, extensionIds, catalogData.sourceLabel()); + HeadlessOutputPrinter.printDryRunSummary( + request, + extensionIds, + catalogData.sourceLabel(), + catalogData.metadataSourceLabel(), + catalogData.detailMessage()); return persistForgefileAndReturn(inputs, request, extensionIds, diagnostics, ExitCodes.OK); } diff --git a/src/main/java/dev/ayagmar/quarkusforge/headless/HeadlessOutputPrinter.java b/src/main/java/dev/ayagmar/quarkusforge/headless/HeadlessOutputPrinter.java index 67943fdf..cad7e791 100644 --- a/src/main/java/dev/ayagmar/quarkusforge/headless/HeadlessOutputPrinter.java +++ b/src/main/java/dev/ayagmar/quarkusforge/headless/HeadlessOutputPrinter.java @@ -38,13 +38,31 @@ public static void printPrefillSummary( } public static void printDryRunSummary( - ProjectRequest request, List extensionIds, String sourceLabel) { + ProjectRequest request, + List extensionIds, + String catalogSourceLabel, + String metadataSourceLabel, + String metadataDetail) { System.out.println("Dry-run validated successfully:"); printRequestFields(request); System.out.println(" - extensions: " + extensionIds); - String effectiveSourceLabel = - (sourceLabel == null || sourceLabel.isBlank()) ? "unknown" : sourceLabel; - System.out.println(" - catalogSource: " + effectiveSourceLabel); + String effectiveCatalogSourceLabel = + (catalogSourceLabel == null || catalogSourceLabel.isBlank()) + ? "unknown" + : catalogSourceLabel; + System.out.println(" - catalogSource: " + effectiveCatalogSourceLabel); + + String effectiveMetadataSourceLabel = + metadataSourceLabel == null ? "" : metadataSourceLabel.strip(); + boolean hasMetadataDetail = metadataDetail != null && !metadataDetail.isBlank(); + if (!effectiveMetadataSourceLabel.isBlank() + && (!effectiveMetadataSourceLabel.equals(effectiveCatalogSourceLabel) + || hasMetadataDetail)) { + System.out.println(" - metadataSource: " + effectiveMetadataSourceLabel); + } + if (hasMetadataDetail) { + System.out.println(" - metadataDetail: " + metadataDetail); + } System.out.println(" - generatedProjectDirectory: " + resolveProjectDirectory(request)); } diff --git a/src/main/java/dev/ayagmar/quarkusforge/runtime/CatalogLoadDiagnostics.java b/src/main/java/dev/ayagmar/quarkusforge/runtime/CatalogLoadDiagnostics.java index fbaa45c1..2ae05edd 100644 --- a/src/main/java/dev/ayagmar/quarkusforge/runtime/CatalogLoadDiagnostics.java +++ b/src/main/java/dev/ayagmar/quarkusforge/runtime/CatalogLoadDiagnostics.java @@ -30,14 +30,17 @@ static BiFunction catalogLoa "catalog.load.success", of("mode", mode), of("source", catalogData.source().label()), + of("metadataSource", catalogData.metadataSourceLabel()), of("stale", catalogData.stale()), of("detail", catalogData.detailMessage())); return new ExtensionCatalogLoadResult( catalogData.extensions(), catalogData.source(), + catalogData.metadataSource(), catalogData.stale(), catalogData.detailMessage(), - catalogData.metadata()); + catalogData.metadata(), + Map.of()); } BoundaryFailure.Details failure = BoundaryFailure.fromThrowable(throwable); switch (failure.kind()) { diff --git a/src/main/java/dev/ayagmar/quarkusforge/runtime/TuiBootstrapService.java b/src/main/java/dev/ayagmar/quarkusforge/runtime/TuiBootstrapService.java index 14ba023d..bde591c7 100644 --- a/src/main/java/dev/ayagmar/quarkusforge/runtime/TuiBootstrapService.java +++ b/src/main/java/dev/ayagmar/quarkusforge/runtime/TuiBootstrapService.java @@ -327,6 +327,7 @@ TuiSessionSummary runSession( new ExtensionCatalogLoadResult( loadResult.extensions(), loadResult.source(), + loadResult.metadataSource(), loadResult.stale(), loadResult.detailMessage(), loadResult.metadata(), diff --git a/src/main/java/dev/ayagmar/quarkusforge/ui/ExtensionCatalogLoadResult.java b/src/main/java/dev/ayagmar/quarkusforge/ui/ExtensionCatalogLoadResult.java index 844165f8..4c91d818 100644 --- a/src/main/java/dev/ayagmar/quarkusforge/ui/ExtensionCatalogLoadResult.java +++ b/src/main/java/dev/ayagmar/quarkusforge/ui/ExtensionCatalogLoadResult.java @@ -3,6 +3,7 @@ import dev.ayagmar.quarkusforge.api.CatalogSource; import dev.ayagmar.quarkusforge.api.ExtensionDto; import dev.ayagmar.quarkusforge.api.MetadataDto; +import dev.ayagmar.quarkusforge.api.MetadataSource; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; @@ -12,6 +13,7 @@ public record ExtensionCatalogLoadResult( List extensions, CatalogSource source, + MetadataSource metadataSource, boolean stale, String detailMessage, MetadataDto metadata, @@ -19,12 +21,36 @@ public record ExtensionCatalogLoadResult( public ExtensionCatalogLoadResult { extensions = List.copyOf(Objects.requireNonNull(extensions)); source = Objects.requireNonNull(source); + metadataSource = Objects.requireNonNull(metadataSource); detailMessage = detailMessage == null ? "" : detailMessage.strip(); presetExtensionsByName = normalizePresetMap(presetExtensionsByName); if (source != CatalogSource.CACHE && stale) { throw new IllegalArgumentException("stale flag is allowed only for cache source"); } + if (source == CatalogSource.CACHE && metadataSource != MetadataSource.CACHE) { + throw new IllegalArgumentException("cache source must use cache metadata"); + } + if (source == CatalogSource.LIVE && metadataSource == MetadataSource.CACHE) { + throw new IllegalArgumentException("live source cannot use cache metadata"); + } + } + + public ExtensionCatalogLoadResult( + List extensions, + CatalogSource source, + boolean stale, + String detailMessage, + MetadataDto metadata, + Map> presetExtensionsByName) { + this( + extensions, + source, + source == CatalogSource.CACHE ? MetadataSource.CACHE : MetadataSource.LIVE, + stale, + detailMessage, + metadata, + presetExtensionsByName); } public ExtensionCatalogLoadResult( @@ -38,7 +64,13 @@ public ExtensionCatalogLoadResult( public static ExtensionCatalogLoadResult live(List extensions) { return new ExtensionCatalogLoadResult( - extensions, CatalogSource.LIVE, false, "", null, Map.of()); + extensions, CatalogSource.LIVE, MetadataSource.LIVE, false, "", null, Map.of()); + } + + public String metadataSourceLabel() { + return metadataSource == MetadataSource.CACHE && stale + ? metadataSource.label() + " [stale]" + : metadataSource.label(); } private static Map> normalizePresetMap( diff --git a/src/test/java/dev/ayagmar/quarkusforge/api/CatalogDataServiceTest.java b/src/test/java/dev/ayagmar/quarkusforge/api/CatalogDataServiceTest.java index 5621aaec..7b57ceb5 100644 --- a/src/test/java/dev/ayagmar/quarkusforge/api/CatalogDataServiceTest.java +++ b/src/test/java/dev/ayagmar/quarkusforge/api/CatalogDataServiceTest.java @@ -61,6 +61,7 @@ void offlineLoadUsesCachedSnapshotWhenAvailable() { CatalogData cachedData = offlineService.load().join(); assertThat(cachedData.source()).isEqualTo(CatalogSource.CACHE); + assertThat(cachedData.metadataSource()).isEqualTo(MetadataSource.CACHE); assertThat(cachedData.stale()).isFalse(); assertThat(cachedData.detailMessage()).contains("using cached snapshot"); assertThat(cachedData.extensions()).isEqualTo(liveData.extensions()); @@ -84,6 +85,7 @@ void offlineLoadMarksCacheAsStaleWhenTtlIsExpired() { CatalogData cachedData = offlineService.load().join(); assertThat(cachedData.source()).isEqualTo(CatalogSource.CACHE); + assertThat(cachedData.metadataSource()).isEqualTo(MetadataSource.CACHE); assertThat(cachedData.stale()).isTrue(); assertThat(cachedData.detailMessage()).contains("stale"); } @@ -116,9 +118,42 @@ void liveExtensionsStillLoadWhenStreamsEndpointIsUnavailable() { CatalogData liveData = service.load().join(); assertThat(liveData.source()).isEqualTo(CatalogSource.LIVE); + assertThat(liveData.metadataSource()).isEqualTo(MetadataSource.SNAPSHOT); assertThat(liveData.extensions()).hasSize(2); assertThat(liveData.detailMessage()).contains("Live metadata unavailable"); - assertThat(new CatalogSnapshotCache(cacheFile).read()).isPresent(); + assertThat(new CatalogSnapshotCache(cacheFile).read()).isEmpty(); + } + } + + @Test + void bundledMetadataFallbackDoesNotOverwriteExistingCacheSnapshot() { + stubExtensionsWithMetadataUnavailable(); + Path cacheFile = tempDir.resolve("catalog-snapshot.json"); + CatalogSnapshotCache snapshotCache = snapshotCache(cacheFile, "2026-02-22T00:00:00Z"); + MetadataDto cachedMetadata = + new MetadataDto( + java.util.List.of("21"), + java.util.List.of("maven"), + java.util.Map.of("maven", java.util.List.of("21")), + java.util.List.of( + new PlatformStream( + "io.quarkus.platform:3.20", "3.20", true, java.util.List.of("21")))); + java.util.List cachedExtensions = + java.util.List.of(new ExtensionDto("io.quarkus:cached", "Cached", "cached")); + assertThat(snapshotCache.write(cachedMetadata, cachedExtensions).written()).isTrue(); + + try (QuarkusApiClient onlineApiClient = onlineClient()) { + CatalogDataService service = new CatalogDataService(onlineApiClient, snapshotCache); + + CatalogData liveData = service.load().join(); + CachedCatalogSnapshot cachedSnapshot = + new CatalogSnapshotCache(cacheFile).read().orElseThrow(); + + assertThat(liveData.source()).isEqualTo(CatalogSource.LIVE); + assertThat(liveData.metadataSource()).isEqualTo(MetadataSource.SNAPSHOT); + assertThat(liveData.detailMessage()).contains("using bundled metadata snapshot"); + assertThat(cachedSnapshot.metadata()).isEqualTo(cachedMetadata); + assertThat(cachedSnapshot.extensions()).isEqualTo(cachedExtensions); } } @@ -187,6 +222,7 @@ void startupLoadUsesCacheWithoutCallingLiveEndpoints() { CatalogData startupData = startupService.loadForStartup().join(); assertThat(startupData.source()).isEqualTo(CatalogSource.CACHE); + assertThat(startupData.metadataSource()).isEqualTo(MetadataSource.CACHE); assertThat(startupData.detailMessage()).contains("startup"); wireMockServer.verify(0, getRequestedFor(urlEqualTo("/api/extensions"))); wireMockServer.verify(0, getRequestedFor(urlEqualTo("/api/streams"))); @@ -213,6 +249,7 @@ void startupLoadUsesStaleCache() { CatalogData startupData = startupService.loadForStartup().join(); assertThat(startupData.source()).isEqualTo(CatalogSource.CACHE); + assertThat(startupData.metadataSource()).isEqualTo(MetadataSource.CACHE); assertThat(startupData.stale()).isTrue(); assertThat(startupData.detailMessage()).contains("stale").contains("startup"); } @@ -231,6 +268,7 @@ void startupLoadFallsBackToLiveWhenNoCacheExists() { // No cache -> falls back to load() which hits live endpoints assertThat(startupData.source()).isEqualTo(CatalogSource.LIVE); + assertThat(startupData.metadataSource()).isEqualTo(MetadataSource.LIVE); assertThat(startupData.extensions()).hasSize(2); } } diff --git a/src/test/java/dev/ayagmar/quarkusforge/api/CatalogDataTest.java b/src/test/java/dev/ayagmar/quarkusforge/api/CatalogDataTest.java index 88c7eda2..96cf2d12 100644 --- a/src/test/java/dev/ayagmar/quarkusforge/api/CatalogDataTest.java +++ b/src/test/java/dev/ayagmar/quarkusforge/api/CatalogDataTest.java @@ -36,6 +36,32 @@ void sourceLabelLiveSourceShowsLive() { assertThat(data.sourceLabel()).isEqualTo("live"); } + @Test + void metadataSourceDefaultsMatchCatalogSource() { + CatalogData liveData = new CatalogData(METADATA, EXTENSIONS, CatalogSource.LIVE, false, ""); + CatalogData cachedData = + new CatalogData(METADATA, EXTENSIONS, CatalogSource.CACHE, true, "stale"); + + assertThat(liveData.metadataSource()).isEqualTo(MetadataSource.LIVE); + assertThat(cachedData.metadataSource()).isEqualTo(MetadataSource.CACHE); + assertThat(cachedData.metadataSourceLabel()).isEqualTo("cache [stale]"); + } + + @Test + void supportsLiveCatalogWithBundledSnapshotMetadata() { + CatalogData data = + new CatalogData( + METADATA, + EXTENSIONS, + CatalogSource.LIVE, + MetadataSource.SNAPSHOT, + false, + "snapshot fallback"); + + assertThat(data.metadataSource()).isEqualTo(MetadataSource.SNAPSHOT); + assertThat(data.metadataSourceLabel()).isEqualTo("bundled snapshot"); + } + @Test void rejectsStaleWithNonCacheSource() { assertThatThrownBy(() -> new CatalogData(METADATA, EXTENSIONS, CatalogSource.LIVE, true, "")) @@ -50,6 +76,16 @@ void rejectsEmptyExtensions() { .hasMessageContaining("must not be empty"); } + @Test + void rejectsIncompatibleMetadataSource() { + assertThatThrownBy( + () -> + new CatalogData( + METADATA, EXTENSIONS, CatalogSource.CACHE, MetadataSource.SNAPSHOT, false, "")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cache metadata"); + } + @Test void detailMessageIsStrippedAndDefaultedWhenNull() { CatalogData data = new CatalogData(METADATA, EXTENSIONS, CatalogSource.LIVE, false, null); diff --git a/src/test/java/dev/ayagmar/quarkusforge/api/QuarkusApiClientTest.java b/src/test/java/dev/ayagmar/quarkusforge/api/QuarkusApiClientTest.java index 042a3ff9..7dfa5760 100644 --- a/src/test/java/dev/ayagmar/quarkusforge/api/QuarkusApiClientTest.java +++ b/src/test/java/dev/ayagmar/quarkusforge/api/QuarkusApiClientTest.java @@ -513,21 +513,20 @@ void fetchMetadataReturnsParsedDtoFromStreamsAndOpenApi() { } @Test - void fetchMetadataFallsBackToDefaultBuildToolsWhenOpenApiIsUnavailable() { + void fetchMetadataFailsWhenOpenApiIsUnavailable() { stubStreamsPayload(List.of(17, 21, 25)); stubFor(get(urlEqualTo("/q/openapi")).willReturn(aResponse().withStatus(404))); try (QuarkusApiClient client = newClient(RetryPolicy.defaults(), new RecordingSleeper())) { - MetadataDto metadata = client.fetchMetadata().join(); - - assertThat(metadata.buildTools()).containsExactly("maven", "gradle", "gradle-kotlin-dsl"); - assertThat(metadata.compatibility().get("gradle-kotlin-dsl")) - .containsExactly("17", "21", "25"); + assertThatThrownBy(() -> client.fetchMetadata().join()) + .hasRootCauseInstanceOf(ApiHttpException.class) + .rootCause() + .hasMessageContaining("Unexpected HTTP status 404"); } } @Test - void fetchMetadataFallsBackToDefaultBuildToolsWhenOpenApiEnumContainsBlankValue() { + void fetchMetadataFailsWhenOpenApiEnumContainsBlankValue() { stubStreamsPayload(List.of(17, 21, 25)); stubFor( get(urlEqualTo("/q/openapi")) @@ -548,12 +547,10 @@ void fetchMetadataFallsBackToDefaultBuildToolsWhenOpenApiEnumContainsBlankValue( """))); try (QuarkusApiClient client = newClient(RetryPolicy.defaults(), new RecordingSleeper())) { - MetadataDto metadata = client.fetchMetadata().join(); - - assertThat(metadata.buildTools()) - .containsExactlyInAnyOrder("maven", "gradle", "gradle-kotlin-dsl"); - assertThat(metadata.compatibility().keySet()) - .containsExactlyInAnyOrder("maven", "gradle", "gradle-kotlin-dsl"); + assertThatThrownBy(() -> client.fetchMetadata().join()) + .hasRootCauseInstanceOf(ApiContractException.class) + .rootCause() + .hasMessageContaining("must not contain blank values"); } } diff --git a/src/test/java/dev/ayagmar/quarkusforge/application/DefaultStartupStateServiceTest.java b/src/test/java/dev/ayagmar/quarkusforge/application/DefaultStartupStateServiceTest.java index bb661946..59d76a9e 100644 --- a/src/test/java/dev/ayagmar/quarkusforge/application/DefaultStartupStateServiceTest.java +++ b/src/test/java/dev/ayagmar/quarkusforge/application/DefaultStartupStateServiceTest.java @@ -51,7 +51,8 @@ void resolveMergesRequestedStoredAndDefaultPrefill() { assertThat(startupState.initialState().request().groupId()).isEqualTo("com.requested"); assertThat(startupState.initialState().request().artifactId()).isEqualTo("saved-app"); assertThat(startupState.initialState().request().version()).isEqualTo("2.0.0"); - assertThat(startupState.initialState().request().packageName()).isEqualTo("org.saved.app"); + assertThat(startupState.initialState().request().packageName()) + .isEqualTo("com.requested.saved.app"); assertThat(startupState.initialState().request().outputDirectory()).isEqualTo("."); assertThat(startupState.initialState().request().platformStream()) .isEqualTo("io.quarkus.platform:3.20"); @@ -59,6 +60,32 @@ void resolveMergesRequestedStoredAndDefaultPrefill() { assertThat(startupState.initialState().request().javaVersion()).isEqualTo("21"); } + @Test + void resolveRecomputesPackageNameWhenCoordinatesAreExplicitlyOverridden() { + StartupMetadataSelection selection = + new StartupMetadataSelection(MetadataCompatibilityContext.success(METADATA), "live", ""); + StartupRequest request = + new StartupRequest( + new CliPrefill("com.requested", "fresh-app", null, "", null, null, null, null), + new CliPrefill( + "org.saved", + "saved-app", + "2.0.0", + "org.saved.app", + "saved-out", + "io.quarkus.platform:3.20", + "gradle", + "21"), + () -> selection); + + StartupState startupState = service.resolve(request); + + assertThat(startupState.initialState().request().groupId()).isEqualTo("com.requested"); + assertThat(startupState.initialState().request().artifactId()).isEqualTo("fresh-app"); + assertThat(startupState.initialState().request().packageName()) + .isEqualTo("com.requested.fresh.app"); + } + @Test void resolveFallsBackToCompiledDefaultsWhenNoPrefillIsProvided() { StartupMetadataSelection selection = diff --git a/src/test/java/dev/ayagmar/quarkusforge/archive/ProjectArchiveServiceTest.java b/src/test/java/dev/ayagmar/quarkusforge/archive/ProjectArchiveServiceTest.java index 6fcadeef..b92a55a7 100644 --- a/src/test/java/dev/ayagmar/quarkusforge/archive/ProjectArchiveServiceTest.java +++ b/src/test/java/dev/ayagmar/quarkusforge/archive/ProjectArchiveServiceTest.java @@ -267,6 +267,7 @@ void cancellationAfterExtractionStartsCompletesAsCancelled() throws Exception { .withStatus(200) .withBody(createZipPayload("demo/pom.xml", "")))); + Path output = tempDir.resolve("generated-project"); Path tempArchive = tempDir.resolve("download.zip"); try (QuarkusApiClient apiClient = newClient()) { ProjectArchiveService service = @@ -280,7 +281,7 @@ void cancellationAfterExtractionStartsCompletesAsCancelled() throws Exception { service .downloadAndExtract( request, - tempDir.resolve("generated-project"), + output, OverwritePolicy.FAIL_IF_EXISTS, cancelled::get, progress -> { @@ -292,6 +293,48 @@ void cancellationAfterExtractionStartsCompletesAsCancelled() throws Exception { .isInstanceOf(CompletionException.class) .hasCauseInstanceOf(java.util.concurrent.CancellationException.class); assertThat(Files.exists(tempArchive)).isFalse(); + assertThat(Files.exists(output)).isFalse(); + } + } + + @Test + void cancellationAfterExtractionStartsDoesNotReplaceExistingOutput() throws Exception { + stubFor( + get(urlPathEqualTo("/api/download")) + .willReturn( + aResponse() + .withStatus(200) + .withBody(createZipPayload("demo/pom.xml", "")))); + + Path output = tempDir.resolve("generated-project"); + Files.createDirectories(output); + Files.writeString(output.resolve("pom.xml"), "existing-project"); + + Path tempArchive = tempDir.resolve("download.zip"); + try (QuarkusApiClient apiClient = newClient()) { + ProjectArchiveService service = + new ProjectArchiveService(apiClient, new SafeZipExtractor(), () -> tempArchive); + + AtomicBoolean cancelled = new AtomicBoolean(false); + assertThatThrownBy( + () -> + service + .downloadAndExtract( + defaultGenerationRequest(), + output, + OverwritePolicy.REPLACE_EXISTING, + cancelled::get, + progress -> { + if (progress == ProjectArchiveService.ProgressStep.EXTRACTING_ARCHIVE) { + cancelled.set(true); + } + }) + .join()) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(java.util.concurrent.CancellationException.class); + + assertThat(Files.readString(output.resolve("pom.xml"))).isEqualTo("existing-project"); + assertThat(Files.exists(tempArchive)).isFalse(); } } diff --git a/src/test/java/dev/ayagmar/quarkusforge/archive/SafeZipExtractorTest.java b/src/test/java/dev/ayagmar/quarkusforge/archive/SafeZipExtractorTest.java index ef02da48..2d9234bf 100644 --- a/src/test/java/dev/ayagmar/quarkusforge/archive/SafeZipExtractorTest.java +++ b/src/test/java/dev/ayagmar/quarkusforge/archive/SafeZipExtractorTest.java @@ -54,6 +54,24 @@ void keepsOutputDirectoryForSingleTopLevelFileArchive() throws IOException { assertThat(Files.readString(destination.resolve("pom.xml"))).isEqualTo(""); } + @Test + void extractFailsFastWhenAlreadyCancelled() throws IOException { + Path zipPath = tempDir.resolve("corrupt.zip"); + Files.writeString(zipPath, "not-a-zip", StandardCharsets.UTF_8); + + SafeZipExtractor extractor = new SafeZipExtractor(); + + assertThatThrownBy( + () -> + extractor.extract( + zipPath, + tempDir.resolve("generated-project"), + OverwritePolicy.FAIL_IF_EXISTS, + () -> true)) + .isInstanceOf(java.util.concurrent.CancellationException.class) + .hasMessageContaining("cancelled"); + } + @Test void acceptsBackslashEntryNamesFromTheCentralDirectory() throws IOException { Path zipPath = diff --git a/src/test/java/dev/ayagmar/quarkusforge/headless/HeadlessCatalogClientTest.java b/src/test/java/dev/ayagmar/quarkusforge/headless/HeadlessCatalogClientTest.java index 47a8fec4..d675d6c0 100644 --- a/src/test/java/dev/ayagmar/quarkusforge/headless/HeadlessCatalogClientTest.java +++ b/src/test/java/dev/ayagmar/quarkusforge/headless/HeadlessCatalogClientTest.java @@ -65,6 +65,8 @@ void loadCatalogDataReturnsLiveCatalogData() throws Exception { CatalogData catalogData = client.loadCatalogData(Duration.ofSeconds(1)); assertThat(catalogData.source()).isEqualTo(CatalogSource.LIVE); + assertThat(catalogData.metadataSource()) + .isEqualTo(dev.ayagmar.quarkusforge.api.MetadataSource.LIVE); assertThat(catalogData.extensions()) .extracting(ExtensionDto::id) .containsExactly("io.quarkus:quarkus-rest"); @@ -116,6 +118,8 @@ void loadCatalogDataFallsBackToCachedSnapshotWhenOverallLoadTimesOut() throws Ex CatalogData catalogData = client.loadCatalogData(Duration.ofMillis(100)); assertThat(catalogData.source()).isEqualTo(CatalogSource.CACHE); + assertThat(catalogData.metadataSource()) + .isEqualTo(dev.ayagmar.quarkusforge.api.MetadataSource.CACHE); assertThat(catalogData.detailMessage()).contains("timed out after 100ms"); assertThat(catalogData.detailMessage()).contains("cached snapshot"); assertThat(catalogData.extensions()) diff --git a/src/test/java/dev/ayagmar/quarkusforge/headless/HeadlessOutputPrinterTest.java b/src/test/java/dev/ayagmar/quarkusforge/headless/HeadlessOutputPrinterTest.java index 9a1082e7..000e068e 100644 --- a/src/test/java/dev/ayagmar/quarkusforge/headless/HeadlessOutputPrinterTest.java +++ b/src/test/java/dev/ayagmar/quarkusforge/headless/HeadlessOutputPrinterTest.java @@ -87,7 +87,12 @@ void printDryRunSummaryOutputsAllFields() { "maven", "21"); - HeadlessOutputPrinter.printDryRunSummary(request, List.of("io.quarkus:quarkus-rest"), "live"); + HeadlessOutputPrinter.printDryRunSummary( + request, + List.of("io.quarkus:quarkus-rest"), + "live", + "bundled snapshot", + "Live metadata unavailable"); String output = stdout.toString(StandardCharsets.UTF_8); assertThat(output) @@ -95,7 +100,29 @@ void printDryRunSummaryOutputsAllFields() { .contains("groupId: com.example") .contains("artifactId: demo") .contains("extensions: [io.quarkus:quarkus-rest]") - .contains("catalogSource: live"); + .contains("catalogSource: live") + .contains("metadataSource: bundled snapshot") + .contains("metadataDetail: Live metadata unavailable"); + } + + @Test + void printDryRunSummaryOmitsRedundantMetadataSource() { + ProjectRequest request = + new ProjectRequest( + "com.example", + "demo", + "1.0.0", + "com.example.demo", + "/output", + "io.quarkus.platform:3.31", + "maven", + "21"); + + HeadlessOutputPrinter.printDryRunSummary( + request, List.of("io.quarkus:quarkus-rest"), "live", "live", ""); + + String output = stdout.toString(StandardCharsets.UTF_8); + assertThat(output).contains("catalogSource: live").doesNotContain("metadataSource"); } @Test @@ -103,10 +130,13 @@ void printDryRunSummaryDefaultsSourceLabel() { ProjectRequest request = new ProjectRequest("org.acme", "app", "1.0.0", "", ".", "", "maven", "25"); - HeadlessOutputPrinter.printDryRunSummary(request, List.of(), null); + HeadlessOutputPrinter.printDryRunSummary(request, List.of(), null, null, null); String output = stdout.toString(StandardCharsets.UTF_8); - assertThat(output).contains("catalogSource: unknown"); + assertThat(output) + .contains("catalogSource: unknown") + .doesNotContain("metadataSource") + .doesNotContain("metadataDetail"); } @Test @@ -190,10 +220,13 @@ void printDryRunSummaryWithBlankSourceLabel() { ProjectRequest request = new ProjectRequest("org.acme", "app", "1.0.0", "", ".", "", "maven", "25"); - HeadlessOutputPrinter.printDryRunSummary(request, List.of(), ""); + HeadlessOutputPrinter.printDryRunSummary(request, List.of(), "", "", ""); String output = stdout.toString(StandardCharsets.UTF_8); - assertThat(output).contains("catalogSource: unknown"); + assertThat(output) + .contains("catalogSource: unknown") + .doesNotContain("metadataSource") + .doesNotContain("metadataDetail"); } @Test diff --git a/src/test/java/dev/ayagmar/quarkusforge/runtime/CatalogLoadDiagnosticsTest.java b/src/test/java/dev/ayagmar/quarkusforge/runtime/CatalogLoadDiagnosticsTest.java index c3e33b81..1a37affb 100644 --- a/src/test/java/dev/ayagmar/quarkusforge/runtime/CatalogLoadDiagnosticsTest.java +++ b/src/test/java/dev/ayagmar/quarkusforge/runtime/CatalogLoadDiagnosticsTest.java @@ -8,6 +8,7 @@ import dev.ayagmar.quarkusforge.api.CatalogSource; import dev.ayagmar.quarkusforge.api.ExtensionDto; import dev.ayagmar.quarkusforge.api.MetadataDto; +import dev.ayagmar.quarkusforge.api.MetadataSource; import dev.ayagmar.quarkusforge.diagnostics.DiagnosticLogger; import java.io.ByteArrayOutputStream; import java.io.PrintStream; @@ -63,6 +64,7 @@ void catalogLoadDiagnosticsReturnsLoadResultAndLogsSuccess() { new MetadataDto(java.util.List.of("25"), java.util.List.of("maven"), Map.of()), java.util.List.of(new ExtensionDto("io.quarkus:quarkus-rest", "REST", "web")), CatalogSource.LIVE, + MetadataSource.SNAPSHOT, false, "fresh"); @@ -73,11 +75,13 @@ void catalogLoadDiagnosticsReturnsLoadResultAndLogsSuccess() { assertThat(result.extensions()).hasSize(1); assertThat(result.source()).isEqualTo(CatalogSource.LIVE); + assertThat(result.metadataSource()).isEqualTo(MetadataSource.SNAPSHOT); assertThat(result.detailMessage()).isEqualTo("fresh"); assertThat(stderr.toString(StandardCharsets.UTF_8)) .contains("\"event\":\"catalog.load.success\"") .contains("\"mode\":\"headless-smoke\"") - .contains("\"source\":\"live\""); + .contains("\"source\":\"live\"") + .contains("\"metadataSource\":\"bundled snapshot\""); } @Test diff --git a/src/test/java/dev/ayagmar/quarkusforge/ui/ExtensionCatalogLoadResultTest.java b/src/test/java/dev/ayagmar/quarkusforge/ui/ExtensionCatalogLoadResultTest.java index 8abd83b1..c3f7f694 100644 --- a/src/test/java/dev/ayagmar/quarkusforge/ui/ExtensionCatalogLoadResultTest.java +++ b/src/test/java/dev/ayagmar/quarkusforge/ui/ExtensionCatalogLoadResultTest.java @@ -5,6 +5,7 @@ import dev.ayagmar.quarkusforge.api.CatalogSource; import dev.ayagmar.quarkusforge.api.ExtensionDto; +import dev.ayagmar.quarkusforge.api.MetadataSource; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -34,9 +35,27 @@ void cacheSourceAllowsStaleFlag() { List.of(extension("rest")), CatalogSource.CACHE, true, "stale data", null); assertThat(result.stale()).isTrue(); + assertThat(result.metadataSource()).isEqualTo(MetadataSource.CACHE); + assertThat(result.metadataSourceLabel()).isEqualTo("cache [stale]"); assertThat(result.detailMessage()).isEqualTo("stale data"); } + @Test + void supportsLiveCatalogWithBundledSnapshotMetadata() { + var result = + new ExtensionCatalogLoadResult( + List.of(extension("rest")), + CatalogSource.LIVE, + MetadataSource.SNAPSHOT, + false, + "snapshot fallback", + null, + Map.of()); + + assertThat(result.metadataSource()).isEqualTo(MetadataSource.SNAPSHOT); + assertThat(result.metadataSourceLabel()).isEqualTo("bundled snapshot"); + } + @Test void liveSourceRejectsStaleFlag() { assertThatThrownBy( @@ -56,6 +75,22 @@ void nullDetailMessageDefaultsToEmpty() { assertThat(result.detailMessage()).isEmpty(); } + @Test + void incompatibleMetadataSourceIsRejected() { + assertThatThrownBy( + () -> + new ExtensionCatalogLoadResult( + List.of(extension("rest")), + CatalogSource.CACHE, + MetadataSource.SNAPSHOT, + false, + "", + null, + Map.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cache metadata"); + } + @Test void detailMessageIsStripped() { var result =