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 to a request that
062/// matches the one set with [#requestForResult]. The content of the
063/// generated file tree is returned using the type specified in the
064/// 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 copying the content of the source
113        /// file to the destination 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 copying the content of the source
126        /// text file to the destination text 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        /// Invoke [String#replaceAll] on each line of the file.
142        ///
143        /// @param regex the regex
144        /// @param replacement the replacement
145        /// @return the source
146        ///
147        public Source replaceAll(String regex, String replacement) {
148            return filter((in, out) -> in.lines().map(
149                l -> l.replaceAll(regex, replacement))
150                .forEach(out::println), StandardCharsets.UTF_8);
151        }
152    }
153
154    /// Initializes a new file tree builder.
155    ///
156    /// @param project the project
157    ///
158    public FileTreeBuilder(Project project) {
159        super(project);
160    }
161
162    /// Adds the given [Stream] of [Source] specifications.
163    ///
164    /// @param sources the sources
165    /// @return the file tree builder
166    ///
167    public FileTreeBuilder add(Stream<Source> sources) {
168        this.sources.add(sources);
169        return this;
170    }
171
172    /// Adds the given [Source] specification.
173    ///
174    /// @param source the source
175    /// @return the file tree builder
176    ///
177    public FileTreeBuilder add(Source source) {
178        this.sources.add(source);
179        return this;
180    }
181
182    /// Convenience method for adding a [Source] without renaming or
183    /// filter to the sources. If `root` is a relative path, it is resolved
184    /// against the project's directory.
185    ///
186    /// @param root the root
187    /// @param pattern the pattern
188    /// @return the file tree builder
189    ///
190    public FileTreeBuilder source(Path root, String pattern) {
191        sources.add(Stream.of(Source.of(FileTree.of(
192            project(), root, pattern))));
193        return this;
194    }
195
196    /// Convenience method for adding a [Source] with optional renaming and
197    /// filtering to the sources. If `root` is a relative path, it is
198    /// resolved against the project's directory.
199    ///
200    /// @param root the root
201    /// @param pattern the pattern
202    /// @param renamer the renamer (may be `null`)
203    /// @param filter the filter (may be `null`)
204    /// @return the file tree builder
205    ///
206    public FileTreeBuilder source(Path root, String pattern,
207            Function<Path, Path> renamer,
208            BiConsumer<InputStream, OutputStream> filter) {
209        var source = Source.of(FileTree.of(project(), root, pattern));
210        if (renamer != null) {
211            source.rename(renamer);
212        }
213        if (filter != null) {
214            source.filter(filter);
215        }
216        sources.add(Stream.of(source));
217        return this;
218    }
219
220    /// Sets the destination directory for the generated file tree. If the
221    /// destination is relative, it is resolved against the project's
222    /// directory.
223    ///
224    /// @param destination the destination
225    /// @return the file tree builder
226    ///
227    public FileTreeBuilder into(Path destination) {
228        if (!destination.isAbsolute()) {
229            destination = project().directory().resolve(destination);
230        }
231        if (destination.toFile().exists()
232            && !destination.toFile().isDirectory()) {
233            throw new IllegalArgumentException(
234                "Destination path \"" + destination
235                    + "\" exists but is not a directory.");
236        }
237        this.destination = destination.normalize();
238        return this;
239    }
240
241    /// Configures the request that this builder responds to by
242    /// providing the generated file tree.
243    ///
244    /// @param proto a prototype request describing the requests that
245    /// the provider should respond to
246    /// @return the file tree builder
247    ///
248    public FileTreeBuilder provideResources(
249            ResourceRequest<? extends FileTree<?>> proto) {
250        requestForResult = proto;
251        return this;
252    }
253
254    @Override
255    protected <T extends Resource> Collection<T>
256            doProvide(ResourceRequest<T> request) {
257        if (request.accepts(CleanlinessType)) {
258            FileTree.of(project(), destination, "**/*").cleanup();
259            return Collections.emptyList();
260        }
261
262        // Check if request matches
263        if (requestForResult == null
264            || !request.accepts(requestForResult.type())
265            || (!requestForResult.name().isEmpty()
266                && !Objects.equals(requestForResult.name().get(),
267                    request.name().orElse(null)))) {
268            return Collections.emptyList();
269        }
270
271        // Always evaluate for most special type
272        if (!request.equals(requestForResult)) {
273            @SuppressWarnings({ "unchecked" })
274            var result = (Collection<T>) resources(requestForResult).toList();
275            return result;
276        }
277
278        if (destination == null) {
279            throw new IllegalStateException("No destination set.");
280        }
281
282        // Retrieve the sources
283        var required = sources.stream().toList();
284        if (!createInDestination(required)) {
285            logger.atFine().log("Output from %s is up to date", this);
286        }
287
288        var result = ResourceFactory.create(request.type(), project(),
289            destination, new String[] { "**/*" });
290        return List.of(result);
291    }
292
293    private boolean createInDestination(List<Source> required) {
294        // Handle sources in parallel, but each source in sequentially.
295        return required.parallelStream().map(source -> {
296            var srcTree = source.tree;
297            return srcTree.entries().map(entry -> {
298                try {
299                    return createTarget(source, entry);
300                } catch (IOException e) {
301                    throw new BuildException().from(this).cause(e);
302                }
303            }).reduce(false, (a, b) -> a || b);
304        }).reduce(false, (a, b) -> a || b);
305    }
306
307    private boolean createTarget(Source source, InputTree.Entry<?> entry)
308            throws IOException {
309        var dest = destination.resolve(entry.path());
310        var rename = source.rename;
311        if (rename != null) {
312            dest = destination.resolve(rename.apply(entry.path()));
313            if (!dest.normalize().startsWith(destination)) {
314                throw new BuildException().from(this).message(
315                    "Rename function returns \"%s\" which is outside the"
316                        + " target directory \"%s\"",
317                    dest, destination);
318            }
319        }
320        Files.createDirectories(dest.getParent());
321        if (dest.toFile().exists() && dest.toFile()
322            .lastModified() >= entry.resource().asOf().get().toEpochMilli()) {
323            return false;
324        }
325        if (source.filter != null) {
326            try (var srcStream = entry.resource().inputStream();
327                    var destStream = Files.newOutputStream(dest)) {
328                source.filter.accept(srcStream, destStream);
329            }
330            return true;
331        }
332        if (source.textFilter != null) {
333            try (var reader = new BufferedReader(new InputStreamReader(
334                entry.resource().inputStream(), source.charset));
335                    var out
336                        = new PrintStream(dest.toFile(), source.charset)) {
337                source.textFilter.accept(reader, out);
338            }
339            return true;
340        }
341        Files.copy(entry.resource().inputStream(), dest,
342            StandardCopyOption.REPLACE_EXISTING);
343        return true;
344    }
345
346}