summaryrefslogtreecommitdiff
path: root/src/user_config.zig
blob: 15253ab2df6d3825f99bec573112b89867579977 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
//! Native startup configuration discovery and loading.
//!
//! `pardes` is a directory inside the platform's per-user configuration
//! directory; its startup command file is `pardes/init`. Discovery is
//! deliberately launcher-owned: native
//! launchers opt in by putting the bytes in `Options.startup_config`, while
//! tests and the browser keep the default `null`.

const std = @import("std");
const builtin = @import("builtin");

const max_bytes = 1024 * 1024;

pub const init_name = "init";
pub const builtin_themes_subdir = "themes/builtin";

/// Resolve the native per-user config directory with stdlib environment and path
/// APIs. XDG_CONFIG_HOME is accepted only when absolute, as required by the
/// XDG base-directory specification; an empty/relative value falls back.
pub fn path(gpa: std.mem.Allocator, env: *const std.process.Environ.Map) !?[]u8 {
    if (builtin.os.tag == .windows) {
        if (nonEmpty(env.get("LOCALAPPDATA"))) |base|
            return try std.fs.path.join(gpa, &.{ base, "pardes" });
        if (nonEmpty(env.get("USERPROFILE"))) |home|
            return try std.fs.path.join(gpa, &.{ home, "AppData", "Local", "pardes" });
        return null;
    }

    if (xdgBase(env)) |base|
        return try std.fs.path.join(gpa, &.{ base, "pardes" });

    const home = nonEmpty(env.get("HOME")) orelse return null;
    if (builtin.os.tag == .macos)
        return try std.fs.path.join(gpa, &.{ home, "Library", "Application Support", "pardes" });
    return try std.fs.path.join(gpa, &.{ home, ".config", "pardes" });
}

/// The config directory and its init file: WHERE they were looked for, and
/// what was there. Missing,
/// unreadable, oversized, or otherwise unusable config is simply no config —
/// but the path resolves either way, because "nothing is there yet" is the
/// answer the Config builtin exists to give and a null would erase it. The
/// arena passed by the launcher owns every returned slice for the process.
pub const Found = struct {
    dir: ?[]const u8 = null,
    path: ?[]const u8 = null,
    bytes: ?[]const u8 = null,
};

pub fn load(
    io: std.Io,
    gpa: std.mem.Allocator,
    env: *const std.process.Environ.Map,
) Found {
    const config_dir = (path(gpa, env) catch return .{}) orelse return .{};
    const config_path = std.fs.path.join(gpa, &.{ config_dir, init_name }) catch return .{ .dir = config_dir };
    return .{
        .dir = config_dir,
        .path = config_path,
        .bytes = std.Io.Dir.cwd().readFileAlloc(io, config_path, gpa, .limited(max_bytes)) catch null,
    };
}

fn nonEmpty(value: ?[]const u8) ?[]const u8 {
    const v = value orelse return null;
    return if (v.len == 0) null else v;
}

fn xdgBase(env: *const std.process.Environ.Map) ?[]const u8 {
    const value = nonEmpty(env.get("XDG_CONFIG_HOME")) orelse return null;
    return if (std.fs.path.isAbsolute(value)) value else null;
}

test "config path honors XDG and rejects a relative XDG directory" {
    if (builtin.os.tag == .windows) return;

    var env: std.process.Environ.Map = .init(std.testing.allocator);
    defer env.deinit();
    try env.put("HOME", "/home/pardes-test");
    try env.put("XDG_CONFIG_HOME", "/var/tmp/pardes-xdg");

    const xdg = (try path(std.testing.allocator, &env)).?;
    defer std.testing.allocator.free(xdg);
    try std.testing.expectEqualStrings("/var/tmp/pardes-xdg/pardes", xdg);

    try env.put("XDG_CONFIG_HOME", "relative/config");
    const fallback = (try path(std.testing.allocator, &env)).?;
    defer std.testing.allocator.free(fallback);
    const expected = if (builtin.os.tag == .macos)
        "/home/pardes-test/Library/Application Support/pardes"
    else
        "/home/pardes-test/.config/pardes";
    try std.testing.expectEqualStrings(expected, fallback);
}

