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