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