001/*
002 * JDrupes Builder
003 * Copyright (C) 2025, 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 io.vavr.control.Option;
023import io.vavr.control.Try;
024import java.io.IOException;
025import java.nio.file.Files;
026import java.nio.file.Path;
027import static java.nio.file.StandardOpenOption.*;
028import java.util.Arrays;
029import java.util.Comparator;
030import java.util.Map;
031import java.util.Map.Entry;
032import java.util.concurrent.ConcurrentHashMap;
033import java.util.function.Supplier;
034import java.util.jar.Attributes;
035import java.util.jar.Attributes.Name;
036import java.util.jar.JarEntry;
037import java.util.jar.JarOutputStream;
038import java.util.jar.Manifest;
039import java.util.stream.Collectors;
040import java.util.stream.Stream;
041import java.util.stream.StreamSupport;
042import org.jdrupes.builder.api.BuildException;
043import org.jdrupes.builder.api.ConfigurationException;
044import org.jdrupes.builder.api.FileResource;
045import org.jdrupes.builder.api.FileTree;
046import org.jdrupes.builder.api.IOResource;
047import org.jdrupes.builder.api.Project;
048import static org.jdrupes.builder.api.Project.Properties.*;
049import org.jdrupes.builder.api.Resource;
050import org.jdrupes.builder.api.ResourceProviderSpi;
051import org.jdrupes.builder.api.ResourceRequest;
052import org.jdrupes.builder.api.ResourceType;
053import static org.jdrupes.builder.api.ResourceType.*;
054import org.jdrupes.builder.api.Resources;
055import org.jdrupes.builder.core.AbstractGenerator;
056import org.jdrupes.builder.core.StreamCollector;
057
058/// A general purpose generator for jars. All contents must be added
059/// explicitly using one of the `add*` methods.
060///
061@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.TooManyMethods" })
062public class JarBuilder extends AbstractGenerator {
063
064    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
065    private final ResourceType<? extends JarFile> jarType;
066    private Supplier<Path> destination
067        = () -> project().buildDirectory().resolve("libs");
068    private Supplier<String> jarName
069        = () -> project().name() + "-" + project().get(Version) + ".jar";
070    private final StreamCollector<Entry<Name, String>> attributes
071        = StreamCollector.cached();
072    private final StreamCollector<
073            Map.Entry<Path, ? extends IOResource>> entryStreams
074                = StreamCollector.cached();
075    private final StreamCollector<FileTree<?>> fileTrees
076        = StreamCollector.cached();
077
078    /// Initializes a new library generator.
079    ///
080    /// @param project the project
081    /// @param jarType the type of jar that the generator generates
082    ///
083    public JarBuilder(Project project,
084            ResourceType<? extends JarFile> jarType) {
085        super(project);
086        this.jarType = jarType;
087    }
088
089    @Override
090    public JarBuilder name(String name) {
091        rename(name);
092        return this;
093    }
094
095    /// Returns the destination directory. Defaults to sub directory
096    /// `libs` in the project's build directory
097    /// (see [Project#buildDirectory]).
098    ///
099    /// @return the destination
100    ///
101    public Path destination() {
102        return destination.get();
103    }
104
105    /// Sets the destination directory. The [Path] is resolved against
106    /// the project's build directory (see [Project#buildDirectory]).
107    ///
108    /// @param destination the new destination
109    /// @return the jar generator
110    ///
111    public JarBuilder destination(Path destination) {
112        this.destination
113            = () -> project().buildDirectory().resolve(destination);
114        return this;
115    }
116
117    /// Sets the destination directory.
118    ///
119    /// @param destination the new destination
120    /// @return the jar generator
121    ///
122    public JarBuilder destination(Supplier<Path> destination) {
123        this.destination = destination;
124        return this;
125    }
126
127    /// Returns the name of the generated jar file. Defaults to
128    /// the project's name followed by its version and `.jar`.
129    ///
130    /// @return the string
131    ///
132    public String jarName() {
133        return jarName.get();
134    }
135
136    /// Sets the supplier for obtaining the name of the generated jar file
137    /// in [ResourceProviderSpi#provide].
138    ///
139    /// @param jarName the jar name
140    /// @return the jar generator
141    ///
142    public JarBuilder jarName(Supplier<String> jarName) {
143        this.jarName = jarName;
144        return this;
145    }
146
147    /// Sets the name of the generated jar file.
148    ///
149    /// @param jarName the jar name
150    /// @return the jar generator
151    ///
152    public JarBuilder jarName(String jarName) {
153        return jarName(() -> jarName);
154    }
155
156    /// Add the given attributes to the manifest.
157    ///
158    /// @param attributes the attributes
159    /// @return the library generator
160    ///
161    public JarBuilder addAttributeValues(
162            Stream<Map.Entry<Attributes.Name, String>> attributes) {
163        this.attributes.add(attributes);
164        return this;
165    }
166
167    /// Add the given attributes to the manifest.
168    ///
169    /// @param attributes the attributes
170    /// @return the library generator
171    ///
172    @SuppressWarnings("PMD.LooseCoupling")
173    public JarBuilder
174            addManifestAttributes(Stream<ManifestAttributes> attributes) {
175        addAttributeValues(
176            attributes.map(a -> a.entrySet().stream()).flatMap(s -> s)
177                .map(e -> Map.entry((Attributes.Name) e.getKey(),
178                    (String) e.getValue())));
179        return this;
180    }
181
182    /// Add the given attributes to the manifest.
183    ///
184    /// @param attributes the attributes
185    /// @return the library generator
186    ///
187    @SafeVarargs
188    public final JarBuilder
189            attributes(Map.Entry<Attributes.Name, String>... attributes) {
190        this.attributes.add(Arrays.stream(attributes));
191        return this;
192    }
193
194    /// Adds single resources to the jar. Each entry is added to the
195    /// jar as entry with the name passed in the key attribute of the
196    /// `Map.Entry` with the content from the [IOResource] in the
197    /// value attribute.
198    ///
199    /// @param entries the entries
200    /// @return the jar generator
201    ///
202    public JarBuilder addEntries(
203            Stream<? extends Map.Entry<Path, ? extends IOResource>> entries) {
204        entryStreams.add(entries);
205        return this;
206    }
207
208    /// Adds the given [FileTree]s. Each file in the tree will be added
209    /// as an entry using its relative path in the tree as name.  
210    ///
211    /// @param trees the trees
212    /// @return the jar generator
213    ///
214    public JarBuilder addTrees(Stream<? extends FileTree<?>> trees) {
215        fileTrees.add(trees);
216        return this;
217    }
218
219    /// Convenience method for adding entries, see [#addTrees(Stream)].
220    ///
221    /// @param trees the trees
222    /// @return the jar generator
223    ///
224    public JarBuilder add(FileTree<?>... trees) {
225        addTrees(Arrays.stream(trees));
226        return this;
227    }
228
229    /// Adds the file tree with the given prefix for each entry.
230    ///
231    /// @param prefix the prefix
232    /// @param tree the tree
233    /// @return the jar builder
234    ///
235    public JarBuilder add(Path prefix, FileTree<?> tree) {
236        entryStreams.add(tree.entries().map(e -> Map.entry(prefix.resolve(e),
237            FileResource.of(tree.root().resolve(e)))));
238        return this;
239    }
240
241    /// For each file tree, add its entries with the given prefix.
242    ///
243    /// @param prefix the prefix
244    /// @param trees the trees
245    /// @return the jar builder
246    ///
247    public JarBuilder add(Path prefix, Stream<? extends FileTree<?>> trees) {
248        entryStreams.add(
249            trees.flatMap(t -> t.entries().map(e -> Map.entry(prefix.resolve(e),
250                FileResource.of(t.root().resolve(e))))));
251        return this;
252    }
253
254    /// Convenience method for adding a single entry, see [#addEntries(Stream)].
255    ///
256    /// @param path the path
257    /// @param resource the resource
258    /// @return the jar generator
259    ///
260    public JarBuilder add(Path path, IOResource resource) {
261        addEntries(Map.of(path, resource).entrySet().stream());
262        return this;
263    }
264
265    /// Builds the jar.
266    ///
267    /// @param jarResource the jar resource
268    ///
269    @SuppressWarnings("PMD.ConfusingTernary")
270    protected void buildJar(JarFile jarResource) {
271        // Collect entries for jar from all sources
272        var contents = new ConcurrentHashMap<Path, Resources<IOResource>>();
273        collectContents(contents);
274        resolveDuplicates(contents);
275
276        // Check if rebuild needed (requires manifest check).
277        var oldManifest = Option.of(jarResource)
278            .filter(jar -> jar.path().toFile().canRead())
279            .flatMap(jr -> Try.withResources(
280                () -> new java.util.jar.JarFile(jr.path().toFile()))
281                .of(jar -> Try.of(jar::getManifest).toOption()
282                    .flatMap(Option::of))
283                .toOption().flatMap(m -> m))
284            .getOrElse(Manifest::new);
285        Manifest manifest = createManifest();
286        if (!manifest.equals(oldManifest)) {
287            logger.atFine().log("Rebuilding %s, manifest changed", jarName());
288        } else {
289            // manifest unchanged, check timestamps
290            var newer = contents.values().stream()
291                .map(r -> r.stream().findFirst().stream()).flatMap(s -> s)
292                .filter(r -> r.isNewerThan(jarResource)).findAny();
293            if (newer.isEmpty()) {
294                logger.atFine().log("Existing %s is up to date.", jarName());
295                return;
296            }
297            logger.atFine().log(
298                "Rebuilding %s, is older than %s", jarName(), newer.get());
299        }
300        writeJar(jarResource, contents, manifest);
301    }
302
303    private Manifest createManifest() {
304        Manifest manifest = new Manifest();
305        @SuppressWarnings("PMD.LooseCoupling")
306        Attributes attributes = manifest.getMainAttributes();
307        attributes.put(Attributes.Name.MANIFEST_VERSION, "1.0");
308        this.attributes.stream().sorted(Map.Entry.comparingByKey(
309            Comparator.comparing(Attributes.Name::toString)))
310            .forEach(e -> attributes.put(e.getKey(), e.getValue()));
311        return manifest;
312    }
313
314    private void writeJar(JarFile jarResource,
315            Map<Path, Resources<IOResource>> contents, Manifest manifest) {
316        // Write jar file
317        logger.atInfo().log("Building %s in %s", jarName(), project().name());
318        try {
319            // Allow continued use of existing jar if open (POSIX only)
320            Files.deleteIfExists(jarResource.path());
321        } catch (IOException e) { // NOPMD
322        }
323        try (JarOutputStream jos = new JarOutputStream(Files.newOutputStream(
324            jarResource.path(), CREATE, TRUNCATE_EXISTING), manifest)) {
325            for (var entry : contents.entrySet()) {
326                if (entry.getValue().isEmpty()) {
327                    continue;
328                }
329                var entryName
330                    = StreamSupport.stream(entry.getKey().spliterator(), false)
331                        .map(Path::toString).collect(Collectors.joining("/"));
332                @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
333                JarEntry jarEntry = new JarEntry(entryName);
334                var ioResource = entry.getValue().stream().findFirst();
335                if (ioResource.isEmpty()) {
336                    logger.atWarning()
337                        .log(jarEntry + " has no associated data");
338                    continue;
339                }
340                jarEntry.setTime(ioResource.get().asOf().get().toEpochMilli());
341                jos.putNextEntry(jarEntry);
342                try (var input = entry.getValue().stream().findFirst().get()
343                    .inputStream()) {
344                    input.transferTo(jos);
345                }
346            }
347
348        } catch (IOException e) {
349            throw new BuildException().from(this).cause(e);
350        }
351    }
352
353    /// Add the contents from the added streams as preliminary jar
354    /// entries. Must be overridden by derived classes that define
355    /// additional ways to provide contents. The overriding method
356    /// must invoke `super.collectEntries(...)`.
357    ///
358    /// @param contents the preliminary contents
359    ///
360    protected void collectContents(Map<Path, Resources<IOResource>> contents) {
361        entryStreams.stream().forEach(entry -> {
362            contents.computeIfAbsent(entry.getKey(),
363                _ -> Resources.with(IOResource.class))
364                .add(entry.getValue());
365        });
366        fileTrees.stream().parallel()
367            .forEach(t -> collect(contents, t));
368    }
369
370    /// Adds the resources from the given file tree to the given contents.
371    /// May be used by derived classes while collecting contents for
372    /// the jar.
373    ///
374    /// @param collected the preliminary contents
375    /// @param fileTree the file tree
376    ///
377    protected void collect(Map<Path, Resources<IOResource>> collected,
378            FileTree<?> fileTree) {
379        var root = fileTree.root();
380        fileTree.stream().forEach(file -> {
381            var relPath = root.relativize(file.path());
382            collected.computeIfAbsent(relPath,
383                _ -> Resources.with(IOResource.class)).add(file);
384        });
385    }
386
387    /// Resolve duplicates. The default implementation outputs a warning
388    /// and skips the duplicate entry. 
389    ///
390    /// @param entries the entries
391    ///
392    @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition",
393        "PMD.UselessPureMethodCall" })
394    protected void resolveDuplicates(
395            Map<Path, Resources<IOResource>> entries) {
396        entries.entrySet().parallelStream().forEach(item -> {
397            var resources = item.getValue();
398            if (resources.stream().count() == 1) {
399                return;
400            }
401            var entryName = item.getKey();
402            resources.stream().reduce((a, b) -> {
403                logger.atWarning().log(
404                    "Entry %s from %s duplicates entry from %s and is skipped.",
405                    entryName, a, b);
406                return a;
407            });
408        });
409    }
410
411    @Override
412    @SuppressWarnings({ "PMD.CollapsibleIfStatements", "unchecked" })
413    protected <T extends Resource> Stream<T>
414            doProvide(ResourceRequest<T> requested) {
415        if (!requested.accepts(jarType)
416            && !requested.accepts(CleanlinessType)) {
417            return Stream.empty();
418        }
419
420        // Prepare jar file
421        var destDir = destination();
422        if (!destDir.toFile().exists()) {
423            if (!destDir.toFile().mkdirs()) {
424                throw new ConfigurationException().from(this)
425                    .message("Cannot create directory: %s", destDir);
426            }
427        }
428        var jarResource = JarFile.of(jarType, destDir.resolve(jarName()));
429
430        // Maybe only delete
431        if (requested.accepts(CleanlinessType)) {
432            jarResource.cleanup();
433            return Stream.empty();
434        }
435
436        // Upgrade to most specific type to avoid duplicate generation
437        if (!requested.type().equals(jarType)) {
438            return (Stream<T>) context().resources(this, project().of(jarType));
439        }
440
441        buildJar(jarResource);
442        return Stream.of((T) jarResource);
443    }
444}