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.EnumSet;
029import java.util.List;
030import java.util.stream.Collectors;
031import java.util.stream.Stream;
032import org.apache.maven.model.DependencyManagement;
033import org.apache.maven.model.Model;
034import org.apache.maven.model.building.DefaultModelBuilderFactory;
035import org.apache.maven.model.building.DefaultModelBuildingRequest;
036import org.apache.maven.model.building.ModelBuildingException;
037import org.apache.maven.model.building.ModelBuildingRequest;
038import org.eclipse.aether.RepositorySystem;
039import org.eclipse.aether.RepositorySystemSession;
040import org.eclipse.aether.artifact.Artifact;
041import org.eclipse.aether.collection.CollectRequest;
042import org.eclipse.aether.graph.Dependency;
043import org.eclipse.aether.graph.DependencyNode;
044import org.eclipse.aether.repository.RemoteRepository;
045import org.eclipse.aether.resolution.ArtifactRequest;
046import org.eclipse.aether.resolution.ArtifactResolutionException;
047import org.eclipse.aether.resolution.DependencyRequest;
048import org.eclipse.aether.resolution.DependencyResolutionException;
049import org.eclipse.aether.util.artifact.SubArtifact;
050import org.eclipse.aether.util.graph.visitor.PreorderDependencyNodeConsumerVisitor;
051import org.jdrupes.builder.api.BuildException;
052import org.jdrupes.builder.api.Resource;
053import org.jdrupes.builder.api.ResourceFactory;
054import org.jdrupes.builder.api.ResourceRequest;
055import org.jdrupes.builder.core.AbstractProvider;
056import static org.jdrupes.builder.mvnrepo.MvnRepoTypes.*;
057
058/// Depending on the request, this provider provides two types of resources.
059/// 
060///  1. The artifacts to be resolved as resources of type [MvnRepoDependency].
061///     The artifacts to be resolved are those added with [resolve].
062///     Note that the result also includes the [MvnRepoBom]s added with
063///     [bom].
064///
065///  2. The resources of type [MvnRepoLibraryJarFile] that result from
066///     resolving the artifacts to be resolved.
067///
068/// The repositories used are those configured for all instances of this
069/// provider by [MavenContext] and the repositories added with
070/// [addRepository]. Should there be no repositories configured, the
071/// Maven Central repository will be added automatically.
072/// 
073/// Resolving is performed using Maven Resolver (formerly Eclipse Aether)
074/// version 2.x. Dependencies are collected from the specified artifacts after
075/// evaluating their effective Maven models, including any imported
076/// BOMs. Version conflicts are resolved using a "highest wins"
077/// strategy, i.e. the highest version of a dependency encountered in the
078/// dependency graph is selected. Note that this differs from Maven's
079/// default behavior which is "nearest wins".
080/// 
081/// Results of the dependency resolution are written to the log with
082/// log level FINE.
083/// 
084@SuppressWarnings("PMD.CouplingBetweenObjects")
085public class MvnRepoLookup extends AbstractProvider {
086
087    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
088    private final List<RemoteRepository> addedRepos = new ArrayList<>();
089    @SuppressWarnings("PMD.AvoidUsingVolatile")
090    private volatile List<RemoteRepository> mergedRepos;
091    private final List<String> coordinates = new ArrayList<>();
092    private final List<String> boms = new ArrayList<>();
093    private boolean downloadSources = true;
094    private boolean downloadJavadoc = true;
095    private boolean probeMode;
096
097    /// Initializes a new Maven repository lookup.
098    ///
099    public MvnRepoLookup() {
100        // Make javadoc happy.
101    }
102
103    /// Add a repository that is to be used for the lookup.
104    ///
105    /// @param repository the repository
106    /// @return the mvn repo lookup
107    ///
108    public MvnRepoLookup addRepository(RemoteRepository repository) {
109        addedRepos.add(repository);
110        return this;
111
112    }
113
114    /// Add a repository that is to be used for the lookup.
115    ///
116    /// @param id the repository id
117    /// @param uri the repository uri
118    /// @param supported the supported version types
119    /// @return the mvn repo lookup
120    ///
121    public MvnRepoLookup addRepository(
122            String id, URI uri, MvnVersionType... supported) {
123        var types = EnumSet.copyOf(Arrays.asList(supported));
124        var builder = new RemoteRepository.Builder(
125            id, "default", uri.toString())
126                .setReleasePolicy(
127                    MavenContext.createPolicy(MvnVersionType.RELEASE,
128                        types.contains(MvnVersionType.RELEASE), null, null))
129                .setSnapshotPolicy(
130                    MavenContext.createPolicy(MvnVersionType.SNAPSHOT,
131                        types.contains(MvnVersionType.SNAPSHOT), null, null));
132        addedRepos.add(builder.build());
133        return this;
134    }
135
136    @SuppressWarnings({ "PMD.AvoidSynchronizedStatement" })
137    private List<RemoteRepository> remoteRepositories() {
138        if (mergedRepos != null) {
139            return mergedRepos;
140        }
141        synchronized (this) {
142            if (mergedRepos != null) {
143                return mergedRepos;
144            }
145            List<RemoteRepository> repos
146                = new ArrayList<>(MavenContext.remoteRepositories());
147            repos.addAll(addedRepos);
148            if (repos.isEmpty()) {
149                repos.add(MavenContext.mavenCentral());
150            }
151            mergedRepos = repos;
152            return mergedRepos;
153        }
154    }
155
156    /// Add a bill of materials. The coordinates are resolved as 
157    /// a dependency with scope `import` which is added to the
158    /// `dependencyManagement` section when evaluating the effective
159    /// model.
160    ///
161    /// @param coordinates the coordinates in the form
162    /// groupId:artifactId:version
163    /// @return the mvn repo lookup
164    ///
165    public MvnRepoLookup bom(String... coordinates) {
166        boms.addAll(Arrays.asList(coordinates));
167        return this;
168    }
169
170    /// Add artifacts, specified by their coordinates
171    /// (`groupId:artifactId:version`) as resources.
172    ///
173    /// @param coordinates the coordinates in the form
174    /// groupId:artifactId:version
175    /// @return the mvn repo lookup
176    ///
177    public MvnRepoLookup resolve(String... coordinates) {
178        this.coordinates.addAll(Arrays.asList(coordinates));
179        return this;
180    }
181
182    /// Add artifacts. The method handles [MvnRepoBom]s correctly.
183    ///
184    /// @param resources the resources
185    /// @return the mvn repo lookup
186    ///
187    public MvnRepoLookup resolve(Stream<? extends MvnRepoResource> resources) {
188        resources.forEach(r -> {
189            if (r instanceof MvnRepoBom) {
190                bom(r.coordinates());
191            } else {
192                resolve(r.coordinates());
193            }
194        });
195        return this;
196    }
197
198    /// Failing to resolve the dependencies normally results in a
199    /// [BuildException], because the requested artifacts are assumed
200    /// to be required for the build.
201    /// 
202    /// By invoking this method the provider enters probe mode
203    /// and returns an empty result stream instead of throwing an
204    /// exception if the resolution fails.
205    ///
206    /// @return the mvn repo lookup
207    ///
208    public MvnRepoLookup probe() {
209        probeMode = true;
210        logger.atFine().log("Probe mode enabled for %s", this);
211        return this;
212    }
213
214    /// Whether to also download the sources. Defaults to `true`.
215    ///
216    /// @param enable the enable
217    /// @return the mvn repo lookup
218    ///
219    public MvnRepoLookup downloadSources(boolean enable) {
220        this.downloadSources = enable;
221        return this;
222    }
223
224    /// Whether to also download the javadoc. Defaults to `true`.
225    ///
226    /// @param enable the enable
227    /// @return the mvn repo lookup
228    ///
229    public MvnRepoLookup downloadJavadoc(boolean enable) {
230        this.downloadJavadoc = enable;
231        return this;
232    }
233
234    /// Provide.
235    ///
236    /// @param <T> the generic type
237    /// @param request the requested resources
238    /// @return the stream
239    ///
240    @Override
241    protected <T extends Resource> Collection<T>
242            doProvide(ResourceRequest<T> request) {
243        if (request.accepts(MvnRepoDependencyType)) {
244            return provideMvnDeps();
245        }
246        if (!request.accepts(MvnRepoLibraryJarFileType)) {
247            return Collections.emptyList();
248        }
249        if (!request.isFor(MvnRepoLibraryJarFileType)) {
250            @SuppressWarnings({ "unchecked", "PMD.AvoidDuplicateLiterals" })
251            var result = (Collection<T>) context()
252                .resources(this, of(MvnRepoLibraryJarFileType)).toList();
253            return result;
254        }
255        try {
256            return provideJars();
257        } catch (ModelBuildingException e) {
258            throw new BuildException().from(this).cause(e);
259        } catch (DependencyResolutionException e) {
260            if (probeMode) {
261                return Collections.emptyList();
262            }
263            Throwable cause = e;
264            while (cause.getCause() != null) {
265                cause = cause.getCause();
266            }
267            throw new BuildException().from(this).cause(cause);
268        }
269
270    }
271
272    private <T extends Resource> Collection<T> provideMvnDeps() {
273        @SuppressWarnings("unchecked")
274        var boms = (Stream<T>) this.boms.stream()
275            .map(MvnRepoBom::of);
276        @SuppressWarnings("unchecked")
277        var deps = (Stream<T>) coordinates.stream()
278            .map(MvnRepoDependency::of);
279        return Stream.concat(boms, deps).toList();
280    }
281
282    @SuppressWarnings("PMD.AvoidSynchronizedStatement")
283    private <T extends Resource> Collection<T> provideJars()
284            throws DependencyResolutionException, ModelBuildingException {
285        @SuppressWarnings("PMD.CloseResource")
286        var repoSystem = MavenContext.repositorySystem();
287        var repoSession = MavenContext.repositorySession();
288        var remoteRepositories = remoteRepositories();
289
290        // Create one synthetic CollectRequest
291        CollectRequest collectRequest
292            = new CollectRequest().setRepositories(remoteRepositories);
293
294        // Add dependencies via their effective model
295        coordinates.stream().parallel().map(c -> depsFromEffectiveModel(
296            c, repoSystem, repoSession, remoteRepositories)).forEach(deps -> {
297                // collectRequest::addDependency is not thread safe
298                synchronized (collectRequest) {
299                    deps.forEach(collectRequest::addDependency);
300                }
301            });
302
303        // Resolve dependencies - Resolver performs mediation
304        logger.atFine().log("Resolving dependencies: %s",
305            lazy(() -> collectRequest.getDependencies().stream()
306                .map(Dependency::toString).collect(Collectors.joining(", "))));
307        DependencyRequest dependencyRequest
308            = new DependencyRequest(collectRequest, null);
309        DependencyNode rootNode = repoSystem.resolveDependencies(repoSession,
310            dependencyRequest).getRoot();
311        logger.atFine().log("Dependency tree for %s:\n%s", name(),
312            lazy(() -> buildTreeString(rootNode, 0, "", true)));
313        List<DependencyNode> dependencyNodes = new ArrayList<>();
314        rootNode.accept(new PreorderDependencyNodeConsumerVisitor(
315            dependencyNodes::add));
316        @SuppressWarnings("unchecked")
317        var result = (Collection<T>) dependencyNodes.stream()
318            .filter(d -> d.getArtifact() != null)
319            .map(DependencyNode::getArtifact)
320            .map(a -> extraDownloads(repoSystem, repoSession,
321                remoteRepositories, a))
322            .map(a -> ResourceFactory.create(MvnRepoLibraryJarFileType,
323                a.toString(), a.getPath()))
324            .toList();
325        return result;
326    }
327
328    private Stream<Dependency> depsFromEffectiveModel(
329            String coordinates, RepositorySystem repoSystem,
330            RepositorySystemSession repoSession,
331            List<RemoteRepository> repos) {
332        // First build raw model
333        Model model = new Model();
334        model.setModelVersion("4.0.0");
335        model.setGroupId("model.group");
336        model.setArtifactId("model.artifact");
337        model.setVersion("0.0.0");
338        model.setDescription(name());
339        var depMgmt = new DependencyManagement();
340        model.setDependencyManagement(depMgmt);
341
342        // Build raw model from boms and coordinate
343        for (String bom : boms) {
344            var dep = DependencyConverter
345                .convert(MvnRepoDependency.of(bom), "import");
346            dep.setType("pom");
347            depMgmt.addDependency(dep);
348        }
349        model.addDependency(DependencyConverter.convert(
350            MvnRepoDependency.of(coordinates), "compile"));
351
352        // Now build (derive) effective model and add its dependencies
353        var buildingRequest = new DefaultModelBuildingRequest()
354            .setRawModel(model).setProcessPlugins(false)
355            .setValidationLevel(ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL)
356            .setModelResolver(
357                new MvnModelResolver(repoSystem, repoSession, repos));
358        try {
359            var effectiveModel = new DefaultModelBuilderFactory()
360                .newInstance().build(buildingRequest).getEffectiveModel();
361            return effectiveModel.getDependencies().stream()
362                .map(DependencyConverter::convert);
363        } catch (ModelBuildingException e) {
364            throw new BuildException().from(this).cause(e);
365        }
366    }
367
368    private Artifact extraDownloads(
369            RepositorySystem repoSystem, RepositorySystemSession repoSession,
370            List<RemoteRepository> repos, Artifact artifact) {
371        if (downloadSources) {
372            downloadSourceJar(repoSystem, repoSession, repos, artifact);
373        }
374        if (downloadJavadoc) {
375            downloadJavadocJar(repoSystem, repoSession, repos, artifact);
376        }
377        return artifact;
378    }
379
380    private void downloadSourceJar(RepositorySystem repoSystem,
381            RepositorySystemSession repoSession,
382            List<RemoteRepository> repos, Artifact jarArtifact) {
383        Artifact sourcesArtifact
384            = new SubArtifact(jarArtifact, "sources", "jar");
385        ArtifactRequest sourcesRequest = new ArtifactRequest();
386        sourcesRequest.setArtifact(sourcesArtifact);
387        sourcesRequest.setRepositories(repos);
388        try {
389            repoSystem.resolveArtifact(repoSession, sourcesRequest);
390        } catch (ArtifactResolutionException e) { // NOPMD
391            // Ignore, sources are optional
392        }
393    }
394
395    private void downloadJavadocJar(RepositorySystem repoSystem,
396            RepositorySystemSession repoSession,
397            List<RemoteRepository> repos, Artifact jarArtifact) {
398        Artifact javadocArtifact
399            = new SubArtifact(jarArtifact, "javadoc", "jar");
400        ArtifactRequest sourcesRequest = new ArtifactRequest();
401        sourcesRequest.setArtifact(javadocArtifact);
402        sourcesRequest.setRepositories(repos);
403        try {
404            repoSystem.resolveArtifact(repoSession, sourcesRequest);
405        } catch (ArtifactResolutionException e) { // NOPMD
406            // Ignore, javadoc is optional
407        }
408    }
409
410    private String buildTreeString(DependencyNode node, int indent,
411            String prefix, boolean isLast) {
412        @SuppressWarnings("PMD.ShortVariable")
413        StringBuilder sb = new StringBuilder();
414        var artifact = node.getArtifact();
415
416        if (indent == 0) {
417            sb.append("root\n");
418        } else {
419            sb.append(prefix).append(isLast ? "`-- " : "|-- ")
420                .append(artifact != null ? artifact.toString() : "node")
421                .append('\n');
422        }
423
424        var children = node.getChildren();
425        String childPrefix
426            = prefix + (indent == 0 ? "  " : isLast ? "    " : "|  ");
427
428        for (int i = 0; i < children.size(); i++) {
429            sb.append(buildTreeString(children.get(i), indent + 1, childPrefix,
430                i == children.size() - 1));
431        }
432        return sb.toString();
433    }
434}