diff --git a/.gitattributes b/.gitattributes index 0cb064a..ce47dad 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ *.zig text=auto eol=lf +*.zon text=auto eol=lf diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index be6c3d9..6d143db 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,27 +2,49 @@ name: CI on: push: - branches: [ master ] + branches: [master] pull_request: - branches: [ master ] + branches: [master] + workflow_dispatch: jobs: build: - strategy: + fail-fast: false matrix: - platform: [ubuntu-latest, macos-latest, windows-latest] - runs-on: ${{ matrix.platform }} - + zig-version: [master] + os: [ubuntu-latest, macos-latest, windows-latest] + include: + - zig-version: "0.12.1" + os: ubuntu-latest + - zig-version: "0.13.0" + os: ubuntu-latest + runs-on: ${{ matrix.os }} steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v2 + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Zig + uses: mlugg/setup-zig@v1 + with: + version: master + + - name: Run Tests + run: zig build test --summary all + + - name: Setup `wasmtime` + if: matrix.os == 'ubuntu-latest' + uses: bytecodealliance/actions/wasmtime/setup@v1 + with: + version: "21.0.1" # Wasmtime v22.0.0 has removed support for the "Wasmtime 13-and-prior CLI" + + - name: Print wasmtime version + if: matrix.os == 'ubuntu-latest' + run: "wasmtime --version" - - name: Setup Zig - uses: goto-bus-stop/setup-zig@v1.3.0 - with: - version: master - - - name: Run tests - run: | - zig test known-folders.zig + - name: Run Tests (wasm32-wasi) + if: matrix.os == 'ubuntu-latest' + run: zig build test -Dtarget=wasm32-wasi -fwasmtime --summary all + env: + WASMTIME_NEW_CLI: 0 + WASMTIME_BACKTRACE_DETAILS: 1 diff --git a/README.md b/README.md index e092d42..396b621 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ - Keep to folders that are available on all operating systems ## API + ```zig pub const KnownFolder = enum { home, @@ -36,13 +37,37 @@ pub const KnownFolderConfig = struct { }; /// Returns a directory handle, or, if the folder does not exist, `null`. -pub fn open(allocator: std.mem.Allocator, folder: KnownFolder, args: std.fs.Dir.OpenDirOptions) (std.fs.Dir.OpenError || Error)!?std.fs.Dir; +pub fn open(allocator: std.mem.Allocator, folder: KnownFolder, args: std.fs.Dir.OpenOptions) (std.fs.Dir.OpenError || Error)!?std.fs.Dir; /// Returns the path to the folder or, if the folder does not exist, `null`. pub fn getPath(allocator: std.mem.Allocator, folder: KnownFolder) Error!?[]const u8; ``` +## Installation + +Initialize a `zig build` project if you haven't already. + +```bash +zig init +``` + +Add the `known-folders` package to your `build.zig.zon`. + +```bash +zig fetch --save git+https://github.com/ziglibs/known-folders.git +``` + +You can then import `known-folders` in your `build.zig` with: + +```zig +const known_folders = b.dependency("known-folders", .{}).module("known-folders"); +const exe = b.addExecutable(...); +// This adds the known-folders module to the executable which can then be imported with `@import("known-folders")` +exe.root_module.addImport("known-folders", known_folders); +``` + ## Configuration + In your root file, add something like this to configure known-folders: ```zig diff --git a/build.zig b/build.zig index 8f963e1..ef45632 100644 --- a/build.zig +++ b/build.zig @@ -1,7 +1,44 @@ const std = @import("std"); +const builtin = @import("builtin"); + +const minimum_zig_version = std.SemanticVersion.parse("0.12.0") catch unreachable; pub fn build(b: *std.Build) void { + if (comptime (builtin.zig_version.order(minimum_zig_version) == .lt)) { + @compileError(std.fmt.comptimePrint( + \\Your Zig version does not meet the minimum build requirement: + \\ required Zig version: {[minimum_zig_version]} + \\ actual Zig version: {[current_version]} + \\ + , .{ + .current_version = builtin.zig_version, + .minimum_zig_version = minimum_zig_version, + })); + } + + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const strip = b.option(bool, "strip", "Omit debug information"); + const test_filters = b.option([]const []const u8, "test-filter", "Skip tests that do not match any filter") orelse &[0][]const u8{}; + _ = b.addModule("known-folders", .{ .root_source_file = b.path("known-folders.zig"), + .target = target, + .optimize = optimize, + .strip = strip, }); + + const unit_tests = b.addTest(.{ + .root_source_file = b.path("known-folders.zig"), + .target = target, + .optimize = optimize, + .strip = strip, + .filters = test_filters, + }); + + const run_unit_tests = b.addRunArtifact(unit_tests); + + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_unit_tests.step); } diff --git a/build.zig.zon b/build.zig.zon index 1b1c9d5..f35f8e1 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,7 @@ .{ - .name = "", + .name = "known-folders", .version = "0.0.0", + .minimum_zig_version = "0.12.0", .dependencies = .{}, .paths = .{ "build.zig", diff --git a/gyro.zzz b/gyro.zzz deleted file mode 100644 index 79d679b..0000000 --- a/gyro.zzz +++ /dev/null @@ -1,10 +0,0 @@ -pkgs: - known-folders: - version: 0.0.0 - description: "Provides access to well-known folders across several operating systems" - source_url: "https://github.com/ziglibs/known-folders" - root: known-folders.zig - files: - LICENSE - README.md - RESOURCES.md diff --git a/known-folders.zig b/known-folders.zig index 4119086..ff22c46 100644 --- a/known-folders.zig +++ b/known-folders.zig @@ -3,22 +3,101 @@ const root = @import("root"); const builtin = @import("builtin"); pub const KnownFolder = enum { + /// Windows default: `%SystemDrive%\Users\%USERNAME%` + /// MacOS default: `$HOME` + /// *nix default: `$HOME` home, + /// Windows default: `%USERPROFILE%\Documents` + /// MacOS default: `$HOME/Documents` + /// *nix default: `$HOME/Documents` + /// XDG directory: `XDG_DOCUMENTS_DIR` documents, + /// Windows default: `%USERPROFILE%\Pictures` + /// MacOS default: `$HOME/Pictures` + /// *nix default: `$HOME/Pictures` + /// XDG directory: `XDG_PICTURES_DIR` pictures, + /// Windows default: `%USERPROFILE%\Music` + /// MacOS default: `$HOME/Music` + /// *nix default: `$HOME/Music` + /// XDG directory: `XDG_MUSIC_DIR` music, + /// Windows default: `%USERPROFILE%\Videos` + /// MacOS default: `$HOME/Movies` + /// *nix default: `$HOME/Videos` + /// XDG directory: `XDG_VIDEOS_DIR` videos, + /// Windows default: `%USERPROFILE%\Desktop` + /// MacOS default: `$HOME/Desktop` + /// *nix default: `$HOME/Desktop` + /// XDG directory: `XDG_DESKTOP_DIR` desktop, + /// Windows default: `%USERPROFILE%\Downloads` + /// MacOS default: `$HOME/Downloads` + /// *nix default: `$HOME/Downloads` + /// XDG directory: `XDG_DOWNLOAD_DIR` downloads, + /// Windows default: `%PUBLIC%` (`%SystemDrive%\Users\Public`) + /// MacOS default: `$HOME/Public` + /// *nix default: `$HOME/Public` + /// XDG directory: `XDG_PUBLICSHARE_DIR` public, + /// Windows default: `%windir%\Fonts` + /// MacOS default: `$HOME/Library/Fonts` + /// *nix default: `$HOME/.local/share/fonts` + /// XDG directory: `XDG_DATA_HOME/fonts` fonts, + /// Windows default: `%APPDATA%\Microsoft\Windows\Start Menu` + /// MacOS default: `$HOME/Applications` + /// *nix default: `$HOME/.local/share/applications` + /// XDG directory: `XDG_DATA_HOME/applications` app_menu, + /// The base directory relative to which user-specific non-essential (cached) data should be written. + /// + /// Windows: `%LOCALAPPDATA%\Temp` + /// MacOS default: HOME/Library/Caches` + /// *nix default: HOME/.cache` + /// XDG directory: `XDG_CACHE_HOME` cache, + /// The base directory relative to which user-specific configuration files should be written. + /// + /// Windows default: %APPDATA% (%USERPROFILE%\AppData\Roaming) + /// MacOS default: `$HOME/Library/Preferences` + /// *nix default: `$HOME/.config` + /// XDG directory: `XDG_CONFIG_HOME` roaming_configuration, + /// The base directory relative to which user-specific configuration files should be written. + /// + /// Windows default: `%LOCALAPPDATA%` (`%USERPROFILE%\AppData\Local`) + /// MacOS default: `$HOME/Library/Application Support` + /// *nix default: `$HOME/.config` + /// XDG directory: `XDG_CONFIG_HOME` local_configuration, + /// The base directory relative to which global configuration files should be searched. + /// + /// Windows default: `%ALLUSERSPROFILE%` (`%ProgramData%`, `%SystemDrive%\ProgramData`) + /// MacOS default: `/Library/Preferences` + /// *nix default: `/etc` + /// XDG directory: `XDG_CONFIG_DIRS` (first directory) global_configuration, + /// The base directory relative to which user-specific data files should be written. + /// + /// Windows: `%LOCALAPPDATA%\Temp` + /// MacOS default: `$HOME/Library/Application Support` + /// *nix default: `$HOME/.local/share` + /// XDG directory: `XDG_DATA_HOME` + /// + /// XDG's definition of `XDG_DATA_HOME`: There is a set of preference ordered base directories data, + /// The base directory relative to which user-specific runtime files and other file objects should be placed. + /// + /// Windows: `%LOCALAPPDATA%\Temp` + /// MacOS default: `$HOME/Library/Application Support` + /// *nix default: no default (only set with `XDG_RUNTIME_DIR`) + /// XDG directory: `XDG_RUNTIME_DIR` + /// runtime, + /// Get the directory that contains the current executable. executable_dir, }; @@ -30,20 +109,32 @@ pub const KnownFolderConfig = struct { xdg_on_mac: bool = false, }; -/// Returns a directory handle, or, if the folder does not exist, `null`. -pub fn open(allocator: std.mem.Allocator, folder: KnownFolder, args: std.fs.Dir.OpenDirOptions) (std.fs.Dir.OpenError || Error)!?std.fs.Dir { - const path_or_null = try getPath(allocator, folder); - if (path_or_null) |path| { - defer allocator.free(path); +const OpenOptions = if (@import("builtin").zig_version.order(std.SemanticVersion.parse("0.14.0-dev.211+0cc42d090") catch unreachable) == .lt) + std.fs.Dir.OpenDirOptions +else + std.fs.Dir.OpenOptions; - return try std.fs.cwd().openDir(path, args); - } else { - return null; - } +/// Returns a directory handle, or, if the folder does not exist, `null`. +pub fn open(allocator: std.mem.Allocator, folder: KnownFolder, args: OpenOptions) (std.fs.Dir.OpenError || Error)!?std.fs.Dir { + const path = try getPath(allocator, folder) orelse return null; + defer allocator.free(path); + return try std.fs.cwd().openDir(path, args); } /// Returns the path to the folder or, if the folder does not exist, `null`. pub fn getPath(allocator: std.mem.Allocator, folder: KnownFolder) Error!?[]const u8 { + var system: DefaultSystem = .{}; + defer system.deinit(); + return getPathInner(DefaultSystem, &system, allocator, folder); +} + +fn getPathInner( + /// `DefaultDefaultSystem` or `TestingDefaultSystem` + comptime System: type, + system: *System, + allocator: std.mem.Allocator, + folder: KnownFolder, +) Error!?[]const u8 { if (folder == .executable_dir) { if (builtin.os.tag == .wasi) return null; return std.fs.selfExeDirPathAlloc(allocator) catch |err| switch (err) { @@ -52,10 +143,6 @@ pub fn getPath(allocator: std.mem.Allocator, folder: KnownFolder) Error!?[]const }; } - // used for temporary allocations - var arena = std.heap.ArenaAllocator.init(allocator); - defer arena.deinit(); - switch (builtin.os.tag) { .windows => { const funcs = struct { @@ -68,9 +155,7 @@ pub fn getPath(allocator: std.mem.Allocator, folder: KnownFolder) Error!?[]const extern "ole32" fn CoTaskMemFree(pv: std.os.windows.LPVOID) callconv(std.os.windows.WINAPI) void; }; - const folder_spec = windows_folder_spec.get(folder); - - switch (folder_spec) { + switch (getWindowsFolderSpec(folder)) { .by_guid => |guid| { var dir_path_ptr: [*:0]u16 = undefined; switch (funcs.SHGetKnownFolderPath( @@ -94,120 +179,385 @@ pub fn getPath(allocator: std.mem.Allocator, folder: KnownFolder) Error!?[]const } }, .by_env => |env_path| { + const env_var = std.process.getEnvVarOwned(allocator, env_path.env_var) catch |err| switch (err) { + error.EnvironmentVariableNotFound => return null, + error.InvalidWtf8 => return null, + error.OutOfMemory => |e| return e, + }; + if (env_path.subdir) |sub_dir| { - const root_path = std.process.getEnvVarOwned(arena.allocator(), env_path.env_var) catch |err| switch (err) { - error.EnvironmentVariableNotFound => return null, - error.InvalidWtf8 => return null, - error.OutOfMemory => |e| return e, - }; - return try std.fs.path.join(allocator, &[_][]const u8{ root_path, sub_dir }); + defer allocator.free(env_var); + return try std.fs.path.join(allocator, &[_][]const u8{ env_var, sub_dir }); } else { - return std.process.getEnvVarOwned(allocator, env_path.env_var) catch |err| switch (err) { - error.EnvironmentVariableNotFound => return null, - error.InvalidWtf8 => return null, - error.OutOfMemory => |e| return e, - }; + return env_var; } }, } }, .macos => { - if (@hasDecl(root, "known_folders_config") and root.known_folders_config.xdg_on_mac) { - return getPathXdg(allocator, &arena, folder); + if (system.config.xdg_on_mac) return try getPathXdg(System, system, allocator, folder); + + if (folder == .global_configuration) { + // special case because the returned path is absolute + return try allocator.dupe(u8, comptime getMacFolderSpec(.global_configuration)); } - switch (mac_folder_spec.get(folder)) { - .absolute => |abs| { - return try allocator.dupe(u8, abs); - }, - .suffix => |s| { - const home_dir = if (std.posix.getenv("HOME")) |home| - home - else - return null; - - if (s) |suffix| { - return try std.fs.path.join(allocator, &[_][]const u8{ home_dir, suffix }); - } else { - return try allocator.dupe(u8, home_dir); - } - }, + const home_dir = try system.getenv(allocator, "HOME") orelse return null; + + if (folder == .home) { + return try allocator.dupe(u8, home_dir); } + + const path = getMacFolderSpec(folder); + return try std.fs.path.join(allocator, &.{ home_dir, path }); }, // Assume unix derivatives with XDG - else => { - return getPathXdg(allocator, &arena, folder); - }, + else => return try getPathXdg(System, system, allocator, folder), } - unreachable; } -fn getPathXdg(allocator: std.mem.Allocator, arena: *std.heap.ArenaAllocator, folder: KnownFolder) Error!?[]const u8 { - const folder_spec = xdg_folder_spec.get(folder); +fn getPathXdg( + /// `DefaultDefaultSystem` or `TestingDefaultSystem` + comptime System: type, + system: *System, + allocator: std.mem.Allocator, + folder: KnownFolder, +) Error!?[]const u8 { + const folder_spec = getXdgFolderSpec(folder); - if (@hasDecl(root, "known_folders_config") and root.known_folders_config.xdg_force_default) { - if (folder_spec.default) |default| { - if (default[0] == '~') { - const home = std.process.getEnvVarOwned(arena.allocator(), "HOME") catch null orelse return null; - return try std.mem.concat(allocator, u8, &[_][]const u8{ home, default[1..] }); - } else { - return try allocator.dupe(u8, default); + fallback: { + if (system.config.xdg_force_default and folder != .home) + break :fallback; + + const env: []const u8, var env_owned: bool = env_opt: { + if (try system.getenv(allocator, folder_spec.env.name)) |env_opt| + break :env_opt .{ env_opt, false }; + + if (system.config.xdg_force_default) + break :fallback; + + if (!folder_spec.env.user_dir) + break :fallback; + + const env = xdgUserDirLookup(System, system, allocator, folder_spec.env.name) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => break :fallback, + } orelse break :fallback; + + break :env_opt .{ env, true }; + }; + defer if (env_owned) allocator.free(env); + + if (folder_spec.env.suffix) |suffix| { + return try std.fs.path.join(allocator, &.{ env, suffix }); + } + + // XDG_CONFIG_DIRS is a sequence of directories that are separated with ':' + if (folder == .global_configuration) { + std.debug.assert(std.mem.eql(u8, folder_spec.env.name, "XDG_CONFIG_DIRS")); + var iter = std.mem.splitScalar(u8, env, ':'); + return try allocator.dupe(u8, iter.first()); + } + + if (env_owned) { + env_owned = false; + return env; + } + + return try allocator.dupe(u8, env); + } + + const default = folder_spec.default orelse return null; + if (default[0] == '~') { + const home = try system.getenv(allocator, "HOME") orelse return null; + return try std.fs.path.join(allocator, &.{ home, default[1..] }); + } else { + return try allocator.dupe(u8, default); + } +} + +/// Encapsulates all operating system interactions +const DefaultSystem = struct { + comptime config: KnownFolderConfig = if (@hasDecl(root, "known_folders_config")) root.known_folders_config else .{}, + envmap: if (builtin.os.tag == .wasi and !builtin.link_libc) ?std.process.EnvMap else ?void = null, + + pub fn deinit(system: *DefaultSystem) void { + if (builtin.os.tag == .wasi and !builtin.link_libc) { + if (system.envmap) |*envmap| envmap.deinit(); + } + } + + /// Caller does **not** owns the returned memory. + pub fn getenv(system: *DefaultSystem, allocator: std.mem.Allocator, key: []const u8) std.mem.Allocator.Error!?[]const u8 { + if (builtin.os.tag == .wasi and !builtin.link_libc) { + if (system.envmap == null) { + system.envmap = std.process.getEnvMap(allocator) catch return error.OutOfMemory; } + return system.envmap.?.get(key); } + return std.posix.getenv(key); } - const env_opt = env_opt: { - if (std.process.getEnvVarOwned(arena.allocator(), folder_spec.env.name) catch null) |env_opt| break :env_opt env_opt; + pub fn openFile(_: *DefaultSystem, dir_path: []const u8, sub_path: []const u8) std.fs.File.OpenError!std.fs.File { + var dir = try std.fs.cwd().openDir(dir_path, .{}); + defer dir.close(); + return try dir.openFile(sub_path, .{}); + } +}; - if (!folder_spec.env.user_dir) break :env_opt null; +/// Encapsulates all operating system interactions which can be overriden for testing purposes. +const TestingSystem = struct { + config: KnownFolderConfig, + /// Specifies all accessible environment variables. + /// + /// Notable environment variables are `HOME`, `XDG_CONFIG_HOME` or `XDG_${FOLDER_NAME}_DIR`. + env_map: []const struct { key: []const u8, value: ?[]const u8 } = &.{}, + /// Specifies all accessible files. + /// + /// This is only used for the `user-dirs.dirs` file but can easily be applied to any other file. + files: []const struct { path: []const u8, data: ?[]const u8 } = &.{}, - // TODO: add caching so we only need to read once in a run - const config_dir_path = getPathXdg(arena.allocator(), arena, .local_configuration) catch null orelse break :env_opt null; - const config_dir = std.fs.cwd().openDir(config_dir_path, .{}) catch break :env_opt null; - const home = std.process.getEnvVarOwned(arena.allocator(), "HOME") catch null orelse break :env_opt null; - const user_dirs = config_dir.openFile("user-dirs.dirs", .{}) catch null orelse break :env_opt null; + tmp_dir: ?std.testing.TmpDir = null, - var read: [1024 * 8]u8 = undefined; - _ = user_dirs.readAll(&read) catch null orelse break :env_opt null; - const start = folder_spec.env.name.len + "=\"$HOME".len; + comptime { + std.debug.assert(builtin.os.tag != .windows); + } - var line_it = std.mem.splitScalar(u8, &read, '\n'); - while (line_it.next()) |line| { - if (std.mem.startsWith(u8, line, folder_spec.env.name)) { - const end = line.len - 1; - if (start >= end) { - return error.ParseError; - } + pub fn deinit(impl: *TestingSystem) void { + // TODO check which env variables and files have been accessed and fail the test if one was never used. + if (impl.tmp_dir) |*tmp_dir| tmp_dir.cleanup(); + } - const subdir = line[start..end]; + /// Asserts that the environment variable is specified in `TestingSystem.env_map`. + /// + /// Caller does **not** owns the returned memory. + pub fn getenv(system: *TestingSystem, allocator: std.mem.Allocator, key: []const u8) std.mem.Allocator.Error!?[]const u8 { + { + // This allocation will simulate the possibility of allocation failure using `std.testing.checkAllAllocationFailures` + allocator.free(try allocator.alloc(u8, 1)); + } - break :env_opt try std.mem.concat(arena.allocator(), u8, &[_][]const u8{ home, subdir }); - } + for (system.env_map) |kv| { + if (std.mem.eql(u8, key, kv.key)) return kv.value; } - break :env_opt null; - }; + system.deinit(); + std.debug.panic("the result of `getenv(\"{}\")` must explicitly specified in the TestingSystem", .{std.zig.fmtEscapes(key)}); + } - if (env_opt) |env| { - if (folder_spec.env.suffix) |suffix| { - return try std.mem.concat(allocator, u8, &[_][]const u8{ env, suffix }); + /// Asserts that the file is specified in `TestingSystem.files`. + pub fn openFile(system: *TestingSystem, dir_path: []const u8, sub_path: []const u8) std.fs.File.OpenError!std.fs.File { + const file_path = std.fs.path.join(std.testing.allocator, &.{ dir_path, sub_path }) catch @panic("OOM"); + defer std.testing.allocator.free(file_path); + + const kv = for (system.files) |kv| { + if (std.mem.eql(u8, file_path, kv.path)) break kv; } else { - if (std.mem.eql(u8, folder_spec.env.name, "XDG_CONFIG_DIRS")) { - var iter = std.mem.splitScalar(u8, env, ':'); - return try allocator.dupe(u8, iter.next() orelse ""); - } else { - return try allocator.dupe(u8, env); + system.deinit(); + std.debug.panic("`openFile(\"{0}\", \"{1}\")` has been called on an unexpected file", .{ std.zig.fmtEscapes(dir_path), std.zig.fmtEscapes(sub_path) }); + }; + + const data = kv.data orelse return error.FileNotFound; + + const tmp_dir = if (system.tmp_dir) |*tmp_dir| tmp_dir else blk: { + system.tmp_dir = std.testing.tmpDir(.{}); + break :blk &system.tmp_dir.?; + }; + + const buffer_size = std.base64.standard.Encoder.calcSize(kv.path.len); + const buffer = std.testing.allocator.alloc(u8, buffer_size) catch @panic("OOM"); + defer std.testing.allocator.free(buffer); + + const prefix = std.base64.standard.Encoder.encode(buffer, kv.path); + + const writeFile = if (comptime builtin.zig_version.order(std.SemanticVersion.parse("0.13.0-dev.68+b86c4bde6") catch unreachable) == .lt) + std.fs.Dir.writeFile2 + else + std.fs.Dir.writeFile; + + writeFile(tmp_dir.dir, .{ + .sub_path = prefix, + .data = data, + }) catch |err| { + tmp_dir.cleanup(); + std.debug.panic("failed to create file './{s}/{s}' which represents '{s}': {}", .{ &tmp_dir.sub_path, prefix, kv.path, err }); + }; + + return try tmp_dir.dir.openFile(prefix, .{}); + } +}; + +const UserDirLookupError = + std.mem.Allocator.Error || + std.fs.File.OpenError || + std.fs.File.ReadError; + +const xdg_user_dir_lookup_line_buffer_size: usize = 511; + +/// Looks up a XDG user directory of the specified type. +/// +/// Caller owns the returned memory. +// +/// Ported of xdg-user-dir-lookup.c from xdg-user-dirs, which is licensed under the MIT license: +/// https://cgit.freedesktop.org/xdg/xdg-user-dirs/tree/xdg-user-dir-lookup.c +fn xdgUserDirLookup( + /// `DefaultDefaultSystem` or `TestingDefaultSystem` + comptime System: type, + system: *System, + allocator: std.mem.Allocator, + /// A string that specifies the type of directory. + /// + /// Asserts that the folder type is should be one of the following: + /// - `XDG_DESKTOP_DIR` + /// - `XDG_DOWNLOAD_DIR` + /// - `XDG_TEMPLATES_DIR` + /// - `XDG_PUBLICSHARE_DIR` + /// - `XDG_DOCUMENTS_DIR` + /// - `XDG_MUSIC_DIR` + /// - `XDG_PICTURES_DIR` + /// - `XDG_VIDEOS_DIR` + folder_type: []const u8, +) UserDirLookupError!?[]u8 { + if (builtin.mode == .Debug) { + std.debug.assert(std.mem.startsWith(u8, folder_type, "XDG_")); + std.debug.assert(std.mem.endsWith(u8, folder_type, "_DIR")); + + const folder_name = folder_type["XDG_".len .. folder_type.len - "_DIR".len]; + std.debug.assert( // + std.mem.eql(u8, folder_name, "DESKTOP") or + std.mem.eql(u8, folder_name, "DOWNLOAD") or + std.mem.eql(u8, folder_name, "TEMPLATES") or + std.mem.eql(u8, folder_name, "PUBLICSHARE") or + std.mem.eql(u8, folder_name, "DOCUMENTS") or + std.mem.eql(u8, folder_name, "MUSIC") or + std.mem.eql(u8, folder_name, "PICTURES") or + std.mem.eql(u8, folder_name, "VIDEOS")); + } + + const home_dir: []const u8 = try system.getenv(allocator, "HOME") orelse return null; + const maybe_config_home: ?[]const u8 = if (try system.getenv(allocator, "XDG_CONFIG_HOME")) |value| + if (value.len != 0) value else null + else + null; + + const file: std.fs.File = if (maybe_config_home) |config_home| + try system.openFile(config_home, "user-dirs.dirs") + else + try system.openFile(home_dir, ".config/user-dirs.dirs"); + defer file.close(); + + var fbr = std.io.bufferedReaderSize(512, file.reader()); + const reader = fbr.reader(); + + var user_dir: ?[]u8 = null; + outer: while (true) { + var buffer: [xdg_user_dir_lookup_line_buffer_size + 1]u8 = undefined; + + // Similar to `readUntilDelimiterOrEof` but also writes a null-terminator + var line: [:0]u8 = for (&buffer, 0..) |*out, index| { + const byte = reader.readByte() catch |err| switch (err) { + error.EndOfStream => if (index == 0) break :outer else '\n', + else => |e| return e, + }; + if (byte == '\n') { + out.* = 0; + break buffer[0..index :0]; } + out.* = byte; + } else blk: { + // This happens when the line is longer than 511 characters + // There are four possible ways to handle this: + // - use dynamic allocation to acquire enough storage + // - return an error + // - skip the line + // - truncate the line + // + // The xdg-user-dir implementation chooses to trunacte the line. + // See "getPath - user-dirs.dirs - very long line" test + + try reader.skipUntilDelimiterOrEof('\n'); + + buffer[buffer.len - 1] = 0; + break :blk buffer[0 .. buffer.len - 1 :0]; + }; + + while (line[0] == ' ' or line[0] == '\t') + line = line[1..]; + + if (!std.mem.startsWith(u8, line, folder_type)) + continue; + line = line[folder_type.len..]; + + while (line[0] == ' ' or line[0] == '\t') line = line[1..]; + + if (line[0] != '=') + continue; + line = line["=".len..]; + + while (line[0] == ' ' or line[0] == '\t') line = line[1..]; + + if (line[0] != '\"') + continue; + line = line["\"".len..]; + + if (user_dir) |path| { + allocator.free(path); + user_dir = null; } - } else { - const default = folder_spec.default orelse return null; - if (default[0] == '~') { - const home = std.process.getEnvVarOwned(arena.allocator(), "HOME") catch null orelse return null; - return try std.mem.concat(allocator, u8, &[_][]const u8{ home, default[1..] }); - } else { - return try allocator.dupe(u8, default); + + var is_relative = false; + if (std.mem.startsWith(u8, line, "$HOME/")) { + line = line["$HOME/".len..]; + is_relative = true; + } else if (line[0] != '/') { + continue; } + + var escaped_character_count: usize = 0; + + var index: usize = 0; + const end_index: usize = while (index < line.len) : (index += 1) { + if (line[index] == '\"') + break index; + if (line[index] == '\\' and line[index + 1] != 0) { + // escaped character + escaped_character_count += 1; + index += 1; + } + } else line.len; + + const new_user_dir_len = (if (is_relative) home_dir.len + "/".len else 0) + end_index - escaped_character_count; + const new_user_dir = try allocator.alloc(u8, new_user_dir_len); + errdefer @compileError(""); + + var out_index: usize = 0; + if (is_relative) { + @memcpy(new_user_dir[0..home_dir.len], home_dir); + new_user_dir[home_dir.len] = '/'; + out_index = home_dir.len + 1; + } + + index = 0; + while (index < end_index) : (index += 1) { + const ch1 = line[index]; + const ch2 = line[index + 1]; + if (ch1 == '\\' and ch2 != 0) { + // escaped character + new_user_dir[out_index] = ch2; + out_index += 1; + index += 1; + } else { + new_user_dir[out_index] = ch1; + out_index += 1; + } + } + + std.debug.assert(out_index == new_user_dir.len); + std.debug.assert(user_dir == null); + user_dir = new_user_dir; } + + return user_dir; } /// Contains the GUIDs for each available known-folder on windows @@ -219,140 +569,598 @@ const WindowsFolderSpec = union(enum) { }, }; -/// Contains the xdg environment variable amd the default value for each available known-folder on windows +fn getWindowsFolderSpec(folder: KnownFolder) WindowsFolderSpec { + return switch (folder) { + .executable_dir => unreachable, + .home => .{ .by_guid = comptime std.os.windows.GUID.parse("{5E6C858F-0E22-4760-9AFE-EA3317B67173}") }, // FOLDERID_Profile + .documents => .{ .by_guid = comptime std.os.windows.GUID.parse("{FDD39AD0-238F-46AF-ADB4-6C85480369C7}") }, // FOLDERID_Documents + .pictures => .{ .by_guid = comptime std.os.windows.GUID.parse("{33E28130-4E1E-4676-835A-98395C3BC3BB}") }, // FOLDERID_Pictures + .music => .{ .by_guid = comptime std.os.windows.GUID.parse("{4BD8D571-6D19-48D3-BE97-422220080E43}") }, // FOLDERID_Music + .videos => .{ .by_guid = comptime std.os.windows.GUID.parse("{18989B1D-99B5-455B-841C-AB7C74E4DDFC}") }, // FOLDERID_Videos + .desktop => .{ .by_guid = comptime std.os.windows.GUID.parse("{B4BFCC3A-DB2C-424C-B029-7FE99A87C641}") }, // FOLDERID_Desktop + .downloads => .{ .by_guid = comptime std.os.windows.GUID.parse("{374DE290-123F-4565-9164-39C4925E467B}") }, // FOLDERID_Downloads + .public => .{ .by_guid = comptime std.os.windows.GUID.parse("{DFDF76A2-C82A-4D63-906A-5644AC457385}") }, // FOLDERID_Public + .fonts => .{ .by_guid = comptime std.os.windows.GUID.parse("{FD228CB7-AE11-4AE3-864C-16F3910AB8FE}") }, // FOLDERID_Fonts + .app_menu => .{ .by_guid = comptime std.os.windows.GUID.parse("{625B53C3-AB48-4EC1-BA1F-A1EF4146FC19}") }, // FOLDERID_StartMenu + .cache => .{ .by_env = .{ .env_var = "LOCALAPPDATA", .subdir = "Temp" } }, // %LOCALAPPDATA%\Temp + .roaming_configuration => .{ .by_guid = comptime std.os.windows.GUID.parse("{3EB685DB-65F9-4CF6-A03A-E3EF65729F3D}") }, // FOLDERID_RoamingAppData + .local_configuration => .{ .by_guid = comptime std.os.windows.GUID.parse("{F1B32785-6FBA-4FCF-9D55-7B8E7F157091}") }, // FOLDERID_LocalAppData + .global_configuration => .{ .by_guid = comptime std.os.windows.GUID.parse("{62AB5D82-FDC1-4DC3-A9DD-070D1D495D97}") }, // FOLDERID_ProgramData + .data => .{ .by_env = .{ .env_var = "APPDATA", .subdir = null } }, // %LOCALAPPDATA%\Temp + .runtime => .{ .by_env = .{ .env_var = "LOCALAPPDATA", .subdir = "Temp" } }, + }; +} + +/// The default value for `KnownFolder.global_configuration` is the only absolute path. All others default values are relative to the home directory. +fn getMacFolderSpec(folder: KnownFolder) []const u8 { + return switch (folder) { + .executable_dir => unreachable, + .home => unreachable, + .documents => "Documents", + .pictures => "Pictures", + .music => "Music", + .videos => "Movies", + .desktop => "Desktop", + .downloads => "Downloads", + .public => "Public", + .fonts => "Library/Fonts", + .app_menu => "Applications", + .cache => "Library/Caches", + .roaming_configuration => "Library/Preferences", + .local_configuration => "Library/Application Support", + .global_configuration => "/Library/Preferences", // absolute path + .data => "Library/Application Support", + .runtime => "Library/Application Support", + }; +} + +/// Contains the xdg environment variable and the default value for each available known-folder const XdgFolderSpec = struct { env: struct { + /// Name of the environment variable. name: []const u8, + /// `true` means that the folder is a user directory that can be overriden in the `user-dirs.dirs`. See `xdgUserDirLookup`. + /// `false` means that the folder is system directory. user_dir: bool, suffix: ?[]const u8, }, default: ?[]const u8, }; -/// Contains the folder items for macOS -const MacFolderSpec = union(enum) { - suffix: ?[]const u8, - absolute: []const u8, +/// The default value for `KnownFolder.global_configuration` is the only absolute path. All others default values are relative to the home directory. +fn getXdgFolderSpec(folder: KnownFolder) XdgFolderSpec { + return switch (folder) { + .executable_dir => unreachable, + .home => .{ .env = .{ .name = "HOME", .user_dir = false, .suffix = null }, .default = null }, + .documents => .{ .env = .{ .name = "XDG_DOCUMENTS_DIR", .user_dir = true, .suffix = null }, .default = "~/Documents" }, + .pictures => .{ .env = .{ .name = "XDG_PICTURES_DIR", .user_dir = true, .suffix = null }, .default = "~/Pictures" }, + .music => .{ .env = .{ .name = "XDG_MUSIC_DIR", .user_dir = true, .suffix = null }, .default = "~/Music" }, + .videos => .{ .env = .{ .name = "XDG_VIDEOS_DIR", .user_dir = true, .suffix = null }, .default = "~/Videos" }, + .desktop => .{ .env = .{ .name = "XDG_DESKTOP_DIR", .user_dir = true, .suffix = null }, .default = "~/Desktop" }, + .downloads => .{ .env = .{ .name = "XDG_DOWNLOAD_DIR", .user_dir = true, .suffix = null }, .default = "~/Downloads" }, + .public => .{ .env = .{ .name = "XDG_PUBLICSHARE_DIR", .user_dir = true, .suffix = null }, .default = "~/Public" }, + .fonts => .{ .env = .{ .name = "XDG_DATA_HOME", .user_dir = false, .suffix = "/fonts" }, .default = "~/.local/share/fonts" }, + .app_menu => .{ .env = .{ .name = "XDG_DATA_HOME", .user_dir = false, .suffix = "/applications" }, .default = "~/.local/share/applications" }, + .cache => .{ .env = .{ .name = "XDG_CACHE_HOME", .user_dir = false, .suffix = null }, .default = "~/.cache" }, + .roaming_configuration => .{ .env = .{ .name = "XDG_CONFIG_HOME", .user_dir = false, .suffix = null }, .default = "~/.config" }, + .local_configuration => .{ .env = .{ .name = "XDG_CONFIG_HOME", .user_dir = false, .suffix = null }, .default = "~/.config" }, + .global_configuration => .{ .env = .{ .name = "XDG_CONFIG_DIRS", .user_dir = false, .suffix = null }, .default = "/etc" }, // absolute path + .data => .{ .env = .{ .name = "XDG_DATA_HOME", .user_dir = false, .suffix = null }, .default = "~/.local/share" }, + .runtime => .{ .env = .{ .name = "XDG_RUNTIME_DIR", .user_dir = false, .suffix = null }, .default = null }, + }; +} + +const GetPathTestParams = struct { + system: TestingSystem, + folder: KnownFolder, + expected: ?[]const u8, }; -/// This returns a struct type with one field per KnownFolder of type `T`. -/// used for storing different config data per field -fn KnownFolderSpec(comptime T: type) type { - return struct { - const Self = @This(); - - home: T, - documents: T, - pictures: T, - music: T, - videos: T, - desktop: T, - downloads: T, - public: T, - fonts: T, - app_menu: T, - cache: T, - roaming_configuration: T, - local_configuration: T, - global_configuration: T, - data: T, - runtime: T, - - fn get(self: Self, folder: KnownFolder) T { - inline for (std.meta.fields(Self)) |fld| { - if (folder == @field(KnownFolder, fld.name)) - return @field(self, fld.name); - } - unreachable; +fn testGetPath(get_path_params: GetPathTestParams) !void { + const funcs = struct { + fn testFunc(allocator: std.mem.Allocator, params: GetPathTestParams) !void { + var system = params.system; + defer system.deinit(); + + const actual = try getPathInner(TestingSystem, &system, allocator, params.folder); + defer if (actual) |path| allocator.free(path); + + if (params.expected == null and actual == null) return; + if (params.expected != null and actual != null and std.mem.eql(u8, params.expected.?, actual.?)) return; + std.debug.print("expected: '{?s}' but got '{?s}'\n", .{ params.expected, actual }); + return error.TestExpectedEqual; } }; + + try std.testing.checkAllAllocationFailures(std.testing.allocator, funcs.testFunc, .{get_path_params}); } -/// Stores how to find each known folder on windows. -const windows_folder_spec = blk: { - // workaround for zig eval branch quota when parsing the GUIDs - @setEvalBranchQuota(10_000); - break :blk KnownFolderSpec(WindowsFolderSpec){ - .home = WindowsFolderSpec{ .by_guid = std.os.windows.GUID.parse("{5E6C858F-0E22-4760-9AFE-EA3317B67173}") }, // FOLDERID_Profile - .documents = WindowsFolderSpec{ .by_guid = std.os.windows.GUID.parse("{FDD39AD0-238F-46AF-ADB4-6C85480369C7}") }, // FOLDERID_Documents - .pictures = WindowsFolderSpec{ .by_guid = std.os.windows.GUID.parse("{33E28130-4E1E-4676-835A-98395C3BC3BB}") }, // FOLDERID_Pictures - .music = WindowsFolderSpec{ .by_guid = std.os.windows.GUID.parse("{4BD8D571-6D19-48D3-BE97-422220080E43}") }, // FOLDERID_Music - .videos = WindowsFolderSpec{ .by_guid = std.os.windows.GUID.parse("{18989B1D-99B5-455B-841C-AB7C74E4DDFC}") }, // FOLDERID_Videos - .desktop = WindowsFolderSpec{ .by_guid = std.os.windows.GUID.parse("{B4BFCC3A-DB2C-424C-B029-7FE99A87C641}") }, // FOLDERID_Desktop - .downloads = WindowsFolderSpec{ .by_guid = std.os.windows.GUID.parse("{374DE290-123F-4565-9164-39C4925E467B}") }, // FOLDERID_Downloads - .public = WindowsFolderSpec{ .by_guid = std.os.windows.GUID.parse("{DFDF76A2-C82A-4D63-906A-5644AC457385}") }, // FOLDERID_Public - .fonts = WindowsFolderSpec{ .by_guid = std.os.windows.GUID.parse("{FD228CB7-AE11-4AE3-864C-16F3910AB8FE}") }, // FOLDERID_Fonts - .app_menu = WindowsFolderSpec{ .by_guid = std.os.windows.GUID.parse("{625B53C3-AB48-4EC1-BA1F-A1EF4146FC19}") }, // FOLDERID_StartMenu - .cache = WindowsFolderSpec{ .by_env = .{ .env_var = "LOCALAPPDATA", .subdir = "Temp" } }, // %LOCALAPPDATA%\Temp - .roaming_configuration = WindowsFolderSpec{ .by_guid = std.os.windows.GUID.parse("{3EB685DB-65F9-4CF6-A03A-E3EF65729F3D}") }, // FOLDERID_RoamingAppData - .local_configuration = WindowsFolderSpec{ .by_guid = std.os.windows.GUID.parse("{F1B32785-6FBA-4FCF-9D55-7B8E7F157091}") }, // FOLDERID_LocalAppData - .global_configuration = WindowsFolderSpec{ .by_guid = std.os.windows.GUID.parse("{62AB5D82-FDC1-4DC3-A9DD-070D1D495D97}") }, // FOLDERID_ProgramData - .data = WindowsFolderSpec{ .by_env = .{ .env_var = "APPDATA", .subdir = null } }, // %LOCALAPPDATA%\Temp - .runtime = WindowsFolderSpec{ .by_env = .{ .env_var = "LOCALAPPDATA", .subdir = "Temp" } }, +test "getPath - no HOME" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + + var system: TestingSystem = .{ + .config = .{ .xdg_on_mac = true }, + .env_map = &.{ + .{ .key = "HOME", .value = null }, + + .{ .key = "XDG_DESKTOP_DIR", .value = "/home/janedoe/Desktop" }, + .{ .key = "XDG_DOWNLOAD_DIR", .value = null }, + }, }; -}; + defer system.deinit(); -/// Stores how to find each known folder in xdg. -const xdg_folder_spec = KnownFolderSpec(XdgFolderSpec){ - .home = XdgFolderSpec{ .env = .{ .name = "HOME", .user_dir = false, .suffix = null }, .default = null }, - .documents = XdgFolderSpec{ .env = .{ .name = "XDG_DOCUMENTS_DIR", .user_dir = true, .suffix = null }, .default = "~/Documents" }, - .pictures = XdgFolderSpec{ .env = .{ .name = "XDG_PICTURES_DIR", .user_dir = true, .suffix = null }, .default = "~/Pictures" }, - .music = XdgFolderSpec{ .env = .{ .name = "XDG_MUSIC_DIR", .user_dir = true, .suffix = null }, .default = "~/Music" }, - .videos = XdgFolderSpec{ .env = .{ .name = "XDG_VIDEOS_DIR", .user_dir = true, .suffix = null }, .default = "~/Videos" }, - .desktop = XdgFolderSpec{ .env = .{ .name = "XDG_DESKTOP_DIR", .user_dir = true, .suffix = null }, .default = "~/Desktop" }, - .downloads = XdgFolderSpec{ .env = .{ .name = "XDG_DOWNLOAD_DIR", .user_dir = true, .suffix = null }, .default = "~/Downloads" }, - .public = XdgFolderSpec{ .env = .{ .name = "XDG_PUBLICSHARE_DIR", .user_dir = true, .suffix = null }, .default = "~/Public" }, - .fonts = XdgFolderSpec{ .env = .{ .name = "XDG_DATA_HOME", .user_dir = false, .suffix = "/fonts" }, .default = "~/.local/share/fonts" }, - .app_menu = XdgFolderSpec{ .env = .{ .name = "XDG_DATA_HOME", .user_dir = false, .suffix = "/applications" }, .default = "~/.local/share/applications" }, - .cache = XdgFolderSpec{ .env = .{ .name = "XDG_CACHE_HOME", .user_dir = false, .suffix = null }, .default = "~/.cache" }, - .roaming_configuration = XdgFolderSpec{ .env = .{ .name = "XDG_CONFIG_HOME", .user_dir = false, .suffix = null }, .default = "~/.config" }, - .local_configuration = XdgFolderSpec{ .env = .{ .name = "XDG_CONFIG_HOME", .user_dir = false, .suffix = null }, .default = "~/.config" }, - .global_configuration = XdgFolderSpec{ .env = .{ .name = "XDG_CONFIG_DIRS", .user_dir = false, .suffix = null }, .default = "/etc" }, - .data = XdgFolderSpec{ .env = .{ .name = "XDG_DATA_HOME", .user_dir = false, .suffix = null }, .default = "~/.local/share" }, - .runtime = XdgFolderSpec{ .env = .{ .name = "XDG_RUNTIME_DIR", .user_dir = false, .suffix = null }, .default = null }, -}; + try testGetPath(.{ + .system = system, + .folder = .home, + .expected = null, + }); -const mac_folder_spec = KnownFolderSpec(MacFolderSpec){ - .home = MacFolderSpec{ .suffix = null }, - .documents = MacFolderSpec{ .suffix = "Documents" }, - .pictures = MacFolderSpec{ .suffix = "Pictures" }, - .music = MacFolderSpec{ .suffix = "Music" }, - .videos = MacFolderSpec{ .suffix = "Movies" }, - .desktop = MacFolderSpec{ .suffix = "Desktop" }, - .downloads = MacFolderSpec{ .suffix = "Downloads" }, - .public = MacFolderSpec{ .suffix = "Public" }, - .fonts = MacFolderSpec{ .suffix = "Library/Fonts" }, - .app_menu = MacFolderSpec{ .suffix = "Applications" }, - .cache = MacFolderSpec{ .suffix = "Library/Caches" }, - .roaming_configuration = MacFolderSpec{ .suffix = "Library/Preferences" }, - .local_configuration = MacFolderSpec{ .suffix = "Library/Application Support" }, - .global_configuration = MacFolderSpec{ .absolute = "/Library/Preferences" }, - .data = MacFolderSpec{ .suffix = "Library/Application Support" }, - .runtime = MacFolderSpec{ .suffix = "Library/Application Support" }, -}; + try testGetPath(.{ + .system = system, + .folder = .desktop, + .expected = "/home/janedoe/Desktop", + }); -// Ref decls -comptime { - _ = KnownFolder; - _ = Error; - _ = open; - _ = getPath; + try testGetPath(.{ + .system = system, + .folder = .downloads, + .expected = null, + }); +} + +test "getPath - find HOME without accessing XDG_CONFIG_HOME" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + + var system: TestingSystem = .{ + .config = .{ .xdg_on_mac = true }, + .env_map = &.{ + .{ .key = "HOME", .value = "/home/janedoe" }, + }, + }; + defer system.deinit(); + + try testGetPath(.{ + .system = system, + .folder = .home, + .expected = "/home/janedoe", + }); +} + +test "getPath - no user-dirs.dirs" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + + var system: TestingSystem = .{ + .config = .{ .xdg_on_mac = true }, + .env_map = &.{ + .{ .key = "HOME", .value = "/home/janedoe" }, + .{ .key = "XDG_CONFIG_HOME", .value = null }, + + .{ .key = "XDG_DESKTOP_DIR", .value = null }, + .{ .key = "XDG_DOWNLOAD_DIR", .value = "/mnt/drive/downloads" }, + .{ .key = "XDG_DATA_HOME", .value = null }, + }, + .files = &.{.{ .path = "/home/janedoe/.config/user-dirs.dirs", .data = null }}, + }; + defer system.deinit(); + + try testGetPath(.{ + .system = system, + .folder = .home, + .expected = "/home/janedoe", + }); + + try testGetPath(.{ + .system = system, + .folder = .desktop, + .expected = "/home/janedoe/Desktop", + }); + + try testGetPath(.{ + .system = system, + .folder = .downloads, + .expected = "/mnt/drive/downloads", + }); + + try testGetPath(.{ + .system = system, + .folder = .data, + .expected = "/home/janedoe/.local/share", + }); + + try testGetPath(.{ + .system = system, + .folder = .fonts, + .expected = "/home/janedoe/.local/share/fonts", + }); +} + +test "getPath - XDG_DATA_HOME with suffix" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + + var system: TestingSystem = .{ + .config = .{ .xdg_on_mac = true }, + .env_map = &.{ + .{ .key = "HOME", .value = "/home/janedoe" }, + .{ .key = "XDG_CONFIG_HOME", .value = null }, + + .{ .key = "XDG_DATA_HOME", .value = "/mnt/data" }, + }, + }; + defer system.deinit(); + + try testGetPath(.{ + .system = system, + .folder = .fonts, + .expected = "/mnt/data/fonts", + }); + + try testGetPath(.{ + .system = system, + .folder = .app_menu, + .expected = "/mnt/data/applications", + }); +} + +test "getPath - user-dirs.dirs" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + + var system: TestingSystem = .{ + .config = .{ .xdg_on_mac = true }, + .env_map = &.{ + .{ .key = "HOME", .value = "/home/janedoe" }, + .{ .key = "XDG_CONFIG_HOME", .value = null }, + + .{ .key = "XDG_DESKTOP_DIR", .value = null }, + .{ .key = "XDG_DOWNLOAD_DIR", .value = null }, + .{ .key = "XDG_DOCUMENTS_DIR", .value = null }, + }, + .files = &.{.{ + .path = "/home/janedoe/.config/user-dirs.dirs", + .data = + \\ + \\# M for Mini + \\W for Wumbo + \\ + \\XDG_DESKTOP_DIR="/mnt/home/Desktop" + \\ XDG_DOCUMENTS_DIR = "$HOME/xdg/Documents" + , + }}, + }; + defer system.deinit(); + + try testGetPath(.{ + .system = system, + .folder = .desktop, + .expected = "/mnt/home/Desktop", + }); + + try testGetPath(.{ + .system = system, + .folder = .downloads, + .expected = "/home/janedoe/Downloads", + }); + + try testGetPath(.{ + .system = system, + .folder = .documents, + .expected = "/home/janedoe/xdg/Documents", + }); +} + +test "getPath - user-dirs.dirs - XDG_CONFIG_HOME" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + + var system: TestingSystem = .{ + .config = .{ .xdg_on_mac = true }, + .env_map = &.{ + .{ .key = "HOME", .value = "/home/janedoe" }, + .{ .key = "XDG_CONFIG_HOME", .value = "/home/janedoe/custom" }, + + .{ .key = "XDG_DESKTOP_DIR", .value = null }, + }, + .files = &.{.{ + .path = "/home/janedoe/custom/user-dirs.dirs", + .data = + \\XDG_DESKTOP_DIR="/mnt/Desktop" + , + }}, + }; + defer system.deinit(); + + try testGetPath(.{ + .system = system, + .folder = .desktop, + .expected = "/mnt/Desktop", + }); +} + +test "getPath - user-dirs.dirs - duplicate config" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + + var system: TestingSystem = .{ + .config = .{ .xdg_on_mac = true }, + .env_map = &.{ + .{ .key = "HOME", .value = "/home/janedoe" }, + .{ .key = "XDG_CONFIG_HOME", .value = null }, + + .{ .key = "XDG_DESKTOP_DIR", .value = null }, + }, + .files = &.{.{ + .path = "/home/janedoe/.config/user-dirs.dirs", + .data = + \\XDG_DESKTOP_DIR="/mnt/first/Desktop" + \\XDG_DESKTOP_DIR="/mnt/second/Desktop" + , + }}, + }; + defer system.deinit(); + + try testGetPath(.{ + .system = system, + .folder = .desktop, + .expected = "/mnt/second/Desktop", + }); +} + +test "getPath - user-dirs.dirs - escaped path" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + + var system: TestingSystem = .{ + .config = .{ .xdg_on_mac = true }, + .env_map = &.{ + .{ .key = "HOME", .value = "/home/janedoe" }, + .{ .key = "XDG_CONFIG_HOME", .value = null }, + + .{ .key = "XDG_DESKTOP_DIR", .value = null }, + .{ .key = "XDG_DOWNLOAD_DIR", .value = null }, + }, + .files = &.{.{ + .path = "/home/janedoe/.config/user-dirs.dirs", + .data = + \\XDG_DESKTOP_DIR="/mnt/\\/Desktop" + \\XDG_DOWNLOAD_DIR="/mnt/\ /Desktop" + , + }}, + }; + defer system.deinit(); + + try testGetPath(.{ + .system = system, + .folder = .desktop, + .expected = "/mnt/\\/Desktop", + }); + + try testGetPath(.{ + .system = system, + .folder = .downloads, + .expected = "/mnt/ /Desktop", + }); +} + +test "getPath - user-dirs.dirs - very long line" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + + const len = "XDG_DESKTOP_DIR =\"\"".len; + const xdg_dir_desktop = "/" ++ "a" ** (xdg_user_dir_lookup_line_buffer_size - len - 1); + const xdg_dir_download = "/" ++ "b" ** (xdg_user_dir_lookup_line_buffer_size - len); + const xdg_dir_documents = "/" ++ "c" ** (xdg_user_dir_lookup_line_buffer_size - len + 1); + const xdg_dir_documents_truncated = "/" ++ "c" ** (xdg_user_dir_lookup_line_buffer_size - len); + + var system: TestingSystem = .{ + .config = .{ .xdg_on_mac = true }, + .env_map = &.{ + .{ .key = "HOME", .value = "/home/janedoe" }, + .{ .key = "XDG_CONFIG_HOME", .value = null }, + + .{ .key = "XDG_DESKTOP_DIR", .value = null }, + .{ .key = "XDG_DOWNLOAD_DIR", .value = null }, + .{ .key = "XDG_DOCUMENTS_DIR", .value = null }, + .{ .key = "XDG_MUSIC_DIR", .value = null }, + }, + .files = &.{.{ + .path = "/home/janedoe/.config/user-dirs.dirs", + .data = std.fmt.comptimePrint( + \\XDG_DESKTOP_DIR ="{s}" + \\XDG_DOWNLOAD_DIR ="{s}" + \\XDG_DOCUMENTS_DIR="{s}" + \\XDG_MUSIC_DIR="$HOME/music" + , .{ xdg_dir_desktop, xdg_dir_download, xdg_dir_documents }), + }}, + }; + defer system.deinit(); + + try testGetPath(.{ + .system = system, + .folder = .desktop, + .expected = xdg_dir_desktop, + }); + + try testGetPath(.{ + .system = system, + .folder = .downloads, + .expected = xdg_dir_download, + }); + + try testGetPath(.{ + .system = system, + .folder = .documents, + .expected = xdg_dir_documents_truncated, + }); + + try testGetPath(.{ + .system = system, + .folder = .music, + .expected = "/home/janedoe/music", + }); +} + +test "getPath - user-dirs.dirs - invalid path" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + + var system: TestingSystem = .{ + .config = .{ .xdg_on_mac = true }, + .env_map = &.{ + .{ .key = "HOME", .value = "/home/janedoe" }, + .{ .key = "XDG_CONFIG_HOME", .value = null }, + + .{ .key = "XDG_DESKTOP_DIR", .value = null }, + }, + .files = &.{.{ + .path = "/home/janedoe/.config/user-dirs.dirs", + .data = + \\XDG_DESKTOP_DIR="non/absolute/path" + , + }}, + }; + defer system.deinit(); + + try testGetPath(.{ + .system = system, + .folder = .desktop, + .expected = "/home/janedoe/Desktop", // fallback + }); +} + +test "getPath - .global_configuration" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + + var system: TestingSystem = .{ + .config = .{ .xdg_on_mac = true }, + .env_map = &.{ + .{ .key = "XDG_CONFIG_DIRS", .value = null }, + }, + }; + defer system.deinit(); + + try testGetPath(.{ + .system = system, + .folder = .global_configuration, + .expected = "/etc", + }); +} + +test "getPath - .global_configuration with XDG_CONFIG_DIRS" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + + var system: TestingSystem = .{ + .config = .{ .xdg_on_mac = true }, + .env_map = &.{ + .{ .key = "XDG_CONFIG_DIRS", .value = "/mnt/etc" }, + }, + }; + defer system.deinit(); + + try testGetPath(.{ + .system = system, + .folder = .global_configuration, + .expected = "/mnt/etc", + }); + + system.env_map = &.{ + .{ .key = "XDG_CONFIG_DIRS", .value = "/mnt/first:/mnt/second" }, + }; + try testGetPath(.{ + .system = system, + .folder = .global_configuration, + .expected = "/mnt/first", + }); +} + +test "getPath - with xdg_force_default=true" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + + var system: TestingSystem = .{ + .config = .{ .xdg_on_mac = true, .xdg_force_default = true }, + .env_map = &.{ + .{ .key = "HOME", .value = "/home/janedoe" }, + + // The fact that the following line wasn't necessary proves that "XDG_DESKTOP_DIR" was never accessed. + // .{ .key = "XDG_DESKTOP_DIR", .value = null }, + }, + }; + defer system.deinit(); + + try testGetPath(.{ + .system = system, + .folder = .home, + .expected = "/home/janedoe", + }); + + try testGetPath(.{ + .system = system, + .folder = .desktop, + .expected = "/home/janedoe/Desktop", + }); + + try testGetPath(.{ + .system = system, + .folder = .global_configuration, + .expected = "/etc", + }); +} + +test "getPath - with xdg_on_mac=false" { + if (!builtin.os.tag.isDarwin()) return error.SkipZigTest; + + var system: TestingSystem = .{ + .config = .{ .xdg_on_mac = false }, + .env_map = &.{ + .{ .key = "HOME", .value = "/home/janedoe" }, + }, + }; + defer system.deinit(); + + try testGetPath(.{ + .system = system, + .folder = .desktop, + .expected = "/home/janedoe/Desktop", + }); + + try testGetPath(.{ + .system = system, + .folder = .fonts, + .expected = "/home/janedoe/Library/Fonts", + }); + + try testGetPath(.{ + .system = system, + .folder = .videos, + .expected = "/home/janedoe/Movies", + }); +} + +test "getPath - .global_configuration with xdg_on_mac=false" { + if (!builtin.os.tag.isDarwin()) return error.SkipZigTest; + + var system: TestingSystem = .{ + .config = .{ .xdg_on_mac = false }, + .env_map = &.{ + // The fact that the following line wasn't necessary proves that "HOME" was never accessed. + // .{ .key = "HOME", .value = "/home/janedoe" }, + }, + }; + defer system.deinit(); + + try testGetPath(.{ + .system = system, + .folder = .global_configuration, + .expected = "/Library/Preferences", + }); } test "query each known folders" { - inline for (std.meta.fields(KnownFolder)) |fld| { - const path_or_null = try getPath(std.testing.allocator, @field(KnownFolder, fld.name)); - if (path_or_null) |path| { - // TODO: Remove later - std.debug.print("{s} => '{s}'\n", .{ fld.name, path }); - std.testing.allocator.free(path); - } + for (std.meta.tags(KnownFolder)) |folder| { + const path_or_null = try getPath(std.testing.allocator, folder); + defer if (path_or_null) |path| std.testing.allocator.free(path); + std.debug.print("{s} => {?s}\n", .{ @tagName(folder), path_or_null }); } } test "open each known folders" { - inline for (std.meta.fields(KnownFolder)) |fld| { - var dir_or_null = open(std.testing.allocator, @field(KnownFolder, fld.name), .{ .access_sub_paths = true }) catch |e| switch (e) { + if (builtin.os.tag == .wasi) return error.SkipZigTest; + + for (std.meta.tags(KnownFolder)) |folder| { + var dir_or_null = open(std.testing.allocator, folder, .{}) catch |e| switch (e) { error.FileNotFound => return, else => return e, }; @@ -361,3 +1169,7 @@ test "open each known folders" { } } } + +comptime { + std.testing.refAllDeclsRecursive(@This()); +}