001/*
002 * Copyright 2023 Smartefact
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package smartefact.xdg.basedir;
018
019import java.nio.file.Path;
020import java.util.List;
021import java.util.function.Supplier;
022import java.util.stream.Stream;
023import org.jetbrains.annotations.Contract;
024import org.jetbrains.annotations.NotNull;
025import org.jetbrains.annotations.Nullable;
026import smartefact.Collections;
027import static smartefact.Preconditions.checkNotNull;
028import smartefact.Strings;
029import smartefact.SystemProperties;
030import static smartefact.ToStringBuilder.toStringBuilder;
031
032/**
033 * Utilities for the <em>XDG Base Directory specification</em>.
034 * <p>
035 * <b>Specification:</b> XDG Base Directory 0.8
036 * </p>
037 *
038 * @author Laurent Pireyn
039 * @see <a href="https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html">XDG Base Directory Specification</a>
040 */
041public final class XdgBaseDir {
042    private static final String HOME = "HOME";
043    private static final String XDG_CACHE_HOME = "XDG_CACHE_HOME";
044    private static final String XDG_CONFIG_DIRS = "XDG_CONFIG_DIRS";
045    private static final String XDG_CONFIG_HOME = "XDG_CONFIG_HOME";
046    private static final String XDG_DATA_DIRS = "XDG_DATA_DIRS";
047    private static final String XDG_DATA_HOME = "XDG_DATA_HOME";
048    private static final String XDG_RUNTIME_DIR = "XDG_RUNTIME_DIR";
049    private static final String XDG_STATE_HOME = "XDG_STATE_HOME";
050    private static final char PATH_SEPARATOR = ':';
051
052    private static @Nullable XdgBaseDir instance;
053
054    public static @NotNull XdgBaseDir getInstance() {
055        synchronized (XdgBaseDir.class) {
056            if (instance == null) {
057                final @NotNull String fileSeparator = SystemProperties.getFileSeparator();
058                if (!fileSeparator.equals("/")) {
059                    throw new IllegalStateException(
060                        "The current platform does not support the XDG Base Directory specification, " +
061                            "as it uses <" + fileSeparator + "> as file separator"
062                    );
063                }
064                instance = new XdgBaseDir();
065            }
066            return instance;
067        }
068    }
069
070    @Contract(pure = true)
071    private static @Nullable String getEnvVar(@NotNull String name) {
072        final @Nullable String value;
073        try {
074            value = System.getenv(name);
075        } catch (SecurityException e) {
076            return null;
077        } catch (NullPointerException e) {
078            throw new AssertionError(e);
079        }
080        return !Strings.isNullOrBlank(value) ? value : null;
081    }
082
083    @Contract(pure = true)
084    private static @NotNull String getRequiredEnvVar(@NotNull String name) {
085        return checkNotNull(
086            getEnvVar(name),
087            () -> "The environment variable <" + name + "> is undefined"
088        );
089    }
090
091    @Contract(pure = true)
092    private static @Nullable Path getEnvVarAsPath(@NotNull String name) {
093        final @Nullable String value = getEnvVar(name);
094        return value != null ? Path.of(value) : null;
095    }
096
097    @Contract(pure = true)
098    private static @NotNull Path getEnvVarAsPath(
099        @NotNull String name,
100        @NotNull Supplier<? extends @NotNull Path> defaultValue
101    ) {
102        final @Nullable Path value = getEnvVarAsPath(name);
103        return value != null ? value : defaultValue.get();
104    }
105
106    @Contract(pure = true)
107    private static @NotNull List<@NotNull Path> getEnvVarAsPathList(
108        @NotNull String name,
109        @NotNull Supplier<? extends @NotNull List<@NotNull Path>> defaultValue
110    ) {
111        final @Nullable String value = getEnvVar(name);
112        return value != null ? toPathList(value) : defaultValue.get();
113    }
114
115    @Contract(pure = true)
116    private static @NotNull List<@NotNull Path> toPathList(@NotNull CharSequence string) {
117        return Collections.map(
118            Strings.split(string, PATH_SEPARATOR),
119            Path::of
120        );
121    }
122
123    @Contract(pure = true)
124    private static @NotNull Stream<@NotNull Path> getExistingFiles(
125        @NotNull Stream<? extends @NotNull Path> candidateDirs,
126        @NotNull String dirName,
127        @NotNull String fileName
128    ) {
129        final @NotNull Path filePath = Path.of(dirName, fileName);
130        return candidateDirs
131            .map(dir -> dir.resolve(filePath))
132            .filter(file -> file.toFile().exists());
133    }
134
135    private final @NotNull Path home = Path.of(getRequiredEnvVar(HOME));
136
137    private final @NotNull Path cacheHome = getEnvVarAsPath(
138        XDG_CACHE_HOME,
139        () -> home.resolve(".cache")
140    );
141
142    private final @NotNull Path configHome = getEnvVarAsPath(
143        XDG_CONFIG_HOME,
144        () -> home.resolve(".config")
145    );
146
147    private final @NotNull Path dataHome = getEnvVarAsPath(
148        XDG_DATA_HOME,
149        () -> home.resolve(".local/share")
150    );
151
152    private final @NotNull Path stateHome = getEnvVarAsPath(
153        XDG_STATE_HOME,
154        () -> home.resolve(".local/state")
155    );
156
157    private final @Nullable Path runtimeDir = getEnvVarAsPath(XDG_RUNTIME_DIR);
158
159    private final @NotNull Path binDir = home.resolve(".local/bin");
160
161    private final @NotNull List<@NotNull Path> configDirs = getEnvVarAsPathList(
162        XDG_CONFIG_DIRS,
163        () -> List.of(Path.of("/etc/xdg"))
164    );
165
166    private final @NotNull List<@NotNull Path> dataDirs = getEnvVarAsPathList(
167        XDG_DATA_DIRS,
168        () -> List.of(Path.of("/usr/local/share"), Path.of("/usr/share"))
169    );
170
171    private XdgBaseDir() {}
172
173    /**
174     * Returns the home directory.
175     * <p>
176     * This implementation is based on the {@code HOME} environment variable,
177     * not on the {@code user.home} system property
178     * (although they should have the same value).
179     * </p>
180     *
181     * @return the home directory (never {@code null})
182     */
183    @Contract(pure = true)
184    public @NotNull Path getHome() {
185        return home;
186    }
187
188    /**
189     * Returns the cached data directory.
190     *
191     * @return the cached data directory (never {@code null})
192     */
193    @Contract(pure = true)
194    public @NotNull Path getCacheHome() {
195        return cacheHome;
196    }
197
198    /**
199     * Returns the configuration directory.
200     *
201     * @return the configuration directory (never {@code null})
202     */
203    @Contract(pure = true)
204    public @NotNull Path getConfigHome() {
205        return configHome;
206    }
207
208    /**
209     * Returns the data directory.
210     *
211     * @return the data directory (never {@code null})
212     */
213    @Contract(pure = true)
214    public @NotNull Path getDataHome() {
215        return dataHome;
216    }
217
218    /**
219     * Returns the state data directory.
220     *
221     * @return the state data directory (never {@code null})
222     */
223    @Contract(pure = true)
224    public @NotNull Path getStateHome() {
225        return stateHome;
226    }
227
228    /**
229     * Returns the runtime directory.
230     * <p>
231     * This may be {@code null} as it has no default value.
232     * </p>
233     *
234     * @return the runtime directory (maybe {@code null})
235     */
236    @Contract(pure = true)
237    public @Nullable Path getRuntimeDir() {
238        return runtimeDir;
239    }
240
241    /**
242     * Returns the user binary directory.
243     *
244     * @return the user binary directory (never {@code null})
245     */
246    @Contract(pure = true)
247    public @NotNull Path getBinDir() {
248        return binDir;
249    }
250
251    /**
252     * Returns the configuration directories.
253     *
254     * @return the configuration directories (never {@code null})
255     */
256    @Contract(pure = true)
257    public @NotNull List<@NotNull Path> getConfigDirs() {
258        return configDirs;
259    }
260
261    /**
262     * Returns the data directories.
263     *
264     * @return the data directories (never {@code null})
265     */
266    @Contract(pure = true)
267    public @NotNull List<@NotNull Path> getDataDirs() {
268        return dataDirs;
269    }
270
271    @Contract(pure = true)
272    public @NotNull Stream<@NotNull Path> getCandidateConfigDirs() {
273        return Stream.concat(
274            Stream.of(configHome),
275            configDirs.stream()
276        );
277    }
278
279    @Contract(pure = true)
280    public @NotNull Stream<@NotNull Path> getCandidateDataDirs() {
281        return Stream.concat(
282            Stream.of(dataHome),
283            dataDirs.stream()
284        );
285    }
286
287    @Contract(pure = true)
288    public @NotNull Stream<@NotNull Path> getConfigFiles(
289        @NotNull String dirName,
290        @NotNull String fileName
291    ) {
292        return getExistingFiles(
293            getCandidateConfigDirs(),
294            dirName,
295            fileName
296        );
297    }
298
299    @Contract(pure = true)
300    public @NotNull Stream<@NotNull Path> getDataFiles(
301        @NotNull String dirName,
302        @NotNull String fileName
303    ) {
304        return getExistingFiles(
305            getCandidateDataDirs(),
306            dirName,
307            fileName
308        );
309    }
310
311    @Override
312    @Contract(pure = true)
313    public String toString() {
314        return toStringBuilder(this)
315            .property("home", home)
316            .property("cacheHome", cacheHome)
317            .property("configHome", configHome)
318            .property("dataHome", dataHome)
319            .property("stateHome", stateHome)
320            .property("runtimeDir", runtimeDir)
321            .property("binDir", binDir)
322            .property("configDirs", configDirs)
323            .property("dataDirs", dataDirs)
324            .build();
325    }
326}