// SPDX-FileCopyrightText: © 2026 Francesc Muñoz // SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! The settings that survive a restart, in `$XDG_CONFIG_HOME/ziptop/config` //! (`~/.config/ziptop/config` when that is unset): plain `key = value` lines, //! so that it is easy to edit by hand, with `#` starting a comment. Reading //! and writing are best effort. A missing or unreadable file is the defaults, //! and a file that cannot be written is a setting that does not persist, //! never a reason not to start. const std = @import("std"); const Allocator = std.mem.Allocator; const Io = std.Io; /// The keys this program writes, in the order it writes them. const known_keys = [_][]const u8{"theme"}; pub const Config = struct { /// Every `key = value` in the file, including keys this version does not /// know, so that writing the file back does not lose them. values: std.StringArrayHashMapUnmanaged([]const u8) = .empty, pub fn get(self: *const Config, key: []const u8) ?[]const u8 { const v = self.values.get(key) orelse return null; return if (v.len == 0) null else v; } pub fn put(self: *Config, arena: Allocator, key: []const u8, value: []const u8) Allocator.Error!void { try self.values.put(arena, key, value); } }; pub fn parse(arena: Allocator, text: []const u8) Allocator.Error!Config { var config: Config = .{}; var lines = std.mem.splitScalar(u8, text, '\n'); while (lines.next()) |raw| { const line = std.mem.trim(u8, raw, " \t\r"); if (line.len == 0 or line[0] == '#') continue; const eq = std.mem.findScalar(u8, line, '=') orelse continue; const key = std.mem.trim(u8, line[0..eq], " \t"); if (key.len == 0) continue; try config.values.put(arena, key, std.mem.trim(u8, line[eq + 1 ..], " \t")); } return config; } fn isKnown(key: []const u8) bool { for (known_keys) |k| if (std.mem.eql(u8, k, key)) return true; return false; } /// The known keys first, in a stable order, then whatever else the file had. pub fn serialize(config: *const Config, w: *Io.Writer) Io.Writer.Error!void { try w.writeAll("# ziptop config -- edit by hand, or with the theme picker (t)\n"); for (known_keys) |key| { const value = config.get(key) orelse continue; try w.print("{s} = {s}\n", .{ key, value }); } for (config.values.keys(), config.values.values()) |key, value| { if (isKnown(key) or value.len == 0) continue; try w.print("{s} = {s}\n", .{ key, value }); } } /// The directory the file lives in. pub fn directory(arena: Allocator, environ: *const std.process.Environ.Map) Allocator.Error!?[]const u8 { if (environ.get("XDG_CONFIG_HOME")) |xdg| { if (xdg.len > 0) return try std.fs.path.join(arena, &.{ xdg, "ziptop" }); } const home = environ.get("HOME") orelse return null; return try std.fs.path.join(arena, &.{ home, ".config", "ziptop" }); } pub fn load(arena: Allocator, io: Io, dir_path: ?[]const u8) Config { const dir = dir_path orelse return .{}; const path = std.fs.path.join(arena, &.{ dir, "config" }) catch return .{}; const text = Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(1 << 20)) catch return .{}; return parse(arena, text) catch .{}; } /// Writes `key = value` into the file, keeping everything else it has. pub fn save(gpa: Allocator, io: Io, dir_path: ?[]const u8, key: []const u8, value: []const u8) void { var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); const a = arena.allocator(); const dir = dir_path orelse return; var config = load(a, io, dir); config.put(a, key, value) catch return; var out: Io.Writer.Allocating = .init(a); serialize(&config, &out.writer) catch return; const cwd = Io.Dir.cwd(); cwd.createDirPath(io, dir) catch return; const path = std.fs.path.join(a, &.{ dir, "config" }) catch return; cwd.writeFile(io, .{ .sub_path = path, .data = out.written() }) catch return; } const testing = std.testing; test "parse skips comments, blank lines and lines without a value" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const config = try parse(arena.allocator(), \\# a comment \\ \\theme = Nord \\nonsense \\ extra= kept ); try testing.expectEqualStrings("Nord", config.get("theme").?); try testing.expectEqualStrings("kept", config.get("extra").?); try testing.expect(config.get("nonsense") == null); } test "serialize writes the known keys first and keeps the rest" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var config = try parse(arena.allocator(), "extra = kept\ntheme = Nord\n"); try config.put(arena.allocator(), "theme", "Dracula"); var out: Io.Writer.Allocating = .init(arena.allocator()); try serialize(&config, &out.writer); try testing.expectEqualStrings( \\# ziptop config -- edit by hand, or with the theme picker (t) \\theme = Dracula \\extra = kept \\ , out.written()); } test "save and load round trip" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const base = try tmp.dir.realPathFileAlloc(testing.io, ".", arena.allocator()); const dir = try std.fs.path.join(arena.allocator(), &.{ base, "ziptop" }); try testing.expect(load(arena.allocator(), testing.io, dir).get("theme") == null); save(testing.allocator, testing.io, dir, "theme", "Gruvbox Dark"); try testing.expectEqualStrings("Gruvbox Dark", load(arena.allocator(), testing.io, dir).get("theme").?); }