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