test "config loader reads init inside the config directory" {
    if (builtin.os.tag == .windows) return;

    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    var base_buf: [std.fs.max_path_bytes]u8 = undefined;
    const base_len = try tmp.dir.realPath(std.testing.io, &base_buf);

    var env: std.process.Environ.Map = .init(std.testing.allocator);
    defer env.deinit();
    try env.put("XDG_CONFIG_HOME", base_buf[0..base_len]);

    const missing = load(std.testing.io, std.testing.allocator, &env);
    defer std.testing.allocator.free(missing.dir.?);
    defer std.testing.allocator.free(missing.path.?);
    const expected_dir = try std.fs.path.join(std.testing.allocator, &.{ base_buf[0..base_len], "pardes" });
    defer std.testing.allocator.free(expected_dir);
    const expected = try std.fs.path.join(std.testing.allocator, &.{ expected_dir, init_name });
    defer std.testing.allocator.free(expected);
    try std.testing.expectEqualStrings(expected_dir, missing.dir.?);
    try std.testing.expectEqualStrings(expected, missing.path.?);
    try std.testing.expect(missing.bytes == null);
    const source = "Theme dark\nUnknown command\nTheme acme\n";
    try tmp.dir.createDir(std.testing.io, "pardes", .default_dir);
    try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "pardes/init", .data = source });
    const found = load(std.testing.io, std.testing.allocator, &env);
    defer std.testing.allocator.free(found.dir.?);
    defer std.testing.allocator.free(found.path.?);
    defer std.testing.allocator.free(found.bytes.?);
    try std.testing.expectEqualStrings(expected_dir, found.dir.?);
    try std.testing.expectEqualStrings(source, found.bytes.?);
}

/// Write one editable `.zon` file per compiled theme. The caller supplies the
/// ring so this filesystem-only module does not import the core. Existing
/// generated copies are replaced; unrelated files in the directory are left
/// alone, which lets a user keep custom themes beside the reference set.
pub fn dumpThemes(
    io: std.Io,
    gpa: std.mem.Allocator,
    config_dir: []const u8,
    theme_values: anytype,
) ![]u8 {
    const out_dir = try std.fs.path.join(gpa, &.{ config_dir, builtin_themes_subdir });
    errdefer gpa.free(out_dir);
    try std.Io.Dir.cwd().createDirPath(io, out_dir);

    for (theme_values) |theme_value| {
        var encoded: std.Io.Writer.Allocating = .init(gpa);
        defer encoded.deinit();
        try std.zon.stringify.serialize(theme_value, .{ .whitespace = true }, &encoded.writer);

        const filename = try std.fmt.allocPrint(gpa, "{s}.zon", .{theme_value.name});
        defer gpa.free(filename);
        const output_path = try std.fs.path.join(gpa, &.{ out_dir, filename });
        defer gpa.free(output_path);
        try std.Io.Dir.cwd().writeFile(io, .{
            .sub_path = output_path,
            .data = encoded.written(),
        });
    }
    return out_dir;
}

test "theme dump creates the builtin subdirectory and ZON files" {
    const io = std.testing.io;
    const gpa = std.testing.allocator;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    var base_buf: [std.fs.max_path_bytes]u8 = undefined;
    const base_len = try tmp.dir.realPath(io, &base_buf);
    const Sample = struct { name: []const u8, rgb: [3]u8 };
    const samples = [_]Sample{
        .{ .name = "one", .rgb = .{ 1, 2, 3 } },
        .{ .name = "two", .rgb = .{ 4, 5, 6 } },
    };
    const output = try dumpThemes(io, gpa, base_buf[0..base_len], &samples);
    defer gpa.free(output);
    const expected = try std.fs.path.join(gpa, &.{ base_buf[0..base_len], builtin_themes_subdir });
    defer gpa.free(expected);
    try std.testing.expectEqualStrings(expected, output);

    const one_path = try std.fs.path.join(gpa, &.{ output, "one.zon" });
    defer gpa.free(one_path);
    const bytes = try std.Io.Dir.cwd().readFileAlloc(io, one_path, gpa, .limited(4096));
    defer gpa.free(bytes);
    const source = try gpa.dupeZ(u8, bytes);
    defer gpa.free(source);
    const parsed = try std.zon.parse.fromSliceAlloc(Sample, gpa, source, null, .{});
    defer std.zon.parse.free(gpa, parsed);
    try std.testing.expectEqualStrings("one", parsed.name);
    try std.testing.expectEqual([3]u8{ 1, 2, 3 }, parsed.rgb);
}