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.java;
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.Map;
027import java.util.Objects;
028import java.util.function.Consumer;
029import java.util.function.Supplier;
030import java.util.stream.Collectors;
031import java.util.stream.Stream;
032import static org.jdrupes.builder.api.CoreProperties.*;
033import org.jdrupes.builder.api.Cleanliness;
034import org.jdrupes.builder.api.ConfigurationException;
035import org.jdrupes.builder.api.FileResource;
036import org.jdrupes.builder.api.Project;
037import org.jdrupes.builder.api.Resource;
038import org.jdrupes.builder.api.ResourceProviderSpi;
039import org.jdrupes.builder.api.ResourceRequest;
040import org.jdrupes.builder.api.ResourceType;
041import static org.jdrupes.builder.api.ResourceType.*;
042import org.jdrupes.builder.api.Resources;
043import org.jdrupes.builder.api.TarFile;
044import org.jdrupes.builder.api.ZipFile;
045import org.jdrupes.builder.core.AbstractGenerator;
046import org.jdrupes.builder.core.StreamCollector;
047import static org.jdrupes.builder.java.JavaTypes.*;
048import org.jdrupes.builder.java.internal.ApplicationConfigurationData;
049import org.jdrupes.builder.java.internal.TarDistributionBuilder;
050import org.jdrupes.builder.java.internal.ZipDistributionBuilder;
051
052/// The [ApplicationBuilder] generates resources of type
053/// [ApplicationZipFile] or [ApplicationTarFile].
054///
055/// Both resource types are runnable application distributions built from
056/// a set of classpath resources and a generated start script that launches
057/// the application.
058///
059/// The application can be configured using methods that control:
060/// 
061///   * the [output directory][#destination(Path)] for the generated
062///     distribution,
063///   * the [base name][#distributionBaseName(Supplier)] of the generated
064///     archive file,
065///   * the executable (start script) [name][#executableName(String)],
066///   * the [main class][#mainClassName(String)] to execute (mandatory),
067///   * and the [JVM options][#applicationJvmOpts(Consumer)] required
068///     by the application and included in the generated start script.
069///
070/// Method [#add(Stream)] is used to specify the classpath resources to
071/// be included in the generated distribution and to be added to the
072/// classpath when running the application.
073/// 
074/// A request for [Cleanliness] removes any generated distribution
075/// archives from the configured destination directory.
076///
077/// The [ApplicationBuilder] avoids duplicate libraries in the distribution
078/// by deduplicating [JarFile]s based on their file path. If the same jar
079/// file is added multiple times, only one copy will be included in the
080/// generated distribution.
081///
082public class ApplicationBuilder extends AbstractGenerator {
083    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
084    private Supplier<Path> destination
085        = () -> project().buildDirectory().resolve("distributions");
086    private Supplier<String> distributionBaseName
087        = () -> project().name() + "-" + project().get(Version);
088    private final StreamCollector<ClasspathElement> resourceStreams
089        = StreamCollector.cached();
090    private final ApplicationConfigurationData config
091        = new ApplicationConfigurationData();
092
093    /// Initializes a new application builder.
094    ///
095    /// @param project the project
096    ///
097    public ApplicationBuilder(Project project) {
098        super(Objects.requireNonNull(project));
099        config.executableName(project().name());
100    }
101
102    @Override
103    public ApplicationBuilder name(String name) {
104        rename(name);
105        return this;
106    }
107
108    /// Returns the name of the script that starts the application.
109    /// The script for Windows has `.bat` appended to this name.
110    ///
111    /// @return the string
112    ///
113    public String executableName() {
114        return config.executableName();
115    }
116
117    /// Sets the executable name.
118    ///
119    /// @param name the name
120    /// @return the application builder
121    ///
122    public ApplicationBuilder executableName(String name) {
123        config.executableName(name);
124        return this;
125    }
126
127    /// Returns the destination directory. Defaults to sub directory
128    /// `applications` in the project's build directory
129    /// (see [Project#buildDirectory]).
130    ///
131    /// @return the destination
132    ///
133    public Path destination() {
134        return destination.get();
135    }
136
137    /// Sets the destination directory. The [Path] is resolved against
138    /// the project's build directory (see [Project#buildDirectory]).
139    ///
140    /// @param destination the new destination
141    /// @return the application builder
142    ///
143    public ApplicationBuilder destination(Path destination) {
144        this.destination
145            = () -> project().buildDirectory().resolve(destination);
146        return this;
147    }
148
149    /// Sets the destination directory.
150    ///
151    /// @param destination the new destination
152    /// @return the jar generator
153    ///
154    public ApplicationBuilder destination(Supplier<Path> destination) {
155        this.destination = destination;
156        return this;
157    }
158
159    /// Returns the base name of the generated TAR or ZIP file. The base
160    /// name is the file name without the extension. Defaults to the 
161    /// project's name followed by its version.
162    ///
163    /// @return the string
164    ///
165    public String distributionBaseName() {
166        return distributionBaseName.get();
167    }
168
169    /// Sets the supplier for obtaining the name of the generated
170    /// ZIP or TAR file's base name in [ResourceProviderSpi#provide].
171    ///
172    /// @param distributionBaseName the distribution base name
173    /// @return the application builder
174    ///
175    public ApplicationBuilder
176            distributionBaseName(Supplier<String> distributionBaseName) {
177        this.distributionBaseName = distributionBaseName;
178        return this;
179    }
180
181    /// Returns the main class name.
182    ///
183    /// @return the main class name
184    ///
185    public String mainClassName() {
186        return config.mainClassName();
187    }
188
189    /// Sets the name of the main class (the application entry point).
190    ///
191    /// @param name the new main class name
192    /// @return the jar generator for method chaining
193    ///
194    public ApplicationBuilder mainClassName(String name) {
195        config.mainClassName(Objects.requireNonNull(name));
196        return this;
197    }
198
199    /// Passes the mutable list of JVM options to the given consumer for
200    /// modification. The start script distinguishes between these options,
201    /// which reflect settings required by the application, and the
202    /// `JAVA_OPTS` that may be used when starting the application to tune
203    /// the JVM for specific environments.
204    ///
205    /// @param modifier the modifier
206    /// @return the list
207    ///
208    public ApplicationBuilder
209            applicationJvmOpts(Consumer<List<String>> modifier) {
210        modifier.accept(config.applicationJvmOpts());
211        return this;
212    }
213
214    /// Adds the given classpath resources to the application.
215    ///
216    /// @param resources the resources
217    /// @return the application builder
218    ///
219    public ApplicationBuilder
220            add(Stream<? extends ClasspathElement> resources) {
221        resourceStreams.add(resources);
222        return this;
223    }
224
225    @Override
226    protected <T extends Resource> Collection<T>
227            doProvide(ResourceRequest<T> request) {
228        if (!request.accepts(ApplicationZipFileType)
229            && !request.accepts(ApplicationTarFileType)
230            && !request.accepts(CleanlinessType)) {
231            return Collections.emptyList();
232        }
233
234        // Maybe only delete
235        if (request.accepts(CleanlinessType)) {
236            destination()
237                .resolve(distributionBaseName() + ".zip").toFile().delete();
238            destination()
239                .resolve(distributionBaseName() + ".tar").toFile().delete();
240            return Collections.emptyList();
241        }
242
243        // Make sure mainClass is set
244        if (mainClassName() == null) {
245            throw new ConfigurationException().from(this)
246                .message("Main class must be set for %s", name());
247        }
248
249        // Prepare the application file
250        var destDir = destination();
251        if (!destDir.toFile().exists() && !destDir.toFile().mkdirs()) {
252            throw new ConfigurationException().from(this)
253                .message("Cannot create directory " + destDir);
254        }
255
256        // Deduplicate classpath elements by path to avoid duplicate jars
257        var seenPaths = new java.util.HashSet<Path>();
258        var deduplicatedCpes = resourceStreams.stream()
259            .filter(cpe -> {
260                if (cpe instanceof JarFile jarFile) {
261                    return seenPaths.add(jarFile.path());
262                }
263                return true;
264            });
265
266        Resources<ClasspathElement> cpes = Resources.of(new ResourceType<>() {});
267        cpes.addAll(deduplicatedCpes);
268
269        FileResource distFile;
270        if (request.accepts(ApplicationZipFileType)) {
271            distFile = buildZip(cpes);
272        } else {
273            distFile = buildTar(cpes);
274        }
275        @SuppressWarnings("unchecked")
276        var result = (T) distFile;
277        return List.of(result);
278    }
279
280    private FileResource buildZip(Resources<ClasspathElement> cpes) {
281        var zipFile = ZipFile.of(ApplicationZipFileType,
282            destination().resolve(distributionBaseName() + ".zip"));
283        if (cpes.isNewerThan(zipFile)) {
284            logger.atInfo().log("%s building %s", this, zipFile);
285            new ZipDistributionBuilder().build(zipFile, config, cpes);
286        } else {
287            logger.atFine().log("%s found %s to be up to date", this, zipFile);
288        }
289        return zipFile;
290    }
291
292    private FileResource buildTar(Resources<ClasspathElement> cpes) {
293        var tarFile = TarFile.of(ApplicationTarFileType,
294            destination().resolve(distributionBaseName() + ".tar"));
295        if (cpes.isNewerThan(tarFile)) {
296            logger.atInfo().log("%s building %s", this, tarFile);
297            new TarDistributionBuilder().build(tarFile, config, cpes);
298        } else {
299            logger.atFine().log("%s found %s to be up to date", this, tarFile);
300        }
301        return tarFile;
302    }
303}