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///
097public class ApplicationBuilder extends AbstractGenerator
098        implements ResourceRetriever {
099    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
100    private Supplier<Path> destination
101        = () -> project().buildDirectory().resolve("distributions");
102    private Supplier<String> distributionBaseName
103        = () -> project().name() + "-" + project().get(Version);
104    private final StreamCollector<ClasspathElement> resourceStreams
105        = StreamCollector.cached();
106    private final StreamCollector<ResourceProvider> providers
107        = StreamCollector.uncached();
108    private boolean providersProcessed;
109    private final ApplicationConfigurationData config
110        = new ApplicationConfigurationData();
111
112    /// Initializes a new application builder.
113    ///
114    /// @param project the project
115    ///
116    public ApplicationBuilder(Project project) {
117        super(Objects.requireNonNull(project));
118        config.executableName(project().name());
119    }
120
121    @Override
122    public ApplicationBuilder name(String name) {
123        rename(name);
124        return this;
125    }
126
127    /// Returns the name of the script that starts the application.
128    /// The script for Windows has `.bat` appended to this name.
129    ///
130    /// @return the string
131    ///
132    public String executableName() {
133        return config.executableName();
134    }
135
136    /// Sets the executable name.
137    ///
138    /// @param name the name
139    /// @return the application builder
140    ///
141    public ApplicationBuilder executableName(String name) {
142        config.executableName(name);
143        return this;
144    }
145
146    /// Returns the destination directory. Defaults to sub directory
147    /// `applications` in the project's build directory
148    /// (see [Project#buildDirectory]).
149    ///
150    /// @return the destination
151    ///
152    public Path destination() {
153        return destination.get();
154    }
155
156    /// Sets the destination directory. The [Path] is resolved against
157    /// the project's build directory (see [Project#buildDirectory]).
158    ///
159    /// @param destination the new destination
160    /// @return the application builder
161    ///
162    public ApplicationBuilder destination(Path destination) {
163        this.destination
164            = () -> project().buildDirectory().resolve(destination);
165        return this;
166    }
167
168    /// Sets the destination directory.
169    ///
170    /// @param destination the new destination
171    /// @return the jar generator
172    ///
173    public ApplicationBuilder destination(Supplier<Path> destination) {
174        this.destination = destination;
175        return this;
176    }
177
178    /// Returns the base name of the generated TAR or ZIP file. The base
179    /// name is the file name without the extension. Defaults to the 
180    /// project's name followed by its version.
181    ///
182    /// @return the string
183    ///
184    public String distributionBaseName() {
185        return distributionBaseName.get();
186    }
187
188    /// Sets the supplier for obtaining the name of the generated
189    /// ZIP or TAR file's base name in [ResourceProviderSpi#provide].
190    ///
191    /// @param distributionBaseName the distribution base name
192    /// @return the application builder
193    ///
194    public ApplicationBuilder
195            distributionBaseName(Supplier<String> distributionBaseName) {
196        this.distributionBaseName = distributionBaseName;
197        return this;
198    }
199
200    /// Returns the main class name.
201    ///
202    /// @return the main class name
203    ///
204    public String mainClassName() {
205        return config.mainClassName();
206    }
207
208    /// Sets the name of the main class (the application entry point).
209    ///
210    /// @param name the new main class name
211    /// @return the jar generator for method chaining
212    ///
213    public ApplicationBuilder mainClassName(String name) {
214        config.mainClassName(Objects.requireNonNull(name));
215        return this;
216    }
217
218    /// Passes the mutable list of JVM options to the given consumer for
219    /// modification. The start script distinguishes between these options,
220    /// which reflect settings required by the application, and the
221    /// `JAVA_OPTS` that may be used when starting the application to tune
222    /// the JVM for specific environments.
223    ///
224    /// @param modifier the modifier
225    /// @return the list
226    ///
227    public ApplicationBuilder
228            applicationJvmOpts(Consumer<List<String>> modifier) {
229        modifier.accept(config.applicationJvmOpts());
230        return this;
231    }
232
233    /// Adds the given classpath resources to the application.
234    ///
235    /// @param resources the resources
236    /// @return the application builder
237    ///
238    public ApplicationBuilder
239            add(Stream<? extends ClasspathElement> resources) {
240        resourceStreams.add(resources);
241        return this;
242    }
243
244    @Override
245    public ResourceRetriever addFrom(Stream<ResourceProvider> providers) {
246        this.providers.add(providers);
247        return this;
248    }
249
250    @Override
251    protected <T extends Resource> Collection<T>
252            doProvide(ResourceRequest<T> request) {
253        if (!request.accepts(ApplicationZipFileType)
254            && !request.accepts(ApplicationTarFileType)
255            && !request.accepts(CleanlinessType)) {
256            return Collections.emptyList();
257        }
258
259        // Maybe only delete
260        if (request.accepts(CleanlinessType)) {
261            destination()
262                .resolve(distributionBaseName() + ".zip").toFile().delete();
263            destination()
264                .resolve(distributionBaseName() + ".tar").toFile().delete();
265            return Collections.emptyList();
266        }
267
268        // Make sure mainClass is set
269        if (mainClassName() == null) {
270            throw new ConfigurationException().from(this)
271                .message("Main class must be set for %s", name());
272        }
273
274        // Prepare the application file
275        var destDir = destination();
276        if (!destDir.toFile().exists() && !destDir.toFile().mkdirs()) {
277            throw new ConfigurationException().from(this)
278                .message("Cannot create directory " + destDir);
279        }
280
281        // Collect jars
282        if (!providersProcessed) {
283            resourceStreams.add(providers.stream()
284                .map(p -> p.resources(of(LibraryJarFileType).usingAll()))
285                .flatMap(s -> s));
286            providersProcessed = true;
287        }
288        var cpes = Resources.with(ClasspathElementType);
289        var repoRefs = Resources.with(MvnRepoResourceType);
290        resourceStreams.stream().forEach(r -> {
291            if (r instanceof MvnRepoJarFile repoJar) {
292                repoRefs.add(repoJar.reference());
293            } else {
294                cpes.add(r);
295            }
296        });
297        // Jar files from maven repositories must be resolved before
298        // they can be added to the application to avoid duplicates.
299        var lookup = new MvnRepoLookup();
300        lookup.resolve(repoRefs.stream());
301        project().context().resources(lookup, of(ClasspathElementType)
302            .using(Consume, Reveal, Supply, Expose))
303            .forEach(cpe -> {
304                if (cpe instanceof MvnRepoLibraryJarFile jarFile) {
305                    cpes.add(jarFile);
306                }
307            });
308
309        // Now build distribution
310        FileResource distFile;
311        if (request.accepts(ApplicationZipFileType)) {
312            distFile = buildZip(cpes);
313        } else {
314            distFile = buildTar(cpes);
315        }
316        @SuppressWarnings("unchecked")
317        var result = (T) distFile;
318        return List.of(result);
319    }
320
321    private FileResource buildZip(Resources<ClasspathElement> cpes) {
322        var zipFile = ZipFile.of(ApplicationZipFileType,
323            destination().resolve(distributionBaseName() + ".zip"));
324        if (cpes.isNewerThan(zipFile)) {
325            logger.atInfo().log("%s building %s", this, zipFile);
326            new ZipDistributionBuilder().build(zipFile, config, cpes);
327        } else {
328            logger.atFine().log("%s found %s to be up to date", this, zipFile);
329        }
330        return zipFile;
331    }
332
333    private FileResource buildTar(Resources<ClasspathElement> cpes) {
334        var tarFile = TarFile.of(ApplicationTarFileType,
335            destination().resolve(distributionBaseName() + ".tar"));
336        if (cpes.isNewerThan(tarFile)) {
337            logger.atInfo().log("%s building %s", this, tarFile);
338            new TarDistributionBuilder().build(tarFile, config, cpes);
339        } else {
340            logger.atFine().log("%s found %s to be up to date", this, tarFile);
341        }
342        return tarFile;
343    }
344}