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