//! Implementation of the XDG Base Directory specification //! (https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) const std = @import("std"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; const posix = std.posix; const homedir = @import("homedir.zig"); pub const Options = struct { /// Subdirectories to join to the base. This avoids extra allocations /// when building up the directory. This is commonly the application. subdir: ?[]const u8 = null, /// The home directory for the user. If this is not set, we will attempt /// to look it up which is an expensive process. By setting this, you can /// avoid lookups. home: ?[]const u8 = null, }; /// Get the XDG user config directory. The returned value is allocated. pub fn config(io: std.Io, alloc: Allocator, environ_map: *const std.process.Environ.Map, opts: Options) ![]u8 { return try dir(io, alloc, environ_map, opts, .{ .env = "XDG_CONFIG_HOME", .windows_env = "LOCALAPPDATA", .default_subdir = ".config", }); } /// Get the XDG cache directory. The returned value is allocated. pub fn cache(io: std.Io, alloc: Allocator, environ_map: *const std.process.Environ.Map, opts: Options) ![]u8 { return try dir(io, alloc, environ_map, opts, .{ .env = "XDG_CACHE_HOME", .windows_env = "LOCALAPPDATA", .default_subdir = ".cache", }); } /// Get the XDG state directory. The returned value is allocated. pub fn state(io: std.Io, alloc: Allocator, environ_map: *const std.process.Environ.Map, opts: Options) ![]u8 { return try dir(io, alloc, environ_map, opts, .{ .env = "XDG_STATE_HOME", .windows_env = "LOCALAPPDATA", .default_subdir = ".local/state", }); } const InternalOptions = struct { env: []const u8, windows_env: []const u8, default_subdir: []const u8, }; /// Unified helper to get XDG directories that follow a common pattern. fn dir( io: std.Io, alloc: Allocator, environ_map: *const std.process.Environ.Map, opts: Options, internal_opts: InternalOptions, ) ![]u8 { // If we have a cached home dir, use that. if (opts.home) |home| { return try std.fs.path.join(alloc, &[_][]const u8{ home, internal_opts.default_subdir, opts.subdir orelse "", }); } // First check the env var. On Windows we treat `LOCALAPPDATA` as a // fallback for `XDG_CONFIG_HOME` const env = switch (builtin.os.tag) { .windows => environ_map.get(internal_opts.env) orelse environ_map.get(internal_opts.windows_env) orelse "", else => environ_map.get(internal_opts.env) orelse "", }; if (env.len > 0) { // If we have a subdir, then we use the env as-is to avoid a copy. if (opts.subdir) |subdir| { return try std.fs.path.join(alloc, &[_][]const u8{ env, subdir, }); } return try alloc.dupe(u8, env); } // Get our home dir var buf: [1024]u8 = undefined; if (try homedir.home(io, environ_map, &buf)) |home| { return try std.fs.path.join(alloc, &[_][]const u8{ home, internal_opts.default_subdir, opts.subdir orelse "", }); } return error.NoHomeDir; } /// Parses the xdg-terminal-exec specification. This expects argv[0] to /// be "xdg-terminal-exec". pub fn parseTerminalExec(argv: []const [*:0]const u8) ?[]const [*:0]const u8 { if (!std.mem.eql( u8, std.fs.path.basename(std.mem.sliceTo(argv[0], 0)), "xdg-terminal-exec", )) return null; // We expect at least one argument if (argv.len < 2) return &.{}; // If the first argument is "-e" we skip it. const start: usize = if (std.mem.eql(u8, std.mem.sliceTo(argv[1], 0), "-e")) 2 else 1; return argv[start..]; } test { const testing = std.testing; const io = testing.io; const alloc = testing.allocator; var environ_map = try testing.environ.createMap(alloc); defer environ_map.deinit(); { const value = try config(io, alloc, &environ_map, .{}); defer alloc.free(value); try testing.expect(value.len > 0); } } test "cache directory paths" { const testing = std.testing; const io = testing.io; const alloc = testing.allocator; const mock_home = if (builtin.os.tag == .windows) "C:\\Users\\test" else "/Users/test"; var environ_map = try testing.environ.createMap(alloc); defer environ_map.deinit(); // Test when XDG_CACHE_HOME is not set { // Test base path { const cache_path = try cache(io, alloc, &environ_map, .{ .home = mock_home }); defer alloc.free(cache_path); const expected = try std.fs.path.join(alloc, &.{ mock_home, ".cache" }); defer alloc.free(expected); try testing.expectEqualStrings(expected, cache_path); } // Test with subdir { const cache_path = try cache(io, alloc, &environ_map, .{ .home = mock_home, .subdir = "ghostty", }); defer alloc.free(cache_path); const expected = try std.fs.path.join(alloc, &.{ mock_home, ".cache", "ghostty" }); defer alloc.free(expected); try testing.expectEqualStrings(expected, cache_path); } } } test "fallback when xdg env empty" { if (builtin.os.tag == .windows) return error.SkipZigTest; const io = std.testing.io; const alloc = std.testing.allocator; const DirCase = struct { name: [:0]const u8, func: fn (std.Io, Allocator, *std.process.Environ.Map, Options) anyerror![]u8, default_subdir: []const u8, }; const cases = [_]DirCase{ .{ .name = "XDG_CONFIG_HOME", .func = config, .default_subdir = ".config" }, .{ .name = "XDG_CACHE_HOME", .func = cache, .default_subdir = ".cache" }, .{ .name = "XDG_STATE_HOME", .func = state, .default_subdir = ".local/state" }, }; inline for (cases) |case| { var environ_map = try std.testing.environ.createMap(alloc); defer environ_map.deinit(); const temp_home = "/tmp/ghostty-test-home"; try environ_map.put("HOME", temp_home); const expected = try std.fs.path.join(alloc, &[_][]const u8{ temp_home, case.default_subdir, }); defer alloc.free(expected); // Test with empty string - should fallback to home try environ_map.put(case.name, ""); const actual = try case.func(io, alloc, &environ_map, .{}); defer alloc.free(actual); try std.testing.expectEqualStrings(expected, actual); } } test "fallback when xdg env empty and subdir" { if (builtin.os.tag == .windows) return error.SkipZigTest; const io = std.testing.io; const alloc = std.testing.allocator; const DirCase = struct { name: [:0]const u8, func: fn (std.Io, Allocator, *const std.process.Environ.Map, Options) anyerror![]u8, default_subdir: []const u8, }; const cases = [_]DirCase{ .{ .name = "XDG_CONFIG_HOME", .func = config, .default_subdir = ".config" }, .{ .name = "XDG_CACHE_HOME", .func = cache, .default_subdir = ".cache" }, .{ .name = "XDG_STATE_HOME", .func = state, .default_subdir = ".local/state" }, }; inline for (cases) |case| { var environ_map = try std.testing.environ.createMap(alloc); defer environ_map.deinit(); const temp_home = "/tmp/ghostty-test-home"; try environ_map.put("HOME", temp_home); const expected = try std.fs.path.join(alloc, &[_][]const u8{ temp_home, case.default_subdir, "ghostty", }); defer alloc.free(expected); // Test with empty string - should fallback to home try environ_map.put(case.name, ""); const actual = try case.func(io, alloc, &environ_map, .{ .subdir = "ghostty" }); defer alloc.free(actual); try std.testing.expectEqualStrings(expected, actual); } } test parseTerminalExec { const testing = std.testing; { const actual = parseTerminalExec(&.{ "a", "b", "c" }); try testing.expect(actual == null); } { const actual = parseTerminalExec(&.{"xdg-terminal-exec"}).?; try testing.expectEqualSlices([*:0]const u8, actual, &.{}); } { const actual = parseTerminalExec(&.{ "xdg-terminal-exec", "a", "b", "c" }).?; try testing.expectEqualSlices([*:0]const u8, actual, &.{ "a", "b", "c" }); } { const actual = parseTerminalExec(&.{ "xdg-terminal-exec", "-e", "a", "b", "c" }).?; try testing.expectEqualSlices([*:0]const u8, actual, &.{ "a", "b", "c" }); } { const actual = parseTerminalExec(&.{ "xdg-terminal-exec", "a", "-e", "b", "c" }).?; try testing.expectEqualSlices([*:0]const u8, actual, &.{ "a", "-e", "b", "c" }); } }