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        if (requested.accepts(MvnRepoDependencyType)) {
161            return generateRepoDependency(requested);
162        }
163        if (!requested.accepts(PomFileType)) {
164            return Stream.empty();
165        }
166
167        // Always provide special type
168        if (!requested.type().equals(PomFileType)) {
169            @SuppressWarnings("unchecked")
170            var result = (Stream<T>) resources(of(PomFileType));
171            return result;
172        }
173
174        pomPath.getParent().toFile().mkdirs();
175        Model model = generatePom();
176
177        // create, compare and maybe write model
178        pomPath.getParent().toFile().mkdirs();
179        try {
180            // Create new in memory
181            ByteArrayOutputStream newPom = new ByteArrayOutputStream();
182            new DefaultModelWriter().write(newPom, null, model);
183            newPom.close();
184
185            // Read existing
186            var existingContent = pomPath.toFile().exists()
187                ? Files.readAllBytes(pomPath)
188                : new byte[0];
189
190            // Compare and write
191            if (Arrays.equals(newPom.toByteArray(), existingContent)) {
192                logger.atFine().log("Existing %s is up to date.",
193                    lazy(() -> project().rootProject().directory()
194                        .relativize(pomPath)));
195            } else {
196                logger.atFine().log("Updating %s",
197                    lazy(() -> project().rootProject()
198                        .directory().relativize(pomPath)));
199                Files.write(pomPath, newPom.toByteArray());
200            }
201        } catch (IOException e) {
202            throw new BuildException().from(this).cause(e);
203        }
204
205        @SuppressWarnings("unchecked")
206        var result = (Stream<T>) Stream.of(PomFile.of(pomPath));
207        return result;
208    }
209
210    private Model generatePom() {
211        Model model = new Model();
212        model.setModelVersion("4.0.0");
213        model.setDependencyManagement(new DependencyManagement());
214        var groupId = project().<String> get(GroupId);
215        if (groupId != null) {
216            model.setGroupId(groupId);
217        }
218        model.setArtifactId(Optional.ofNullable(project()
219            .<String> get(ArtifactId)).orElse(project().name()));
220        model.setVersion(project().get(Version));
221        model.setName(project().name());
222
223        // Get declared repository dependencies. These are the
224        // explicitly declared dependencies and the repository
225        // dependencies supplied by the projects on the compile
226        // or runtime classpath.
227        var compilationDeps = Resources.of(MvnRepoDependenciesType)
228            .addAll(project().providers(Supply, Expose).without(this)
229                .without(Project.class).resources(of(MvnRepoDependencyType)))
230            .addAll(project().providers()
231                .filter(p -> p instanceof Project).select(Supply, Expose)
232                .flatMap(p -> p.resources(of(MvnRepoDependencyType)
233                    .using(Supply))));
234        addDependencies(model, compilationDeps, "compile");
235        var runtimeDeps = Resources.of(MvnRepoDependenciesType)
236            .addAll(project().providers(Reveal).without(this)
237                .without(Project.class).resources(of(MvnRepoDependencyType)))
238            .addAll(project().providers()
239                .filter(p -> p instanceof Project).select(Reveal)
240                .flatMap(p -> p.resources(of(MvnRepoDependencyType)
241                    .using(Supply))));
242        addDependencies(model, runtimeDeps, "runtime");
243
244        // Adapt
245        pomAdapter.accept(model);
246        return model;
247    }
248
249    private void addDependencies(Model model, Resources<MvnRepoDependency> deps,
250            String scope) {
251        deps.stream().forEach(d -> {
252            var dep = new Dependency();
253            dep.setGroupId(d.groupId());
254            dep.setArtifactId(d.artifactId());
255            dep.setVersion(d.version());
256            if (d instanceof MvnRepoBom) {
257                dep.setScope("import");
258                dep.setType("pom");
259                model.getDependencyManagement().addDependency(dep);
260            } else {
261                dep.setScope(scope);
262                model.addDependency(dep);
263            }
264        });
265    }
266
267    /// Allow derived classes to post process the generated POM.
268    ///
269    /// @param adaptor the adaptor
270    /// @return the pom file generator
271    ///
272    public PomFileGenerator adaptPom(Consumer<Model> adaptor) {
273        this.pomAdapter = adaptor;
274        return this;
275    }
276
277    @SuppressWarnings("unchecked")
278    private <T extends Resource> Stream<T>
279            generateRepoDependency(ResourceRequest<T> requested) {
280        if (requested.accepts(MvnRepoDependenciesType)) {
281            return Stream.empty();
282        }
283        return Stream.of((T) MvnRepoDependency.of(String.format("%s:%s:%s",
284            project().<String> get(GroupId), Optional.ofNullable(project()
285                .<String> get(ArtifactId)).orElse(project().name()),
286            project().get(Version))));
287    }
288
289}