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