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