//! What the app keeps between runs: one small file per name in //! $XDG_STATE_HOME/parcel, or ~/.local/state/parcel. It is state the UI //! changes, such as the list's filters, not configuration someone edits. //! The tests pass none, so they start from the defaults and write nothing. const std = @import("std"); const Io = std.Io; const Environ = std.process.Environ; const mem = std.mem; const log = std.log.scoped(.settings); const Settings = @This(); io: Io, dir: Io.Dir, /// Opens the directory, creating it. Null, logged, when there is no home /// directory to put it in or it cannot be made. pub fn open(io: Io, environ: *const Environ.Map) ?Settings { var buf: [Io.Dir.max_path_bytes]u8 = undefined; const path = if (environ.get("XDG_STATE_HOME")) |state| mem.print(&buf, "{s}/parcel", .{state}) catch return null else if (environ.get("HOME")) |home| mem.print(&buf, "{s}/.local/state/parcel", .{home}) catch return null else { log.warn("neither XDG_STATE_HOME nor HOME is set; nothing will be saved", .{}); return null; }; const dir = Io.Dir.cwd().createDirPathOpen(io, path, .{}) catch |err| { log.warn("{s}: {t}; nothing will be saved", .{ path, err }); return null; }; return .{ .io = io, .dir = dir }; } pub fn close(self: Settings) void { self.dir.close(self.io); } /// The directory under this one that holds an account's directory each. pub const accounts_dir = "accounts"; /// Account `name`'s own settings, in a directory of its own under /// `accounts_dir`, created. Null, logged, when it cannot be made. pub fn account(self: Settings, name: []const u8) ?Settings { var buf: [Io.Dir.max_path_bytes]u8 = undefined; const path = mem.print(&buf, "{s}/{s}", .{ accounts_dir, name }) catch return null; const dir = self.dir.createDirPathOpen(self.io, path, .{}) catch |err| { log.warn("{s}: {t}; nothing of it will be saved", .{ path, err }); return null; }; return .{ .io = self.io, .dir = dir }; } /// The accounts' names, sorted, which is the order Ctrl and a digit /// counts them in: each directory under `accounts_dir`. pub fn accounts(self: Settings, arena: mem.Allocator) ![]const []const u8 { var dir = self.dir.openDir(self.io, accounts_dir, .{ .iterate = true }) catch |err| return switch (err) { error.FileNotFound => &.{}, else => err, }; defer dir.close(self.io); var names: std.ArrayList([]const u8) = .empty; var it = dir.iterate(); while (try it.next(self.io)) |entry| { if (entry.kind != .directory or entry.name[0] == '.') continue; try names.append(arena, try arena.dupe(u8, entry.name)); } mem.sort([]const u8, names.items, {}, lessThan); return names.items; } fn lessThan(_: void, a: []const u8, b: []const u8) bool { return mem.order(u8, a, b) == .lt; } /// Moves `files` from this directory to account `name`'s, as when the /// one account parcel had before becomes the first of several. Those /// missing are skipped. pub fn moveTo(self: Settings, name: []const u8, files: []const []const u8) !void { const to = self.account(name) orelse return error.NoAccountDirectory; defer to.close(); for (files) |file| { Io.Dir.rename(self.dir, file, to.dir, file, self.io) catch |err| switch (err) { error.FileNotFound => {}, else => return err, }; } } /// What was saved under `name`, read into `buf`; null if nothing was. pub fn load(self: Settings, name: []const u8, buf: []u8) ?[]u8 { return self.dir.readFile(self.io, name, buf) catch |err| { if (err != error.FileNotFound) log.warn("{s}: {t}", .{ name, err }); return null; }; } /// What was saved under `name`, up to `limit`, which the caller frees; /// null if nothing was. pub fn loadAlloc(self: Settings, name: []const u8, gpa: mem.Allocator, limit: Io.Limit) ?[]u8 { return self.dir.readFileAlloc(self.io, name, gpa, limit) catch |err| { if (err != error.FileNotFound) log.warn("{s}: {t}", .{ name, err }); return null; }; } /// Forgets what was saved under `name`. pub fn remove(self: Settings, name: []const u8) void { self.dir.deleteFile(self.io, name) catch |err| { if (err != error.FileNotFound) log.warn("{s}: {t}", .{ name, err }); }; } /// Saves `bytes` under `name`, replacing the file whole so a crash midway /// leaves the old one. A failure is logged: losing a preference should not /// stop the app. pub fn save(self: Settings, name: []const u8, bytes: []const u8) void { write(self, name, bytes, .default_file) catch |err| log.warn("{s}: {t}", .{ name, err }); } /// Saves `bytes` only the user may read, such as a refresh token or /// their mail. A failure is returned for the caller to judge. pub fn saveSecret(self: Settings, name: []const u8, bytes: []const u8) !void { return write(self, name, bytes, @fromBackingInt(0o600)); } fn write(self: Settings, name: []const u8, bytes: []const u8, permissions: Io.File.Permissions) !void { var file = try self.dir.createFileAtomic(self.io, name, .{ .replace = true, .permissions = permissions }); defer file.deinit(self.io); try file.file.writeStreamingAll(self.io, bytes); try file.replace(self.io); }