001/*
002 * JDrupes Builder
003 * Copyright (C) 2025 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.*;
023import java.io.ByteArrayOutputStream;
024import java.io.IOException;
025import java.nio.file.Files;
026import java.nio.file.Path;
027import java.util.Arrays;
028import java.util.Optional;
029import java.util.function.Consumer;
030import java.util.function.Supplier;
031import java.util.stream.Stream;
032import org.apache.maven.model.Dependency;
033import org.apache.maven.model.DependencyManagement;
034import org.apache.maven.model.Model;
035import org.apache.maven.model.io.DefaultModelWriter;
036import org.jdrupes.builder.api.BuildException;
037import org.jdrupes.builder.api.Generator;
038import static org.jdrupes.builder.api.Intent.*;
039import org.jdrupes.builder.api.Project;
040import static org.jdrupes.builder.api.Project.Properties.*;
041import org.jdrupes.builder.api.Resource;
042import org.jdrupes.builder.api.ResourceRequest;
043import org.jdrupes.builder.api.Resources;
044import org.jdrupes.builder.core.AbstractGenerator;
045import static org.jdrupes.builder.mvnrepo.MvnProperties.*;
046import static org.jdrupes.builder.mvnrepo.MvnRepoTypes.*;
047
048/// A [Generator] (mainly) for POM files. In response to requests for
049/// [PomFile] this generator produces a Maven [Model] containing basic
050/// project information. The following properties are used:
051/// 
052///   * The groupId is set to the value of the property
053///     [MvnProperties#GroupId] if it is defined.
054/// 
055///   * The artifactId is set to the property [MvnProperties#ArtifactId],
056///     or to the name of the project if [MvnProperties#ArtifactId] is
057///     not defined.
058/// 
059///   * The version is set to the value of the property
060///     [Project.Properties#Version].
061/// 
062/// Dependencies in the model are evaluated by querying the project's
063/// providers.
064/// 
065/// Compile dependencies are evaluated as follows:
066/// 
067///   * Resources of type [MvnRepoDependency] are obtained from providers
068///     associated via `Supply` and `Expose` that are not projects. These
069///     resources are Maven repository dependencies declared by the
070///     project itself.
071/// 
072///   * Projects associated via `Supply` and `Expose` dependencies
073///     are then queried for resources of type [MvnRepoDependency]
074///     using their `Supply` dependencies. This yields the Maven
075///     repository coordinates of projects required for compilation.
076/// 
077/// Runtime dependencies are evaluated as follows:
078/// 
079///   * Resources of type [MvnRepoDependency] are obtained from providers
080///     associated via `Reveal` that are not projects. These
081///     resources represent Maven repository dependencies declared by
082///     the project itself.
083/// 
084///   * Projects associated via `Reveal` dependencies are then queried for
085///     resources of type [MvnRepoDependency] using their `Supply`
086///     dependencies. This yields the Maven repository coordinates of 
087///     projects required at runtime.
088///
089/// The resulting model is passed to [#adaptPom] for project specific
090/// customization before it is written to the POM file.
091///
092/// In addition to handling [PomFile] requests, this generator also 
093/// responds to requests for [MvnRepoDependencies][MvnRepoDependency].
094/// In this case, it returns Maven coordinates derived from the
095/// properties [MvnProperties#GroupId], [MvnProperties#ArtifactId]
096/// and [Project.Properties#Version] (see above). This reflects the
097/// assumption that a project with a POM file is intended to be released
098/// as a Maven artifact. Other projects in a multi-project build will
099/// therefore ultimately depend on the Maven artifact to be released.
100///
101@SuppressWarnings("PMD.TooManyStaticImports")
102public class PomFileGenerator extends AbstractGenerator {
103
104    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
105    /// The Constant GENERATED_BY.
106    public static final String GENERATED_BY = "Generated by JDrupes Builder";
107    private Supplier<Path> destination
108        = () -> project().buildDirectory().resolve("publications/maven");
109    private Consumer<Model> pomAdapter = _ -> {
110    };
111
112    /// Instantiates a new library generator.
113    ///
114    /// @param project the project
115    ///
116    public PomFileGenerator(Project project) {
117        super(project);
118    }
119
120    /// Returns the destination directory. Defaults to sub directory
121    /// `publications/maven` in the project's build directory
122    /// (see [Project#buildDirectory]).
123    ///
124    /// @return the destination
125    ///
126    public Path destination() {
127        return destination.get();
128    }
129
130    /// Sets the destination directory. The [Path] is resolved against
131    /// the project's build directory (see [Project#buildDirectory]).
132    ///
133    /// @param destination the new destination
134    /// @return the jar generator
135    ///
136    public PomFileGenerator destination(Path destination) {
137        this.destination
138            = () -> project().buildDirectory().resolve(destination);
139        return this;
140    }
141
142    /// Sets the destination directory.
143    ///
144    /// @param destination the new destination
145    /// @return the jar generator
146    ///
147    public PomFileGenerator destination(Supplier<Path> destination) {
148        this.destination = destination;
149        return this;
150    }
151
152    @Override
153    protected <T extends Resource> Stream<T>
154            doProvide(ResourceRequest<T> requested) {
155        var pomPath = destination().resolve(Optional.ofNullable(
156            project().get(ArtifactId)).orElse(project().name()) + "-pom.xml");
157        if (cleanup(requested, pomPath)) {
158            return Stream.empty();
159        }
160
161        if (requested.accepts(MvnRepoDependencyType)) {
162            return generateRepoDependency(requested);
163        }
164
165        if (!requested.accepts(PomFileType)) {
166            return Stream.empty();
167        }
168
169        pomPath.getParent().toFile().mkdirs();
170        Model model = generatePom();
171
172        // create, compare and maybe write model
173        pomPath.getParent().toFile().mkdirs();
174        try {
175            // Create new in memory
176            ByteArrayOutputStream newPom = new ByteArrayOutputStream();
177            new DefaultModelWriter().write(newPom, null, model);
178            newPom.close();
179
180            // Read existing
181            var existingContent = pomPath.toFile().exists()
182                ? Files.readAllBytes(pomPath)
183                : new byte[0];
184
185            // Compare and write
186            if (Arrays.equals(newPom.toByteArray(), existingContent)) {
187                logger.atFine().log("Existing %s is up to date.",
188                    lazy(() -> project().rootProject().directory()
189                        .relativize(pomPath)));
190            } else {
191                logger.atFine().log("Updating %s",
192                    lazy(() -> project().rootProject()
193                        .directory().relativize(pomPath)));
194                Files.write(pomPath, newPom.toByteArray());
195            }
196        } catch (IOException e) {
197            throw new BuildException().from(this).cause(e);
198        }
199
200        @SuppressWarnings("unchecked")
201        var result = (Stream<T>) Stream
202            .of(project().newResource(PomFileType, pomPath));
203        return result;
204    }
205
206    private Model generatePom() {
207        Model model = new Model();
208        model.setModelVersion("4.0.0");
209        model.setDependencyManagement(new DependencyManagement());
210        var groupId = project().<String> get(GroupId);
211        if (groupId != null) {
212            model.setGroupId(groupId);
213        }
214        model.setArtifactId(Optional.ofNullable(project()
215            .<String> get(ArtifactId)).orElse(project().name()));
216        model.setVersion(project().get(Version));
217        model.setName(project().name());
218
219        // Get declared repository dependencies. These are the
220        // explicitly declared dependencies and the repository
221        // dependencies supplied by the projects on the compile
222        // or runtime classpath.
223        var compilationDeps = newResource(MvnRepoDependenciesType)
224            .addAll(project().providers(Supply, Expose).without(this)
225                .without(Project.class).resources(of(MvnRepoDependencyType)))
226            .addAll(project().providers()
227                .filter(p -> p instanceof Project).select(Supply, Expose)
228                .flatMap(p -> p.resources(of(MvnRepoDependencyType)
229                    .using(Supply))));
230        addDependencies(model, compilationDeps, "compile");
231        var runtimeDeps = newResource(MvnRepoDependenciesType)
232            .addAll(project().providers(Reveal).without(this)
233                .without(Project.class).resources(of(MvnRepoDependencyType)))
234            .addAll(project().providers()
235                .filter(p -> p instanceof Project).select(Reveal)
236                .flatMap(p -> p.resources(of(MvnRepoDependencyType)
237                    .using(Supply))));
238        addDependencies(model, runtimeDeps, "runtime");
239
240        // Adapt
241        pomAdapter.accept(model);
242        return model;
243    }
244
245    private void addDependencies(Model model, Resources<MvnRepoDependency> deps,
246            String scope) {
247        deps.stream().forEach(d -> {
248            var dep = new Dependency();
249            dep.setGroupId(d.groupId());
250            dep.setArtifactId(d.artifactId());
251            dep.setVersion(d.version());
252            if (d instanceof MvnRepoBom) {
253                dep.setScope("import");
254                dep.setType("pom");
255                model.getDependencyManagement().addDependency(dep);
256            } else {
257                dep.setScope(scope);
258                model.addDependency(dep);
259            }
260        });
261    }
262
263    /// Allow derived classes to post process the generated POM.
264    ///
265    /// @param adaptor the adaptor
266    /// @return the pom file generator
267    ///
268    public PomFileGenerator adaptPom(Consumer<Model> adaptor) {
269        this.pomAdapter = adaptor;
270        return this;
271    }
272
273    @SuppressWarnings("unchecked")
274    private <T extends Resource> Stream<T>
275            generateRepoDependency(ResourceRequest<T> requested) {
276        if (requested.accepts(MvnRepoDependenciesType)) {
277            return Stream.empty();
278        }
279        return Stream.of((T) newResource(MvnRepoDependencyType,
280            project().<String> get(GroupId)
281                + ":" + Optional.ofNullable(project()
282                    .<String> get(ArtifactId)).orElse(project().name())
283                + ":" + project().get(Version)));
284    }
285
286}