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///
105public class PomFileGenerator extends AbstractGenerator {
106
107    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
108    /// The Constant GENERATED_BY.
109    public static final String GENERATED_BY = "Generated by JDrupes Builder";
110    private Supplier<Path> destination
111        = () -> project().buildDirectory().resolve("publications/maven");
112    private Consumer<Model> pomAdapter = _ -> {
113    };
114
115    /// Instantiates a new library generator.
116    ///
117    /// @param project the project
118    ///
119    public PomFileGenerator(Project project) {
120        super(project);
121    }
122
123    /// Returns the destination directory. Defaults to sub directory
124    /// `publications/maven` in the project's build directory
125    /// (see [Project#buildDirectory]).
126    ///
127    /// @return the destination
128    ///
129    public Path destination() {
130        return destination.get();
131    }
132
133    /// Sets the destination directory. The [Path] is resolved against
134    /// the project's build directory (see [Project#buildDirectory]).
135    ///
136    /// @param destination the new destination
137    /// @return the POM file generator
138    ///
139    public PomFileGenerator destination(Path destination) {
140        this.destination
141            = () -> project().buildDirectory().resolve(destination);
142        return this;
143    }
144
145    /// Sets the destination directory.
146    ///
147    /// @param destination the new destination
148    /// @return the POM file generator
149    ///
150    public PomFileGenerator destination(Supplier<Path> destination) {
151        this.destination = destination;
152        return this;
153    }
154
155    @Override
156    protected <T extends Resource> Collection<T>
157            doProvide(ResourceRequest<T> requested) {
158        var pomPath = destination().resolve(Optional.ofNullable(
159            project().get(ArtifactId)).orElse(project().name()) + "-pom.xml");
160        if (cleanup(requested, pomPath)) {
161            return Collections.emptyList();
162        }
163        if (requested.accepts(MvnRepoDependencyType)) {
164            return generateRepoDependency(requested);
165        }
166        if (!requested.accepts(PomFileType)) {
167            return Collections.emptyList();
168        }
169
170        // Always provide special type
171        if (!requested.type().equals(PomFileType)) {
172            @SuppressWarnings("unchecked")
173            var result = (Collection<T>) resources(of(PomFileType)).toList();
174            return result;
175        }
176
177        pomPath.getParent().toFile().mkdirs();
178        Model model = generatePom();
179
180        // create, compare and maybe write model
181        pomPath.getParent().toFile().mkdirs();
182        try {
183            // Create new in memory
184            ByteArrayOutputStream newPom = new ByteArrayOutputStream();
185            new DefaultModelWriter().write(newPom, null, model);
186            newPom.close();
187
188            // Read existing
189            var existingContent = pomPath.toFile().exists()
190                ? Files.readAllBytes(pomPath)
191                : new byte[0];
192
193            // Compare and write
194            if (Arrays.equals(newPom.toByteArray(), existingContent)) {
195                logger.atFine().log("Existing %s is up to date.",
196                    lazy(() -> project().rootProject().directory()
197                        .relativize(pomPath)));
198            } else {
199                logger.atFine().log("Updating %s",
200                    lazy(() -> project().rootProject()
201                        .directory().relativize(pomPath)));
202                Files.write(pomPath, newPom.toByteArray());
203            }
204        } catch (IOException e) {
205            throw new BuildException().from(this).cause(e);
206        }
207
208        @SuppressWarnings("unchecked")
209        var result = (Collection<T>) List.of(PomFile.of(pomPath));
210        return result;
211    }
212
213    private Model generatePom() {
214        Model model = new Model();
215        model.setModelVersion("4.0.0");
216        model.setDependencyManagement(new DependencyManagement());
217        var groupId = project().get(GroupId);
218        if (groupId != null) {
219            model.setGroupId(groupId);
220        }
221        model.setArtifactId(Optional.ofNullable(project()
222            .get(ArtifactId)).orElse(project().name()));
223        model.setVersion(project().get(Version));
224        model.setName(project().name());
225
226        // Get declared repository dependencies. These are the
227        // explicitly declared dependencies and the repository
228        // dependencies supplied by the projects on the compile
229        // or runtime classpath.
230        var compilationDeps = Resources.of(MvnRepoDependenciesType)
231            .addAll(project().providers(Supply, Expose).without(this)
232                .without(Project.class).resources(of(MvnRepoDependencyType)))
233            .addAll(project().providers()
234                .filter(p -> p instanceof Project).select(Supply, Expose)
235                .flatMap(p -> p.resources(of(MvnRepoDependencyType)
236                    .using(Supply))));
237        addDependencies(model, compilationDeps, "compile");
238        var runtimeDeps = Resources.of(MvnRepoDependenciesType)
239            .addAll(project().providers(Reveal).without(this)
240                .without(Project.class).resources(of(MvnRepoDependencyType)))
241            .addAll(project().providers()
242                .filter(p -> p instanceof Project).select(Reveal)
243                .flatMap(p -> p.resources(of(MvnRepoDependencyType)
244                    .using(Supply))));
245        addDependencies(model, runtimeDeps, "runtime");
246
247        // Adapt
248        pomAdapter.accept(model);
249        return model;
250    }
251
252    private void addDependencies(Model model, Resources<MvnRepoDependency> deps,
253            String scope) {
254        deps.stream().forEach(d -> {
255            var dep = new Dependency();
256            dep.setGroupId(d.groupId());
257            dep.setArtifactId(d.artifactId());
258            dep.setVersion(d.version());
259            if (d instanceof MvnRepoBom) {
260                dep.setScope("import");
261                dep.setType("pom");
262                model.getDependencyManagement().addDependency(dep);
263            } else {
264                dep.setScope(scope);
265                model.addDependency(dep);
266            }
267        });
268    }
269
270    /// Allow derived classes to post process the generated POM.
271    ///
272    /// @param adaptor the adaptor
273    /// @return the pom file generator
274    ///
275    public PomFileGenerator adaptPom(Consumer<Model> adaptor) {
276        this.pomAdapter = adaptor;
277        return this;
278    }
279
280    @SuppressWarnings("unchecked")
281    private <T extends Resource> Collection<T>
282            generateRepoDependency(ResourceRequest<T> requested) {
283        if (requested.accepts(MvnRepoDependenciesType)) {
284            return Collections.emptyList();
285        }
286        return List.of((T) MvnRepoDependency.of(String.format("%s:%s:%s",
287            project().<String> get(GroupId), Optional.ofNullable(project()
288                .<String> get(ArtifactId)).orElse(project().name()),
289            project().get(Version))));
290    }
291
292}