001/*
002 * JDrupes Builder
003 * Copyright (C) 2026 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.distribution;
020
021import com.google.common.flogger.FluentLogger;
022import java.nio.file.Path;
023import java.util.Collection;
024import java.util.Collections;
025import java.util.HashSet;
026import java.util.List;
027import java.util.Objects;
028import java.util.Set;
029import java.util.function.Consumer;
030import java.util.function.Supplier;
031import java.util.stream.Stream;
032import org.eclipse.aether.repository.RemoteRepository;
033import org.jdrupes.builder.api.Cleanliness;
034import org.jdrupes.builder.api.ConfigurationException;
035import static org.jdrupes.builder.api.CoreProperties.*;
036import org.jdrupes.builder.api.FileResource;
037import org.jdrupes.builder.api.Intent;
038import static org.jdrupes.builder.api.Intent.*;
039import org.jdrupes.builder.api.Project;
040import org.jdrupes.builder.api.Resource;
041import org.jdrupes.builder.api.ResourceProvider;
042import org.jdrupes.builder.api.ResourceProviderSpi;
043import org.jdrupes.builder.api.ResourceRequest;
044import org.jdrupes.builder.api.ResourceRetriever;
045import static org.jdrupes.builder.api.ResourceType.*;
046import org.jdrupes.builder.api.Resources;
047import org.jdrupes.builder.api.TarFile;
048import org.jdrupes.builder.api.ZipFile;
049import org.jdrupes.builder.core.AbstractGenerator;
050import org.jdrupes.builder.core.StreamCollector;
051import static org.jdrupes.builder.distribution.DistributionTypes.*;
052import org.jdrupes.builder.distribution.internal.ApplicationConfigurationData;
053import org.jdrupes.builder.distribution.internal.TarDistributionBuilder;
054import org.jdrupes.builder.distribution.internal.ZipDistributionBuilder;
055import org.jdrupes.builder.java.CodeContribution;
056import static org.jdrupes.builder.java.JavaTypes.*;
057import org.jdrupes.builder.java.LibraryJarFile;
058import org.jdrupes.builder.mvnrepo.MvnRepoJarFile;
059import org.jdrupes.builder.mvnrepo.MvnRepoLibraryJarFile;
060import org.jdrupes.builder.mvnrepo.MvnRepoLookup;
061import org.jdrupes.builder.mvnrepo.MvnRepoResource;
062import static org.jdrupes.builder.mvnrepo.MvnRepoTypes.*;
063
064/// The [ApplicationBuilder] generates application distributions as
065/// resources of type [ApplicationZipFile] or [ApplicationTarFile].
066///
067/// Both resource types represent runnable application distributions 
068/// consisting of code contributions and a generated start script
069/// that launches the application.
070///
071/// The application can be configured using methods that control:
072/// 
073///   * the [output directory][#destination(Path)] for the generated
074///     distribution,
075///   * the [base name][#distributionBaseName(Supplier)] of the generated
076///     archive file,
077///   * the executable (start script) [name][#executableName(String)],
078///   * the [main class][#mainClassName(String)] to execute (mandatory),
079///   * and the [JVM options][#applicationJvmOpts(Consumer)] required
080///     by the application and included in the generated start script.
081///
082/// Method [#add(Stream)] is used to specify the code contributions to
083/// be included in the generated distribution and added to the
084/// classpath or module-path when running the application. In addition,
085/// the application builder adds the resources obtained from the providers
086/// specified with [#addFrom], using a request for resources of type
087/// [LibraryJarFile] with all [intents][Intent].
088/// 
089/// Special handling is provided for resources of type [MvnRepoJarFile].
090/// For these resources the associated [MvnRepoResource] information is
091/// collected first. The collected coordinates are then used to resolve
092/// the corresponding jar files from the Maven repository. The resolved
093/// JAR files are then added to the generated distribution. This prevents
094/// different versions of the same library to be included in the
095/// distribution.
096/// 
097/// A request for [Cleanliness] removes any generated distribution
098/// archives from the configured destination directory.
099///
100public class ApplicationBuilder extends AbstractGenerator
101        implements ResourceRetriever {
102    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
103    private Supplier<Path> destination
104        = () -> project().buildDirectory().resolve("distributions");
105    private Supplier<String> distributionBaseName
106        = () -> project().name() + "-" + project().get(Version);
107    private final StreamCollector<CodeContribution> resourceStreams
108        = StreamCollector.cached();
109    private final StreamCollector<ResourceProvider> providers
110        = StreamCollector.uncached();
111    private boolean providersProcessed;
112    private final ApplicationConfigurationData config
113        = new ApplicationConfigurationData();
114
115    /// Initializes a new application builder.
116    ///
117    /// @param project the project
118    ///
119    public ApplicationBuilder(Project project) {
120        super(Objects.requireNonNull(project));
121        config.executableName(project().name());
122    }
123
124    @Override
125    public ApplicationBuilder name(String name) {
126        rename(name);
127        return this;
128    }
129
130    /// Returns the name of the script that starts the application.
131    /// The script for Windows has `.bat` appended to this name.
132    ///
133    /// @return the string
134    ///
135    public String executableName() {
136        return config.executableName();
137    }
138
139    /// Sets the executable name.
140    ///
141    /// @param name the name
142    /// @return the application builder
143    ///
144    public ApplicationBuilder executableName(String name) {
145        config.executableName(name);
146        return this;
147    }
148
149    /// Returns the destination directory. Defaults to sub directory
150    /// `applications` in the project's build directory
151    /// (see [Project#buildDirectory]).
152    ///
153    /// @return the destination
154    ///
155    public Path destination() {
156        return destination.get();
157    }
158
159    /// Sets the destination directory. The [Path] is resolved against
160    /// the project's build directory (see [Project#buildDirectory]).
161    ///
162    /// @param destination the new destination
163    /// @return the application builder
164    ///
165    public ApplicationBuilder destination(Path destination) {
166        this.destination
167            = () -> project().buildDirectory().resolve(destination);
168        return this;
169    }
170
171    /// Sets the destination directory.
172    ///
173    /// @param destination the new destination
174    /// @return the jar generator
175    ///
176    public ApplicationBuilder destination(Supplier<Path> destination) {
177        this.destination = destination;
178        return this;
179    }
180
181    /// Returns the base name of the generated TAR or ZIP file. The base
182    /// name is the file name without the extension. Defaults to the 
183    /// project's name followed by its version.
184    ///
185    /// @return the string
186    ///
187    public String distributionBaseName() {
188        return distributionBaseName.get();
189    }
190
191    /// Sets the supplier for obtaining the name of the generated
192    /// ZIP or TAR file's base name in [ResourceProviderSpi#provide].
193    ///
194    /// @param distributionBaseName the distribution base name
195    /// @return the application builder
196    ///
197    public ApplicationBuilder
198            distributionBaseName(Supplier<String> distributionBaseName) {
199        this.distributionBaseName = distributionBaseName;
200        return this;
201    }
202
203    /// Returns the main class name.
204    ///
205    /// @return the main class name
206    ///
207    public String mainClassName() {
208        return config.mainClassName();
209    }
210
211    /// Sets the name of the main class (the application entry point).
212    ///
213    /// @param name the new main class name
214    /// @return the jar generator for method chaining
215    ///
216    public ApplicationBuilder mainClassName(String name) {
217        config.mainClassName(Objects.requireNonNull(name));
218        return this;
219    }
220
221    /// Passes the mutable list of JVM options to the given consumer for
222    /// modification. The start script distinguishes between these options,
223    /// which reflect settings required by the application, and the
224    /// `JAVA_OPTS` that may be used when starting the application to tune
225    /// the JVM for specific environments.
226    ///
227    /// @param modifier the modifier
228    /// @return the list
229    ///
230    public ApplicationBuilder
231            applicationJvmOpts(Consumer<List<String>> modifier) {
232        modifier.accept(config.applicationJvmOpts());
233        return this;
234    }
235
236    /// Adds the given code contributions to the application.
237    ///
238    /// @param resources the resources
239    /// @return the application builder
240    ///
241    public ApplicationBuilder
242            add(Stream<? extends CodeContribution> resources) {
243        resourceStreams.add(resources);
244        return this;
245    }
246
247    @Override
248    public ResourceRetriever addFrom(Stream<ResourceProvider> providers) {
249        this.providers.add(providers);
250        return this;
251    }
252
253    @Override
254    @SuppressWarnings({ "PMD.CognitiveComplexity", "PMD.NcssCount" })
255    protected <T extends Resource> Collection<T>
256            doProvide(ResourceRequest<T> request) {
257        // Maybe only delete
258        if (request.accepts(CleanlinessType)) {
259            destination()
260                .resolve(distributionBaseName() + ".zip").toFile().delete();
261            destination()
262                .resolve(distributionBaseName() + ".tar").toFile().delete();
263            return Collections.emptyList();
264        }
265
266        // Check if provided and evaluate for most special type
267        if (!request.accepts(ApplicationZipFileType, ApplicationTarFileType)) {
268            return Collections.emptyList();
269        }
270        if (request.accepts(ApplicationZipFileType)
271                && !request.isFor(ApplicationZipFileType)) {
272            @SuppressWarnings("unchecked")
273            var result = (Collection<T>) context().resources(this, project()
274                .of(ApplicationZipFileType)).toList();
275            return result;
276        }
277        if (request.accepts(ApplicationTarFileType)
278                && !request.isFor(ApplicationTarFileType)) {
279            @SuppressWarnings("unchecked")
280            var result = (Collection<T>) context().resources(this, project()
281                .of(ApplicationTarFileType)).toList();
282            return result;
283        }
284
285        // Make sure mainClass is set
286        if (mainClassName() == null) {
287            throw new ConfigurationException().from(this)
288                .message("Main class must be set for %s", name());
289        }
290
291        // Prepare the application file
292        var destDir = destination();
293        if (!destDir.toFile().exists() && !destDir.toFile().mkdirs()) {
294            throw new ConfigurationException().from(this)
295                .message("Cannot create directory " + destDir);
296        }
297
298        // Collect jars
299        if (!providersProcessed) {
300            resourceStreams.add(providers.stream()
301                .map(p -> p.resources(of(LibraryJarFileType).usingAll()))
302                .flatMap(s -> s));
303            providersProcessed = true;
304        }
305        var cpes = Resources.with(CodeContributionType);
306        Set<RemoteRepository> repos = new HashSet<>();
307        var repoRefs = Resources.with(MvnRepoResourceType);
308        resourceStreams.stream().forEach(r -> {
309            if (r instanceof MvnRepoJarFile repoJar) {
310                repoRefs.add(repoJar.reference());
311                repos.addAll(repoJar.repositories());
312            } else {
313                cpes.add(r);
314            }
315        });
316        // Jar files from maven repositories must be resolved before
317        // they can be added to the application to avoid duplicates.
318        var lookup = new MvnRepoLookup()
319            .addRepositories(repos.toArray(RemoteRepository[]::new));
320        lookup.resolve(repoRefs.stream());
321        project().context().resources(lookup, of(CodeContributionType)
322            .using(Consume, Reveal, Supply, Expose))
323            .forEach(cpe -> {
324                if (cpe instanceof MvnRepoLibraryJarFile jarFile) {
325                    cpes.add(jarFile);
326                }
327            });
328
329        // Now build distribution
330        FileResource distFile;
331        if (request.accepts(ApplicationZipFileType)) {
332            distFile = buildZip(cpes);
333        } else {
334            distFile = buildTar(cpes);
335        }
336        @SuppressWarnings("unchecked")
337        var result = (T) distFile;
338        return List.of(result);
339    }
340
341    private FileResource buildZip(Resources<CodeContribution> cpes) {
342        var zipFile = ZipFile.of(ApplicationZipFileType,
343            destination().resolve(distributionBaseName() + ".zip"));
344        if (cpes.isNewerThan(zipFile)) {
345            logger.atInfo().log("%s building %s", this, zipFile);
346            new ZipDistributionBuilder().build(zipFile, config, cpes);
347        } else {
348            logger.atFine().log("%s found %s to be up to date", this, zipFile);
349        }
350        return zipFile;
351    }
352
353    private FileResource buildTar(Resources<CodeContribution> cpes) {
354        var tarFile = TarFile.of(ApplicationTarFileType,
355            destination().resolve(distributionBaseName() + ".tar"));
356        if (cpes.isNewerThan(tarFile)) {
357            logger.atInfo().log("%s building %s", this, tarFile);
358            new TarDistributionBuilder().build(tarFile, config, cpes);
359        } else {
360            logger.atFine().log("%s found %s to be up to date", this, tarFile);
361        }
362        return tarFile;
363    }
364}