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