//! 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); }