001/*
002 * SPDX-License-Identifier: Apache-2.0
003 *
004 * Copyright 2023-2026 The Enola <https://enola.dev> Authors
005 *
006 * Licensed under the Apache License, Version 2.0 (the "License");
007 * you may not use this file except in compliance with the License.
008 * You may obtain a copy of the License at
009 *
010 *     https://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing, software
013 * distributed under the License is distributed on an "AS IS" BASIS,
014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 * See the License for the specific language governing permissions and
016 * limitations under the License.
017 */
018package dev.enola.common.io.resource;
019
020import com.google.common.io.ByteSink;
021import com.google.common.io.ByteSource;
022import com.google.common.io.MoreFiles;
023import com.google.common.net.MediaType;
024
025import dev.enola.common.io.MoreFileSystems;
026import dev.enola.common.io.iri.URIs;
027
028import org.jspecify.annotations.Nullable;
029
030import java.io.File;
031import java.io.IOException;
032import java.net.URI;
033import java.nio.file.*;
034import java.util.Arrays;
035
036/**
037 * {@link Resource} for a file (not a directory) at a {@link Path} on a {@link FileSystem}.
038 *
039 * <p>Note that a {@link Path} object instance is associated to a FileSystem, see {@link
040 * Path#getFileSystem()}. But in its text-form ({@link Path#of(String, String...)} and {@link
041 * Path#toString()}) this association is lost, and it should thus be avoided. In its URI-form
042 * ({@link Path#of(URI)} and {@link Path#toUri()}) this is preserved; typically via the "scheme"
043 * (and possibly the "authority") component/s of an URI. So a path in text does not identify a
044 * filesystem, whereas an URI can. While the "file:" scheme is obviously the most well-known and
045 * common one, the JVM actually can and does support others, notably the "jar:file:" one. To avoid
046 * related confusions, this class intentionally only offers constructors which take {@link URI}
047 * instead of {@link Path} (or exposing {@link File}) arguments.
048 */
049public class FileResource extends BaseResource implements Resource {
050    // TODO Rename FileResource to FileSystemPathResource!
051
052    public static class Provider implements ResourceProvider {
053
054        private final @Nullable File baseFile;
055
056        public Provider(File baseFile) {
057            this.baseFile = baseFile;
058        }
059
060        public Provider() {
061            this.baseFile = null;
062        }
063
064        @Override
065        public Resource getResource(URI uri) {
066            // URIs like "jar:file:/tmp/libmodels.jar!/enola.dev/properties.ttl" are not supported
067            // here. The UrlResource accepts jar: scheme though - and can read (but not write) them.
068            if ("jar".equals(uri.getScheme())) return null;
069
070            if (MoreFileSystems.URI_SCHEMAS.contains(uri.getScheme())
071                    && isValid(URIs.getFilePath(uri))) return new FileResource(uri);
072
073            // NB: There's very similar logic in ClasspathResource
074            if (uri.getScheme() == null && baseFile != null) {
075                if (uri.toString().contains(".."))
076                    throw new IllegalArgumentException(uri.toString());
077                return new ReadableButNotWritableDelegatingResource(
078                        new FileResource(new File(baseFile, uri.toString()).toURI()));
079            }
080
081            return null;
082        }
083    }
084
085    private static final OpenOption[] EMPTY_OPTIONS = new OpenOption[0];
086
087    private final Path path;
088    private final OpenOption[] openOptions;
089
090    public FileResource(URI uri, MediaType mediaType, OpenOption... openOptions) {
091        super(uri, mediaType);
092        this.path = checkValid(URIs.getFilePath(uri));
093        this.openOptions = safe(openOptions);
094    }
095
096    public FileResource(URI uri, OpenOption... openOptions) {
097        super(uri, MoreFiles.asByteSource(URIs.getFilePath(uri), openOptions));
098        this.path = checkValid(URIs.getFilePath(uri));
099        this.openOptions = safe(openOptions);
100    }
101
102    private static Path checkValid(Path path) {
103        if (isValid(path)) return path;
104        throw new IllegalArgumentException(
105                "Exists, but is a directory; thus cannot be a Resource: " + path);
106    }
107
108    private static boolean isValid(Path path) {
109        if (Files.notExists(path)) return true;
110        return !Files.isDirectory(path);
111    }
112
113    private static OpenOption[] safe(OpenOption[] openOptions) {
114        if (openOptions.length == 0) return EMPTY_OPTIONS; // skipcq: JAVA-S1049
115        else return Arrays.copyOf(openOptions, openOptions.length);
116    }
117
118    @Override
119    public ByteSink byteSink() {
120        var parentDirectoryPath = path.getParent();
121        if (parentDirectoryPath != null) {
122            try {
123                if (!Files.exists(parentDirectoryPath))
124                    Files.createDirectories(parentDirectoryPath);
125            } catch (IOException e) {
126                return new ErrorByteSink(e);
127            }
128        }
129        return MoreFiles.asByteSink(path, openOptions);
130    }
131
132    @Override
133    public ByteSource byteSource() {
134        return MoreFiles.asByteSource(path, openOptions);
135    }
136}