001/*
002 * JDrupes Builder
003 * Copyright (C) 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 java.io.BufferedReader;
023import java.io.IOException;
024import java.io.InputStream;
025import java.io.InputStreamReader;
026import java.io.OutputStream;
027import java.io.PrintStream;
028import java.nio.charset.Charset;
029import java.nio.charset.StandardCharsets;
030import java.nio.file.Files;
031import java.nio.file.Path;
032import java.nio.file.StandardCopyOption;
033import java.util.Collection;
034import java.util.Collections;
035import java.util.List;
036import java.util.Objects;
037import java.util.function.BiConsumer;
038import java.util.function.Function;
039import java.util.stream.Stream;
040import org.jdrupes.builder.api.BuildException;
041import org.jdrupes.builder.api.Cleanliness;
042import org.jdrupes.builder.api.FileTree;
043import org.jdrupes.builder.api.InputTree;
044import org.jdrupes.builder.api.Project;
045import org.jdrupes.builder.api.Resource;
046import org.jdrupes.builder.api.ResourceFactory;
047import org.jdrupes.builder.api.ResourceRequest;
048import static org.jdrupes.builder.api.ResourceType.CleanlinessType;
049import org.jdrupes.builder.core.AbstractGenerator;
050import org.jdrupes.builder.core.StreamCollector;
051
052/// A provider that generates a [FileTree] from existing file trees.
053/// In general, copying file trees should be avoided. However, in some
054/// situations a resource provider and a consumer cannot be configured
055/// so that the output of the former can be used directly by the latter.
056/// 
057/// The provider generates a [FileTree] in the directory specified 
058/// with [#into] by copying files from the sources defined with one
059/// of the `source`-methods. The class is not named `Copier`
060/// because the specification of [Source]s supports transformations
061/// beyond simply copying.   
062/// 
063/// The provider generates the [FileTree] in response to a request that
064/// matches the one set with [#requestForResult]. The content of the
065/// generated file tree is returned using the type specified in the
066/// request.
067/// 
068/// A request for [Cleanliness] deletes the directory specified with
069/// [#into].
070///
071public class ApplicationBuilder extends AbstractGenerator {
072    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
073    private final StreamCollector<Source> sources = new StreamCollector<>(true);
074    private Path destination;
075    private ResourceRequest<?> requestForResult;
076
077    /// Describes a source that contributes files to the generated
078    /// tree.
079    ///
080    public static final class Source {
081        private final InputTree<?> tree;
082        private Function<Path, Path> rename;
083        private BiConsumer<InputStream, OutputStream> filter;
084        private BiConsumer<BufferedReader, PrintStream> textFilter;
085        private Charset charset;
086
087        private Source(InputTree<?> tree) {
088            this.tree = tree;
089        }
090
091        /// Creates a new source specification.
092        /// 
093        /// @param tree the source file tree
094        /// @return the source
095        ///
096        @SuppressWarnings("PMD.ShortMethodName")
097        public static Source of(InputTree<?> tree) {
098            return new Source(tree);
099        }
100
101        /// Specifies a function for renaming source files. The method
102        /// receives the file's path relative to the source tree's root.
103        /// It must return the file's path relative to the destination
104        /// set with [#into].
105        ///
106        /// @param renamer the function for renaming
107        /// @return the source
108        ///
109        public Source rename(Function<Path, Path> renamer) {
110            this.rename = renamer;
111            return this;
112        }
113
114        /// Specifies a function for copying the content of the source
115        /// file to the destination file. If set, this function
116        /// is invoked for each file instead of simply copying the file
117        /// content.
118        ///
119        /// @param filter the copy function
120        /// @return the source
121        ///
122        public Source filter(BiConsumer<InputStream, OutputStream> filter) {
123            this.filter = filter;
124            return this;
125        }
126
127        /// Specifies a function for copying the content of the source
128        /// text file to the destination text file. If set, this function
129        /// is invoked for each file instead of simply copying the file
130        /// content.
131        ///
132        /// @param filter the copy function
133        /// @param charset the charset
134        /// @return the source
135        ///
136        public Source filter(BiConsumer<BufferedReader, PrintStream> filter,
137                Charset charset) {
138            this.textFilter = filter;
139            this.charset = charset;
140            return this;
141        }
142
143        /// Invoke [String#replaceAll] on each line of the file.
144        ///
145        /// @param regex the regex
146        /// @param replacement the replacement
147        /// @return the source
148        ///
149        public Source replaceAll(String regex, String replacement) {
150            return filter((in, out) -> in.lines().map(
151                l -> l.replaceAll(regex, replacement))
152                .forEach(out::println), StandardCharsets.UTF_8);
153        }
154    }
155
156    /// Initializes a new file tree builder.
157    ///
158    /// @param project the project
159    ///
160    public ApplicationBuilder(Project project) {
161        super(project);
162    }
163
164    /// Adds the given [Stream] of [Source] specifications.
165    ///
166    /// @param sources the sources
167    /// @return the file tree builder
168    ///
169    public ApplicationBuilder add(Stream<Source> sources) {
170        this.sources.add(sources);
171        return this;
172    }
173
174    /// Adds the given [Source] specification.
175    ///
176    /// @param source the source
177    /// @return the file tree builder
178    ///
179    public ApplicationBuilder add(Source source) {
180        this.sources.add(source);
181        return this;
182    }
183
184    /// Convenience method for adding a [Source] without renaming or
185    /// filter to the sources. If `root` is a relative path, it is resolved
186    /// against the project's directory.
187    ///
188    /// @param root the root
189    /// @param pattern the pattern
190    /// @return the file tree builder
191    ///
192    public ApplicationBuilder source(Path root, String pattern) {
193        sources.add(Stream.of(Source.of(FileTree.of(
194            project(), root, pattern))));
195        return this;
196    }
197
198    /// Convenience method for adding a [Source] with optional renaming and
199    /// filtering to the sources. If `root` is a relative path, it is
200    /// resolved against the project's directory.
201    ///
202    /// @param root the root
203    /// @param pattern the pattern
204    /// @param renamer the renamer (may be `null`)
205    /// @param filter the filter (may be `null`)
206    /// @return the file tree builder
207    ///
208    public ApplicationBuilder source(Path root, String pattern,
209            Function<Path, Path> renamer,
210            BiConsumer<InputStream, OutputStream> filter) {
211        var source = Source.of(FileTree.of(project(), root, pattern));
212        if (renamer != null) {
213            source.rename(renamer);
214        }
215        if (filter != null) {
216            source.filter(filter);
217        }
218        sources.add(Stream.of(source));
219        return this;
220    }
221
222    /// Sets the destination directory for the generated file tree. If the
223    /// destination is relative, it is resolved against the project's
224    /// directory.
225    ///
226    /// @param destination the destination
227    /// @return the file tree builder
228    ///
229    public ApplicationBuilder into(Path destination) {
230        if (!destination.isAbsolute()) {
231            destination = project().directory().resolve(destination);
232        }
233        if (destination.toFile().exists()
234            && !destination.toFile().isDirectory()) {
235            throw new IllegalArgumentException(
236                "Destination path \"" + destination
237                    + "\" exists but is not a directory.");
238        }
239        this.destination = destination.normalize();
240        return this;
241    }
242
243    /// Configures the request that this builder responds to by
244    /// providing the generated file tree.
245    ///
246    /// @param proto a prototype request describing the requests that
247    /// the provider should respond to
248    /// @return the file tree builder
249    ///
250    public ApplicationBuilder provideResources(
251            ResourceRequest<? extends FileTree<?>> proto) {
252        requestForResult = proto;
253        return this;
254    }
255
256    @Override
257    protected <T extends Resource> Collection<T>
258            doProvide(ResourceRequest<T> request) {
259        if (request.accepts(CleanlinessType)) {
260            FileTree.of(project(), destination, "**/*").cleanup();
261            return Collections.emptyList();
262        }
263
264        // Check if request matches
265        if (requestForResult == null
266            || !request.accepts(requestForResult.type())
267            || (!requestForResult.name().isEmpty()
268                && !Objects.equals(requestForResult.name().get(),
269                    request.name().orElse(null)))) {
270            return Collections.emptyList();
271        }
272
273        // Always evaluate for most special type
274        if (!request.equals(requestForResult)) {
275            @SuppressWarnings({ "unchecked" })
276            var result = (Collection<T>) resources(requestForResult).toList();
277            return result;
278        }
279
280        if (destination == null) {
281            throw new IllegalStateException("No destination set.");
282        }
283
284        // Retrieve the sources
285        var required = sources.stream().toList();
286        if (!createInDestination(required)) {
287            logger.atFine().log("Output from %s is up to date", this);
288        }
289
290        var result = ResourceFactory.create(request.type(), project(),
291            destination, new String[] { "**/*" });
292        return List.of(result);
293    }
294
295    private boolean createInDestination(List<Source> required) {
296        // Handle sources in parallel, but each source in sequentially.
297        return required.parallelStream().map(source -> {
298            var srcTree = source.tree;
299            return srcTree.entries().map(entry -> {
300                try {
301                    return createTarget(source, entry);
302                } catch (IOException e) {
303                    throw new BuildException().from(this).cause(e);
304                }
305            }).reduce(false, (a, b) -> a || b);
306        }).reduce(false, (a, b) -> a || b);
307    }
308
309    private boolean createTarget(Source source, InputTree.Entry<?> entry)
310            throws IOException {
311        var dest = destination.resolve(entry.path());
312        var rename = source.rename;
313        if (rename != null) {
314            dest = destination.resolve(rename.apply(entry.path()));
315            if (!dest.normalize().startsWith(destination)) {
316                throw new BuildException().from(this).message(
317                    "Rename function returns \"%s\" which is outside the"
318                        + " target directory \"%s\"",
319                    dest, destination);
320            }
321        }
322        Files.createDirectories(dest.getParent());
323        if (dest.toFile().exists() && dest.toFile()
324            .lastModified() >= entry.resource().asOf().get().toEpochMilli()) {
325            return false;
326        }
327        if (source.filter != null) {
328            try (var srcStream = entry.resource().inputStream();
329                    var destStream = Files.newOutputStream(dest)) {
330                source.filter.accept(srcStream, destStream);
331            }
332            return true;
333        }
334        if (source.textFilter != null) {
335            try (var reader = new BufferedReader(new InputStreamReader(
336                entry.resource().inputStream(), source.charset));
337                    var out
338                        = new PrintStream(dest.toFile(), source.charset)) {
339                source.textFilter.accept(reader, out);
340            }
341            return true;
342        }
343        Files.copy(entry.resource().inputStream(), dest,
344            StandardCopyOption.REPLACE_EXISTING);
345        return true;
346    }
347
348}