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 eu.maveniverse.maven.mima.context.Context;
023import eu.maveniverse.maven.mima.context.ContextOverrides;
024import eu.maveniverse.maven.mima.context.Runtime;
025import eu.maveniverse.maven.mima.context.Runtimes;
026import java.net.URI;
027import java.util.ArrayList;
028import java.util.Arrays;
029import java.util.Collection;
030import java.util.Collections;
031import java.util.List;
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.DependencyNode;
045import org.eclipse.aether.repository.RemoteRepository;
046import org.eclipse.aether.repository.RepositoryPolicy;
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.PreorderNodeListGenerator;
053import org.jdrupes.builder.api.BuildException;
054import org.jdrupes.builder.api.Resource;
055import org.jdrupes.builder.api.ResourceFactory;
056import org.jdrupes.builder.api.ResourceRequest;
057import org.jdrupes.builder.core.AbstractProvider;
058import static org.jdrupes.builder.mvnrepo.MvnRepoTypes.*;
059
060/// Depending on the request, this provider provides two types of resources.
061/// 
062///  1. The artifacts to be resolved as resources of type [MvnRepoDependency].
063///     The artifacts to be resolved are those added with [resolve].
064///     Note that the result also includes the [MvnRepoBom]s.
065///
066///  2. The resources of type [MvnRepoLibraryJarFile] that result from
067///     resolving the artifacts to be resolved.
068///
069@SuppressWarnings("PMD.CouplingBetweenObjects")
070public class MvnRepoLookup extends AbstractProvider {
071
072    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
073    private static Context rootContextInstance;
074    private final List<String> coordinates = new ArrayList<>();
075    private final List<String> boms = new ArrayList<>();
076    private boolean downloadSources = true;
077    private boolean downloadJavadoc = true;
078    private URI snapshotUri;
079    private boolean probeMode;
080
081    /// Initializes a new Maven repository lookup.
082    ///
083    public MvnRepoLookup() {
084        // Make javadoc happy.
085    }
086
087    /// Lazily creates the root context.
088    /// 
089    /// @return the context
090    ///
091    /* default */ static Context rootContext() {
092        if (rootContextInstance != null) {
093            return rootContextInstance;
094        }
095        ContextOverrides overrides = ContextOverrides.create()
096            .withUserSettings(true).build();
097        Runtime runtime = Runtimes.INSTANCE.getRuntime();
098        rootContextInstance = runtime.create(overrides);
099        logger.atFine().log("Using repositories: %s",
100            rootContextInstance.remoteRepositories().stream()
101                .map(RemoteRepository::getUrl)
102                .collect(Collectors.joining(", ")));
103        return rootContextInstance;
104    }
105
106    /// Sets the Maven snapshot repository URI.
107    ///
108    /// @param uri the snapshot repository URI
109    /// @return the mvn repo lookup
110    ///
111    public MvnRepoLookup snapshotRepository(URI uri) {
112        this.snapshotUri = uri;
113        return this;
114    }
115
116    /// Returns the snapshot repository. Defaults to
117    /// `https://central.sonatype.com/repository/maven-snapshots/`.
118    ///
119    /// @return the snapshot repository
120    ///
121    public URI snapshotRepository() {
122        return snapshotUri;
123    }
124
125    /// Add a bill of materials. The coordinates are resolved as 
126    /// a dependency with scope `import` which is added to the
127    /// `dependencyManagement` section.
128    ///
129    /// @param coordinates the coordinates
130    /// @return the mvn repo lookup
131    ///
132    public MvnRepoLookup bom(String... coordinates) {
133        boms.addAll(Arrays.asList(coordinates));
134        return this;
135    }
136
137    /// Add artifacts, specified by their coordinates
138    /// (`groupId:artifactId:version`) as resources.
139    ///
140    /// @param coordinates the coordinates
141    /// @return the mvn repo lookup
142    ///
143    public MvnRepoLookup resolve(String... coordinates) {
144        this.coordinates.addAll(Arrays.asList(coordinates));
145        return this;
146    }
147
148    /// Add artifacts. The method handles [MvnRepoBom]s correctly.
149    ///
150    /// @param resources the resources
151    /// @return the mvn repo lookup
152    ///
153    public MvnRepoLookup resolve(Stream<? extends MvnRepoResource> resources) {
154        resources.forEach(r -> {
155            if (r instanceof MvnRepoBom) {
156                bom(r.coordinates());
157            } else {
158                resolve(r.coordinates());
159            }
160        });
161        return this;
162    }
163
164    /// Failing to resolve the dependencies normally results in a
165    /// [BuildException], because the requested artifacts are assumed
166    /// to be required for the build.
167    /// 
168    /// By invoking this method the provider enters probe mode
169    /// and returns an empty result stream instead of throwing an
170    /// exception if the resolution fails.
171    ///
172    /// @return the mvn repo lookup
173    ///
174    public MvnRepoLookup probe() {
175        probeMode = true;
176        logger.atFine().log("Probe mode enabled for %s", this);
177        return this;
178    }
179
180    /// Whether to also download the sources. Defaults to `true`.
181    ///
182    /// @param enable the enable
183    /// @return the mvn repo lookup
184    ///
185    public MvnRepoLookup downloadSources(boolean enable) {
186        this.downloadSources = enable;
187        return this;
188    }
189
190    /// Whether to also download the javadoc. Defaults to `true`.
191    ///
192    /// @param enable the enable
193    /// @return the mvn repo lookup
194    ///
195    public MvnRepoLookup downloadJavadoc(boolean enable) {
196        this.downloadJavadoc = enable;
197        return this;
198    }
199
200    /// Provide.
201    ///
202    /// @param <T> the generic type
203    /// @param request the requested resources
204    /// @return the stream
205    ///
206    @Override
207    protected <T extends Resource> Collection<T>
208            doProvide(ResourceRequest<T> request) {
209        if (request.accepts(MvnRepoDependencyType)) {
210            return provideMvnDeps();
211        }
212        if (!request.accepts(MvnRepoLibraryJarFileType)) {
213            return Collections.emptyList();
214        }
215        if (!request.isFor(MvnRepoLibraryJarFileType)) {
216            @SuppressWarnings({ "unchecked", "PMD.AvoidDuplicateLiterals" })
217            var result = (Collection<T>) context()
218                .resources(this, of(MvnRepoLibraryJarFileType)).toList();
219            return result;
220        }
221        try {
222            return provideJars();
223        } catch (ModelBuildingException e) {
224            throw new BuildException().from(this).cause(e);
225        } catch (DependencyResolutionException e) {
226            if (probeMode) {
227                return Collections.emptyList();
228            }
229            throw new BuildException().from(this).cause(e);
230        }
231    }
232
233    private <T extends Resource> Collection<T> provideMvnDeps() {
234        @SuppressWarnings("unchecked")
235        var boms = (Stream<T>) this.boms.stream()
236            .map(MvnRepoBom::of);
237        @SuppressWarnings("unchecked")
238        var deps = (Stream<T>) coordinates.stream()
239            .map(MvnRepoDependency::of);
240        return Stream.concat(boms, deps).toList();
241    }
242
243    private <T extends Resource> Collection<T> provideJars()
244            throws DependencyResolutionException, ModelBuildingException {
245        var repoSystem = rootContext().repositorySystem();
246        var repoSession = rootContext().repositorySystemSession();
247        var repos = new ArrayList<>(rootContext().remoteRepositories());
248        if (snapshotUri != null) {
249            repos.add(createSnapshotRepository());
250        }
251
252        // Create an effective model. This make sure that BOMs are
253        // handled correctly.
254        Model model = getEffectiveModel(repoSystem, repoSession,
255            repos);
256
257        // Create collect request using data from the (effective) model
258        CollectRequest collectRequest
259            = new CollectRequest().setRepositories(repos);
260        collectRequest.setManagedDependencies(
261            model.getDependencyManagement().getDependencies().stream()
262                .map(DependencyConverter::convert).toList());
263        model.getDependencies().stream().map(DependencyConverter::convert)
264            .forEach(collectRequest::addDependency);
265
266        // Resolve dependencies
267        DependencyRequest dependencyRequest
268            = new DependencyRequest(collectRequest, null);
269        DependencyNode rootNode;
270        rootNode = repoSystem.resolveDependencies(repoSession,
271            dependencyRequest).getRoot();
272// For maven 2.x libraries:
273//                List<DependencyNode> dependencyNodes = new ArrayList<>();
274//                rootNode.accept(new PreorderDependencyNodeConsumerVisitor(
275//                    dependencyNodes::add));
276        PreorderNodeListGenerator nlg = new PreorderNodeListGenerator();
277        rootNode.accept(nlg);
278        List<DependencyNode> dependencyNodes = nlg.getNodes();
279        @SuppressWarnings("unchecked")
280        var result = (Collection<T>) dependencyNodes.stream()
281            .filter(d -> d.getArtifact() != null)
282            .map(DependencyNode::getArtifact)
283            .map(a -> {
284                if (downloadSources) {
285                    downloadSourceJar(repoSystem, repoSession, a);
286                }
287                if (downloadJavadoc) {
288                    downloadJavadocJar(repoSystem, repoSession, a);
289                }
290                return a;
291            })
292            .map(a -> ResourceFactory.create(MvnRepoLibraryJarFileType,
293                a.toString(), a.getFile().toPath()))
294            .toList();
295        return result;
296    }
297
298    private Model getEffectiveModel(RepositorySystem repoSystem,
299            RepositorySystemSession repoSession, List<RemoteRepository> repos)
300            throws ModelBuildingException {
301        // First build raw model
302        Model model = new Model();
303        model.setModelVersion("4.0.0");
304        model.setGroupId("model.group");
305        model.setArtifactId("model.artifact");
306        model.setVersion("0.0.0");
307        var depMgmt = new DependencyManagement();
308        model.setDependencyManagement(depMgmt);
309        boms.stream().forEach(c -> {
310            var mvnResource = MvnRepoDependency.of(c);
311            var dep = DependencyConverter.convert(mvnResource, "import");
312            dep.setType("pom");
313            depMgmt.addDependency(dep);
314        });
315        coordinates.forEach(c -> {
316            var mvnResource = MvnRepoDependency.of(c);
317            model.addDependency(
318                DependencyConverter.convert(mvnResource, "compile"));
319        });
320
321        // Now build (derive) effective model
322        var buildingRequest = new DefaultModelBuildingRequest()
323            .setRawModel(model).setProcessPlugins(false)
324            .setValidationLevel(ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL)
325            .setModelResolver(
326                new MvnModelResolver(repoSystem, repoSession, repos));
327        return new DefaultModelBuilderFactory()
328            .newInstance().build(buildingRequest).getEffectiveModel();
329    }
330
331    private RemoteRepository createSnapshotRepository() {
332        return new RemoteRepository.Builder(
333            "snapshots", "default", snapshotUri.toString())
334                .setSnapshotPolicy(new RepositoryPolicy(
335                    true,  // enable snapshots
336                    RepositoryPolicy.UPDATE_POLICY_ALWAYS,
337                    RepositoryPolicy.CHECKSUM_POLICY_WARN))
338                .setReleasePolicy(new RepositoryPolicy(
339                    false,
340                    RepositoryPolicy.UPDATE_POLICY_NEVER,
341                    RepositoryPolicy.CHECKSUM_POLICY_IGNORE))
342                .build();
343    }
344
345    private void downloadSourceJar(RepositorySystem repoSystem,
346            RepositorySystemSession repoSession, Artifact jarArtifact) {
347        Artifact sourcesArtifact
348            = new SubArtifact(jarArtifact, "sources", "jar");
349        ArtifactRequest sourcesRequest = new ArtifactRequest();
350        sourcesRequest.setArtifact(sourcesArtifact);
351        sourcesRequest.setRepositories(rootContext().remoteRepositories());
352        try {
353            repoSystem.resolveArtifact(repoSession, sourcesRequest);
354        } catch (ArtifactResolutionException e) { // NOPMD
355            // Ignore, sources are optional
356        }
357    }
358
359    private void downloadJavadocJar(RepositorySystem repoSystem,
360            RepositorySystemSession repoSession, Artifact jarArtifact) {
361        Artifact javadocArtifact
362            = new SubArtifact(jarArtifact, "javadoc", "jar");
363        ArtifactRequest sourcesRequest = new ArtifactRequest();
364        sourcesRequest.setArtifact(javadocArtifact);
365        sourcesRequest.setRepositories(rootContext().remoteRepositories());
366        try {
367            repoSystem.resolveArtifact(repoSession, sourcesRequest);
368        } catch (ArtifactResolutionException e) { // NOPMD
369            // Ignore, javadoc is optional
370        }
371    }
372}