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