001/*
002 * JDrupes Builder
003 * Copyright (C) 2025, 2026 Michael N. Lipp
004 * 
005 * This program is free software: you can redistribute it and/or modify
006 * it under the terms of the GNU Affero General Public License as
007 * published by the Free Software Foundation, either version 3 of the
008 * License, or (at your option) any later version.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
013 * GNU Affero General Public License for more details.
014 *
015 * You should have received a copy of the GNU Affero General Public License
016 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
017 */
018
019package org.jdrupes.builder.mvnrepo;
020
021import com.google.common.flogger.FluentLogger;
022import static com.google.common.flogger.LazyArgs.lazy;
023import java.net.URI;
024import java.util.ArrayList;
025import java.util.Arrays;
026import java.util.Collection;
027import java.util.Collections;
028import java.util.HashMap;
029import java.util.List;
030import java.util.Map;
031import java.util.Objects;
032import java.util.stream.Collectors;
033import java.util.stream.Stream;
034import org.apache.maven.model.DependencyManagement;
035import org.apache.maven.model.Model;
036import org.apache.maven.model.building.DefaultModelBuilderFactory;
037import org.apache.maven.model.building.DefaultModelBuildingRequest;
038import org.apache.maven.model.building.ModelBuildingException;
039import org.apache.maven.model.building.ModelBuildingRequest;
040import org.eclipse.aether.RepositorySystem;
041import org.eclipse.aether.RepositorySystemSession;
042import org.eclipse.aether.artifact.Artifact;
043import org.eclipse.aether.collection.CollectRequest;
044import org.eclipse.aether.graph.Dependency;
045import org.eclipse.aether.graph.DependencyNode;
046import org.eclipse.aether.repository.RemoteRepository;
047import org.eclipse.aether.resolution.ArtifactRequest;
048import org.eclipse.aether.resolution.ArtifactResolutionException;
049import org.eclipse.aether.resolution.DependencyRequest;
050import org.eclipse.aether.resolution.DependencyResolutionException;
051import org.eclipse.aether.util.artifact.SubArtifact;
052import org.eclipse.aether.util.graph.visitor.PreorderDependencyNodeConsumerVisitor;
053import org.eclipse.aether.util.version.GenericVersionScheme;
054import org.eclipse.aether.version.InvalidVersionSpecificationException;
055import org.eclipse.aether.version.Version;
056import org.jdrupes.builder.api.BuildException;
057import org.jdrupes.builder.api.Renamable;
058import org.jdrupes.builder.api.Resource;
059import org.jdrupes.builder.api.ResourceRequest;
060import org.jdrupes.builder.core.AbstractProvider;
061import static org.jdrupes.builder.mvnrepo.MvnRepoTypes.*;
062
063/// Depending on the request, this provider provides two types of resources.
064/// 
065///  1. The artifacts to be resolved as resources of type [MvnRepoDependency].
066///     The artifacts to be resolved are those added with [resolve].
067///     Note that the result also includes the [MvnRepoBom]s added with
068///     [bom].
069///
070///  2. The resources of type [MvnRepoLibraryJarFile] that result from
071///     resolving the artifacts to be resolved.
072///
073/// The repositories used are those added with [addRepositories]. If
074/// no repositories are configured, the Maven Central repository
075/// is added automatically.
076/// 
077/// Resolving is performed using Maven Resolver (formerly Eclipse Aether)
078/// version 2.x. Dependencies are collected from the specified artifacts after
079/// evaluating their effective Maven models, including any imported
080/// BOMs. Version conflicts are resolved using a "highest wins"
081/// strategy, i.e. the highest version of a dependency encountered in the
082/// dependency graph is selected. Note that this differs from Maven's
083/// default behavior which is "nearest wins".
084/// 
085/// Results of the dependency resolution are written to the log with
086/// log level FINE.
087/// 
088@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.GodClass" })
089public class MvnRepoLookup extends AbstractProvider implements Renamable {
090
091    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
092    private final List<RemoteRepository> addedRepos = new ArrayList<>();
093    private final List<String> coordinates = new ArrayList<>();
094    private final List<String> boms = new ArrayList<>();
095    private boolean downloadSources = true;
096    private boolean downloadJavadoc = true;
097    private boolean probeMode;
098
099    /// Initializes a new Maven repository lookup.
100    ///
101    public MvnRepoLookup() {
102        // Make javadoc happy.
103    }
104
105    @Override
106    public MvnRepoLookup name(String name) {
107        rename(name);
108        return this;
109    }
110
111    /// Add repositories to be used for the lookup.
112    ///
113    /// @param repositories the repositories
114    /// @return the mvn repo lookup
115    ///
116    public MvnRepoLookup addRepositories(RemoteRepository... repositories) {
117        for (var repo : Objects.requireNonNull(repositories)) {
118            Objects.requireNonNull(repo);
119        }
120        addedRepos.addAll(Arrays.asList(repositories));
121        return this;
122
123    }
124
125    /// Uses [MavenContext#createRepository] to create a repository
126    /// and adds it to the lookup.
127    ///
128    /// @param id the repository id
129    /// @param uri the repository uri
130    /// @param supported the supported version types
131    /// @return the mvn repo lookup
132    ///
133    public MvnRepoLookup addRepository(
134            String id, URI uri, MvnVersionType... supported) {
135        addedRepos.add(MavenContext.createRepository(id, uri, supported));
136        return this;
137    }
138
139    /// Add a bill of materials. The coordinates are resolved as 
140    /// a dependency with scope `import` which is added to the
141    /// `dependencyManagement` section when evaluating the effective
142    /// model.
143    ///
144    /// @param coordinates the coordinates in the form
145    /// groupId:artifactId:version
146    /// @return the mvn repo lookup
147    ///
148    public MvnRepoLookup bom(String... coordinates) {
149        boms.addAll(Arrays.asList(coordinates));
150        return this;
151    }
152
153    /// Add artifacts, specified by their coordinates
154    /// (`groupId:artifactId:version`) as resources.
155    ///
156    /// @param coordinates the coordinates in the form
157    /// groupId:artifactId:version
158    /// @return the mvn repo lookup
159    ///
160    public MvnRepoLookup resolve(String... coordinates) {
161        this.coordinates.addAll(Arrays.asList(coordinates));
162        return this;
163    }
164
165    /// Add artifacts. The method handles [MvnRepoBom]s correctly.
166    ///
167    /// @param resources the resources
168    /// @return the mvn repo lookup
169    ///
170    public MvnRepoLookup resolve(Stream<? extends MvnRepoResource> resources) {
171        resources.forEach(r -> {
172            if (r instanceof MvnRepoBom) {
173                bom(r.coordinates());
174            } else {
175                resolve(r.coordinates());
176            }
177        });
178        return this;
179    }
180
181    /// Failing to resolve the dependencies normally results in a
182    /// [BuildException], because the requested artifacts are assumed
183    /// to be required for the build.
184    /// 
185    /// By invoking this method the provider enters probe mode
186    /// and returns an empty result stream instead of throwing an
187    /// exception if the resolution fails.
188    ///
189    /// @return the mvn repo lookup
190    ///
191    public MvnRepoLookup probe() {
192        probeMode = true;
193        logger.atFine().log("Probe mode enabled for %s", this);
194        return this;
195    }
196
197    /// Whether to also download the sources. Defaults to `true`.
198    ///
199    /// @param enable the enable
200    /// @return the mvn repo lookup
201    ///
202    public MvnRepoLookup downloadSources(boolean enable) {
203        this.downloadSources = enable;
204        return this;
205    }
206
207    /// Whether to also download the javadoc. Defaults to `true`.
208    ///
209    /// @param enable the enable
210    /// @return the mvn repo lookup
211    ///
212    public MvnRepoLookup downloadJavadoc(boolean enable) {
213        this.downloadJavadoc = enable;
214        return this;
215    }
216
217    @Override
218    protected <T extends Resource> Collection<T>
219            doProvide(ResourceRequest<T> request) {
220        // Check if provided and evaluate for most special type
221        if (!request.accepts(MvnRepoLibraryJarFileType,
222            MvnRepoDependencyType)) {
223            return Collections.emptyList();
224        }
225        if (request.accepts(MvnRepoDependencyType)
226            && !request.isFor(MvnRepoDependencyType)) {
227            @SuppressWarnings({ "unchecked", "PMD.AvoidDuplicateLiterals" })
228            var result = (Collection<T>) context()
229                .resources(this, of(MvnRepoDependencyType)).toList();
230            return result;
231        }
232        if (request.accepts(MvnRepoLibraryJarFileType)
233            && !request.isFor(MvnRepoLibraryJarFileType)) {
234            @SuppressWarnings({ "unchecked" })
235            var result = (Collection<T>) context()
236                .resources(this, of(MvnRepoLibraryJarFileType)).toList();
237            return result;
238        }
239
240        // Handle request for maven dependencies
241        if (request.accepts(MvnRepoDependencyType)) {
242            return provideMvnDeps();
243        }
244
245        // Handle request for libraries
246        try {
247            return provideJars();
248        } catch (ModelBuildingException e) {
249            throw new BuildException().from(this).cause(e);
250        } catch (DependencyResolutionException e) {
251            if (probeMode) {
252                return Collections.emptyList();
253            }
254            logger.atSevere().withCause(e).log("%s failed to resolve", this);
255            Throwable cause = e;
256            while (cause.getCause() != null) {
257                cause = cause.getCause();
258            }
259            throw new BuildException().from(this).cause(cause);
260        }
261
262    }
263
264    private <T extends Resource> Collection<T> provideMvnDeps() {
265        @SuppressWarnings("unchecked")
266        var boms = (Stream<T>) this.boms.stream()
267            .map(MvnRepoBom::of);
268        @SuppressWarnings("unchecked")
269        var deps = (Stream<T>) coordinates.stream()
270            .map(MvnRepoDependency::of);
271        return Stream.concat(boms, deps).toList();
272    }
273
274    @SuppressWarnings("PMD.AvoidSynchronizedStatement")
275    private <T extends Resource> Collection<T> provideJars()
276            throws DependencyResolutionException, ModelBuildingException {
277        @SuppressWarnings("PMD.CloseResource")
278        var repoSystem = MavenContext.repositorySystem();
279        var repoSession = MavenContext.repositorySession();
280
281        // Create one CollectRequest
282        var repos = new ArrayList<>(addedRepos);
283        if (repos.isEmpty()) {
284            repos.add(MavenContext.mavenCentral());
285        }
286
287        // Collect all dependencies via their effective models
288        List<Dependency> allDeps = new ArrayList<>();
289        coordinates.stream().parallel().map(c -> depsFromEffectiveModel(
290            c, repoSystem, repoSession, repos)).forEach(deps -> {
291                // allDeps is not thread safe
292                synchronized (allDeps) {
293                    deps.forEach(allDeps::add);
294                }
295            });
296
297        // Deduplicate: keep highest version for each
298        // groupId:artifactId:classifier:type:scope.
299        // The resolver does not apply the highest wins strategy to the
300        // list of dependencies passed to it, only to those pulled in by
301        // those dependencies.
302        List<Dependency> resolvedDeps = deduplicateByHighestVersion(allDeps);
303
304        // Populate collect request
305        CollectRequest collectRequest
306            = new CollectRequest().setRepositories(repos);
307        resolvedDeps.forEach(collectRequest::addDependency);
308
309        // Resolve dependencies - Resolver performs mediation
310        logger.atFine().log("%s resolving dependencies: %s", name(),
311            lazy(() -> collectRequest.getDependencies().stream()
312                .map(Dependency::toString).collect(Collectors.joining(", "))));
313        DependencyRequest dependencyRequest
314            = new DependencyRequest(collectRequest, null);
315        DependencyNode rootNode = repoSystem.resolveDependencies(repoSession,
316            dependencyRequest).getRoot();
317        logger.atFine().log("Dependency tree from %s:\n%s", name(),
318            lazy(() -> buildTreeString(rootNode, 0, "", true)));
319        List<DependencyNode> dependencyNodes = new ArrayList<>();
320        rootNode.accept(new PreorderDependencyNodeConsumerVisitor(
321            dependencyNodes::add));
322        @SuppressWarnings("unchecked")
323        var result = (Collection<T>) dependencyNodes.stream()
324            .filter(d -> d.getArtifact() != null)
325            .map(d -> {
326                var artifact = extraDownloads(
327                    repoSystem, repoSession, repos, d.getArtifact());
328                return MvnRepoLibraryJarFile.of(d.getRepositories(),
329                    artifact.toString(), artifact.getPath());
330            }).toList();
331        return result;
332    }
333
334    @SuppressWarnings("PMD.CognitiveComplexity")
335    private List<Dependency> deduplicateByHighestVersion(
336            List<Dependency> dependencies) {
337        var versionScheme = new GenericVersionScheme();
338
339        // Group by all properties except version
340        @SuppressWarnings("PMD.UseConcurrentHashMap")
341        Map<String, Dependency> bestByGroup = new HashMap<>();
342        for (Dependency dep : dependencies) {
343            var artifact = dep.getArtifact();
344            String key = artifact.getGroupId() + ':' + artifact.getArtifactId()
345                + ':' + (artifact.getClassifier() != null
346                    ? artifact.getClassifier()
347                    : "")
348                + ':' + (artifact.getExtension() != null
349                    ? artifact.getExtension()
350                    : "jar")
351                + ':' + dep.getScope();
352
353            Dependency existing = bestByGroup.get(key);
354            if (existing == null) {
355                bestByGroup.put(key, dep);
356            } else {
357                // Compare versions, keep highest
358                try {
359                    Version existingVersion = versionScheme.parseVersion(
360                        existing.getArtifact().getVersion());
361                    Version candidateVersion = versionScheme.parseVersion(
362                        artifact.getVersion());
363                    if (candidateVersion.compareTo(existingVersion) > 0) {
364                        bestByGroup.put(key, dep);
365                    }
366                } catch (InvalidVersionSpecificationException e) {
367                    // Fall back to string comparison
368                    String existingVersion
369                        = existing.getArtifact().getVersion();
370                    String candidateVersion = artifact.getVersion();
371                    if (candidateVersion.compareTo(existingVersion) > 0) {
372                        bestByGroup.put(key, dep);
373                    }
374                }
375            }
376        }
377        return new ArrayList<>(bestByGroup.values());
378    }
379
380    private Stream<Dependency> depsFromEffectiveModel(
381            String coordinates, RepositorySystem repoSystem,
382            RepositorySystemSession repoSession,
383            List<RemoteRepository> repos) {
384        // First build raw model
385        Model model = new Model();
386        model.setModelVersion("4.0.0");
387        model.setGroupId("model.group");
388        model.setArtifactId("model.artifact");
389        model.setVersion("0.0.0");
390        model.setDescription(name());
391        var depMgmt = new DependencyManagement();
392        model.setDependencyManagement(depMgmt);
393
394        // Build raw model from boms and coordinate
395        for (String bom : boms) {
396            var dep = DependencyConverter
397                .convert(MvnRepoDependency.of(bom), "import");
398            dep.setType("pom");
399            depMgmt.addDependency(dep);
400        }
401        model.addDependency(DependencyConverter.convert(
402            MvnRepoDependency.of(coordinates), "compile"));
403
404        // Now build (derive) effective model and add its dependencies
405        var buildingRequest = new DefaultModelBuildingRequest()
406            .setRawModel(model).setProcessPlugins(false)
407            .setValidationLevel(ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL)
408            .setModelResolver(
409                new MvnModelResolver(repoSystem, repoSession, repos));
410        try {
411            var effectiveModel = new DefaultModelBuilderFactory()
412                .newInstance().build(buildingRequest).getEffectiveModel();
413            return effectiveModel.getDependencies().stream()
414                .map(DependencyConverter::convert);
415        } catch (ModelBuildingException e) {
416            throw new BuildException().from(this).cause(e);
417        }
418    }
419
420    private Artifact extraDownloads(
421            RepositorySystem repoSystem, RepositorySystemSession repoSession,
422            List<RemoteRepository> repos, Artifact artifact) {
423        if (downloadSources) {
424            downloadSourceJar(repoSystem, repoSession, repos, artifact);
425        }
426        if (downloadJavadoc) {
427            downloadJavadocJar(repoSystem, repoSession, repos, artifact);
428        }
429        return artifact;
430    }
431
432    private void downloadSourceJar(RepositorySystem repoSystem,
433            RepositorySystemSession repoSession,
434            List<RemoteRepository> repos, Artifact jarArtifact) {
435        Artifact sourcesArtifact
436            = new SubArtifact(jarArtifact, "sources", "jar");
437        ArtifactRequest sourcesRequest = new ArtifactRequest();
438        sourcesRequest.setArtifact(sourcesArtifact);
439        sourcesRequest.setRepositories(repos);
440        try {
441            repoSystem.resolveArtifact(repoSession, sourcesRequest);
442        } catch (ArtifactResolutionException e) { // NOPMD
443            // Ignore, sources are optional
444        }
445    }
446
447    private void downloadJavadocJar(RepositorySystem repoSystem,
448            RepositorySystemSession repoSession,
449            List<RemoteRepository> repos, Artifact jarArtifact) {
450        Artifact javadocArtifact
451            = new SubArtifact(jarArtifact, "javadoc", "jar");
452        ArtifactRequest sourcesRequest = new ArtifactRequest();
453        sourcesRequest.setArtifact(javadocArtifact);
454        sourcesRequest.setRepositories(repos);
455        try {
456            repoSystem.resolveArtifact(repoSession, sourcesRequest);
457        } catch (ArtifactResolutionException e) { // NOPMD
458            // Ignore, javadoc is optional
459        }
460    }
461
462    private String buildTreeString(DependencyNode node, int indent,
463            String prefix, boolean isLast) {
464        @SuppressWarnings("PMD.ShortVariable")
465        StringBuilder sb = new StringBuilder();
466        var artifact = node.getArtifact();
467
468        if (indent == 0) {
469            sb.append("root\n");
470        } else {
471            sb.append(prefix).append(isLast ? "`-- " : "|-- ")
472                .append(artifact != null ? artifact.toString() : "node")
473                .append('\n');
474        }
475
476        var children = node.getChildren();
477        String childPrefix
478            = prefix + (indent == 0 ? "  " : isLast ? "    " : "|  ");
479
480        for (int i = 0; i < children.size(); i++) {
481            sb.append(buildTreeString(children.get(i), indent + 1, childPrefix,
482                i == children.size() - 1));
483        }
484        return sb.toString();
485    }
486}