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