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