001/*
002 * JDrupes Builder
003 * Copyright (C) 2025 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 io.github.azagniotov.matcher.AntPathMatcher;
023import java.io.IOException;
024import java.nio.file.AccessDeniedException;
025import java.nio.file.FileVisitResult;
026import java.nio.file.Files;
027import java.nio.file.NoSuchFileException;
028import java.nio.file.Path;
029import java.nio.file.SimpleFileVisitor;
030import java.nio.file.attribute.BasicFileAttributes;
031import java.time.Instant;
032import java.util.ArrayList;
033import java.util.Arrays;
034import java.util.List;
035import java.util.Objects;
036import java.util.Optional;
037import java.util.stream.Stream;
038import org.jdrupes.builder.api.BuildException;
039import org.jdrupes.builder.api.FileResource;
040import org.jdrupes.builder.api.FileTree;
041import org.jdrupes.builder.api.Project;
042import org.jdrupes.builder.api.ResourceFactory;
043import org.jdrupes.builder.api.ResourceType;
044
045/// The default implementation of a [FileTree].
046///
047/// @param <T> the type of the [FileResource]s in the tree.
048///
049public class DefaultFileTree<T extends FileResource> extends DefaultResources<T>
050        implements FileTree<T> {
051    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
052    @SuppressWarnings("PMD.FieldNamingConventions")
053    private static final AntPathMatcher pathMatcher
054        = new AntPathMatcher.Builder().build();
055    private Instant latestChange;
056    private final Project project;
057    private final Path root;
058    private final String[] patterns;
059    private final List<String> excludes = new ArrayList<>();
060    private boolean withDirs;
061    private boolean filled;
062
063    /// Returns a new file tree. The file tree includes all files
064    /// matching `pattern` in the tree starting at `root`. `root`
065    /// may be specified as absolute path or as path relative to the
066    /// `project`'s directory (see [Project#directory]).
067    /// 
068    /// if `project` is `null`, and `root` is a relative path,
069    /// `root` is resolved against the current working directory.
070    ///
071    /// @param type the resource type
072    /// @param project the project
073    /// @param root the root
074    /// @param patterns the include patterns
075    ///
076    @SuppressWarnings({ "PMD.ArrayIsStoredDirectly", "PMD.UseVarargs" })
077    protected DefaultFileTree(ResourceType<?> type, Project project, Path root,
078            String[] patterns) {
079        super(type);
080        this.project = project;
081        this.root = root;
082        if (patterns.length == 0) {
083            this.patterns = new String[] { "**/*" };
084        } else {
085            this.patterns = patterns;
086        }
087    }
088
089    @Override
090    public FileTree<T> withDirectories() {
091        withDirs = true;
092        return this;
093    }
094
095    @Override
096    public FileTree<T> exclude(String pattern) {
097        excludes.add(pattern);
098        return this;
099    }
100
101    @Override
102    public Path root(boolean relativize) {
103        if (project == null) {
104            return root.toAbsolutePath();
105        }
106        Path result = project.directory().resolve(root).normalize();
107        if (relativize) {
108            return project.directory().relativize(result);
109        }
110        return result;
111    }
112
113    @Override
114    public Path root() {
115        return root(false);
116    }
117
118    private void fill() {
119        if (filled) {
120            return;
121        }
122        try {
123            find(root(), patterns);
124        } catch (IOException e) {
125            logger.atSevere().withCause(e).log("Problem scanning files");
126            throw new BuildException().from(project).cause(e);
127        }
128        filled = true;
129    }
130
131    @Override
132    public Optional<Instant> asOf() {
133        fill();
134        return Optional.ofNullable(latestChange);
135    }
136
137    @SuppressWarnings({ "PMD.CognitiveComplexity", "PMD.UseVarargs" })
138    private void find(Path root, String[] patterns) throws IOException {
139        if (!root.toFile().exists()) {
140            return;
141        }
142        Files.walkFileTree(root, new SimpleFileVisitor<>() {
143
144            @Override
145            public FileVisitResult visitFile(Path path,
146                    BasicFileAttributes attrs) throws IOException {
147                return testAndAdd(path);
148            }
149
150            private FileVisitResult testAndAdd(Path path) {
151                Path pathInTree = root.relativize(path);
152                if (excludes.stream().anyMatch(ex -> pathMatcher
153                    .isMatch(ex, pathInTree.toString()))) {
154                    if (path.toFile().isDirectory()) {
155                        return FileVisitResult.SKIP_SUBTREE;
156                    }
157                    return FileVisitResult.CONTINUE;
158                }
159                if (Arrays.stream(patterns).anyMatch(pattern -> pathMatcher
160                    .isMatch(pattern, pathInTree.toString()))) {
161                    @SuppressWarnings("unchecked")
162                    T resource = (T) ResourceFactory
163                        .create(type().containedType(), path);
164                    DefaultFileTree.this.add(resource);
165                    if (resource.asOf().isPresent() && (latestChange == null
166                        || resource.asOf().get().isAfter(latestChange))) {
167                        latestChange = resource.asOf().get();
168                    }
169                }
170                return FileVisitResult.CONTINUE;
171            }
172
173            @Override
174            public FileVisitResult preVisitDirectory(Path dir,
175                    BasicFileAttributes attrs) throws IOException {
176                if (withDirs) {
177                    return testAndAdd(dir);
178                }
179                return FileVisitResult.CONTINUE;
180            }
181
182            @Override
183            public FileVisitResult postVisitDirectory(Path dir, IOException exc)
184                    throws IOException {
185                if (!withDirs) {
186                    return FileVisitResult.CONTINUE;
187                }
188
189                // Directories (and their modification date) included
190                var dirMod = Instant.ofEpochMilli(dir.toFile().lastModified());
191                if (latestChange == null || dirMod.isAfter(latestChange)) {
192                    latestChange = dirMod;
193                }
194                return FileVisitResult.CONTINUE;
195            }
196
197            @Override
198            public FileVisitResult visitFileFailed(Path file, IOException exc)
199                    throws IOException {
200                if (exc instanceof AccessDeniedException) {
201                    return FileVisitResult.SKIP_SUBTREE;
202                }
203                return FileVisitResult.CONTINUE;
204            }
205        });
206    }
207
208    @Override
209    public Stream<T> stream() {
210        return LazyCollectionStream.of(() -> {
211            fill();
212            return get();
213        });
214    }
215
216    @Override
217    public FileTree<T> clear() {
218        super.clear();
219        filled = false;
220        return this;
221    }
222
223    @Override
224    public void cleanup() {
225        try {
226            deleteFiles(root());
227        } catch (IOException e) {
228            logger.atSevere().withCause(e).log("Problem scanning files");
229            throw new BuildException().from(project).cause(e);
230        }
231        filled = false;
232    }
233
234    @SuppressWarnings("PMD.CognitiveComplexity")
235    private void deleteFiles(Path root) throws IOException {
236        if (!root.toFile().exists()) {
237            return;
238        }
239        Files.walkFileTree(root, new SimpleFileVisitor<>() {
240
241            @Override
242            public FileVisitResult visitFile(Path path,
243                    BasicFileAttributes attrs) throws IOException {
244                return testAndDelete(path);
245            }
246
247            private FileVisitResult testAndDelete(Path path)
248                    throws IOException {
249                Path pathInTree = root.relativize(path);
250                if (excludes.stream().anyMatch(ex -> pathMatcher
251                    .isMatch(ex, pathInTree.toString()))) {
252                    if (path.toFile().isDirectory()) {
253                        return FileVisitResult.SKIP_SUBTREE;
254                    }
255                    return FileVisitResult.CONTINUE;
256                }
257                if (Arrays.stream(patterns).anyMatch(pattern -> pathMatcher
258                    .isMatch(pattern, pathInTree.toString()))) {
259                    try {
260                        Files.delete(path);
261                    } catch (NoSuchFileException e) { // NOPMD
262                        // We can have concurrent cleanups
263                    }
264                }
265                return FileVisitResult.CONTINUE;
266            }
267
268            @Override
269            public FileVisitResult postVisitDirectory(Path dir,
270                    IOException exc) throws IOException {
271                if (exc != null) {
272                    return FileVisitResult.CONTINUE;
273                }
274                if (dir.toFile().exists()) {
275                    try {
276                        if (Files.list(dir).findFirst().isEmpty()) {
277                            Files.delete(dir);
278                        }
279                    } catch (NoSuchFileException e) { // NOPMD
280                        // We can have concurrent cleanups
281                    }
282                }
283                return FileVisitResult.CONTINUE;
284            }
285
286            @Override
287            public FileVisitResult visitFileFailed(Path file,
288                    IOException exc) throws IOException {
289                if (exc instanceof AccessDeniedException) {
290                    return FileVisitResult.SKIP_SUBTREE;
291                }
292                return FileVisitResult.CONTINUE;
293            }
294        });
295    }
296
297    @Override
298    public Stream<Path> paths() {
299        return stream().map(fr -> root().relativize(fr.path()));
300    }
301
302    @SuppressWarnings("unchecked")
303    @Override
304    public Stream<Entry<T>> entries() {
305        return paths().map(path -> new Entry<T>(path,
306            ResourceFactory.create((ResourceType<T>) type().containedType(),
307                root().resolve(path))));
308    }
309
310    @Override
311    public int hashCode() {
312        final int prime = 31;
313        int result = super.hashCode();
314        result
315            = prime * result + Objects.hash(excludes, patterns, root, withDirs);
316        return result;
317    }
318
319    @Override
320    public boolean equals(Object obj) {
321        if (this == obj) {
322            return true;
323        }
324        if (!super.equals(obj)) {
325            return false;
326        }
327        return (obj instanceof DefaultFileTree other)
328            && Objects.equals(excludes, other.excludes)
329            && Objects.equals(patterns, other.patterns)
330            && Objects.equals(root, other.root) && withDirs == other.withDirs;
331    }
332
333    @Override
334    public String toString() {
335        var wasFilled = filled;
336        fill();
337        String str = type().toString() + " (" + asOfLocalized()
338            + ") from " + Path.of("").toAbsolutePath().relativize(root())
339            + " with " + stream().count() + " elements";
340        if (!wasFilled) {
341            clear();
342        }
343        filled = wasFilled;
344        return str;
345    }
346}