magdalena

interactive shell navigation and history
Log | Files | Refs | README | LICENSE

db.zig (14881B)


      1 const std = @import("std");
      2 const zul = @import("zul");
      3 const io = @import("io.zig");
      4 
      5 pub const DirectoryEntry = struct {
      6     path: []const u8,
      7     timestamp: ?[]const u8 = null,
      8 };
      9 
     10 pub const FileEntry = struct {
     11     path: []const u8,
     12     file_type: []const u8,
     13     action: []const u8,
     14     timestamp: ?[]const u8 = null,
     15 };
     16 
     17 pub const SearchResult = struct {
     18     directories: []DirectoryEntry,
     19     files: []FileEntry,
     20 };
     21 
     22 pub const Db = struct {
     23     allocator: std.mem.Allocator,
     24     base_path: []const u8,
     25     dirs_path: []const u8,
     26     files_path: []const u8,
     27 
     28     pub fn init(allocator: std.mem.Allocator, path: []const u8) !Db {
     29         const base_path = try allocator.dupe(u8, path);
     30         errdefer allocator.free(base_path);
     31 
     32         const dirs_path = try std.fs.path.join(allocator, &.{ base_path, "dirs.log" });
     33         errdefer allocator.free(dirs_path);
     34 
     35         const files_path = try std.fs.path.join(allocator, &.{ base_path, "files.log" });
     36         errdefer allocator.free(files_path);
     37 
     38         const res = Db{
     39             .allocator = allocator,
     40             .base_path = base_path,
     41             .dirs_path = dirs_path,
     42             .files_path = files_path,
     43         };
     44 
     45         std.Io.Dir.cwd().createDirPath(io.rt(), base_path) catch |err| {
     46             if (err != error.PathAlreadyExists) return err;
     47         };
     48 
     49         for (&[_][]const u8{ dirs_path, files_path }) |p| {
     50             var f = std.Io.Dir.cwd().openFile(io.rt(), p, .{ .mode = .read_write }) catch |err| {
     51                 if (err == error.FileNotFound) {
     52                     const created = try std.Io.Dir.cwd().createFile(io.rt(), p, .{ .truncate = false });
     53                     created.close(io.rt());
     54                     continue;
     55                 }
     56                 return err;
     57             };
     58             f.close(io.rt());
     59         }
     60 
     61         return res;
     62     }
     63 
     64     pub fn deinit(self: *Db) void {
     65         self.allocator.free(self.base_path);
     66         self.allocator.free(self.dirs_path);
     67         self.allocator.free(self.files_path);
     68     }
     69 
     70     pub fn recentDirs(self: *Db) !zul.Managed([]DirectoryEntry) {
     71         const content = std.Io.Dir.cwd().readFileAlloc(io.rt(), self.dirs_path, self.allocator, .limited(64 * 1024 * 1024)) catch |err| {
     72             if (err == error.FileNotFound) return zul.Managed([]DirectoryEntry).fromJson(try std.json.parseFromSlice([]DirectoryEntry, self.allocator, "[]", .{}));
     73             return err;
     74         };
     75         defer self.allocator.free(content);
     76 
     77         var arena = try self.allocator.create(std.heap.ArenaAllocator);
     78         errdefer {
     79             arena.deinit();
     80             self.allocator.destroy(arena);
     81         }
     82         arena.* = std.heap.ArenaAllocator.init(self.allocator);
     83 
     84         return .{
     85             .arena = arena,
     86             .value = try parseDirLog(arena.allocator(), content),
     87         };
     88     }
     89 
     90     pub fn recentFiles(self: *Db) !zul.Managed([]FileEntry) {
     91         const content = std.Io.Dir.cwd().readFileAlloc(io.rt(), self.files_path, self.allocator, .limited(64 * 1024 * 1024)) catch |err| {
     92             if (err == error.FileNotFound) return zul.Managed([]FileEntry).fromJson(try std.json.parseFromSlice([]FileEntry, self.allocator, "[]", .{}));
     93             return err;
     94         };
     95         defer self.allocator.free(content);
     96 
     97         var arena = try self.allocator.create(std.heap.ArenaAllocator);
     98         errdefer {
     99             arena.deinit();
    100             self.allocator.destroy(arena);
    101         }
    102         arena.* = std.heap.ArenaAllocator.init(self.allocator);
    103 
    104         return .{
    105             .arena = arena,
    106             .value = try parseFileLog(arena.allocator(), content),
    107         };
    108     }
    109 
    110     pub fn searchHistory(self: *Db, query_str: []const u8) !zul.Managed(SearchResult) {
    111         const managed_dirs = try self.recentDirs();
    112         defer managed_dirs.deinit();
    113         const managed_files = try self.recentFiles();
    114         defer managed_files.deinit();
    115 
    116         var arena = try self.allocator.create(std.heap.ArenaAllocator);
    117         errdefer {
    118             arena.deinit();
    119             self.allocator.destroy(arena);
    120         }
    121         arena.* = std.heap.ArenaAllocator.init(self.allocator);
    122         const allocator = arena.allocator();
    123 
    124         var dir_list = std.ArrayListUnmanaged(DirectoryEntry).empty;
    125         for (managed_dirs.value) |d| {
    126             if (std.mem.indexOf(u8, d.path, query_str) != null) {
    127                 try dir_list.append(allocator, .{
    128                     .path = try allocator.dupe(u8, d.path),
    129                     .timestamp = if (d.timestamp) |ts| try allocator.dupe(u8, ts) else null,
    130                 });
    131             }
    132         }
    133 
    134         var file_list = std.ArrayListUnmanaged(FileEntry).empty;
    135         for (managed_files.value) |f| {
    136             if (std.mem.indexOf(u8, f.path, query_str) != null) {
    137                 try file_list.append(allocator, .{
    138                     .path = try allocator.dupe(u8, f.path),
    139                     .file_type = try allocator.dupe(u8, f.file_type),
    140                     .action = try allocator.dupe(u8, f.action),
    141                     .timestamp = if (f.timestamp) |ts| try allocator.dupe(u8, ts) else null,
    142                 });
    143             }
    144         }
    145 
    146         return .{
    147             .arena = arena,
    148             .value = .{
    149                 .directories = try dir_list.toOwnedSlice(allocator),
    150                 .files = try file_list.toOwnedSlice(allocator),
    151             },
    152         };
    153     }
    154 
    155     fn resolvePath(self: *Db, path: []const u8) ![]const u8 {
    156         return std.Io.Dir.cwd().realPathFileAlloc(io.rt(), path, self.allocator) catch {
    157             return try std.fs.path.resolve(self.allocator, &[_][]const u8{path});
    158         };
    159     }
    160 
    161     pub fn logDir(self: *Db, dir_path: []const u8) !void {
    162         const abs_path = try self.resolvePath(dir_path);
    163         defer self.allocator.free(abs_path);
    164 
    165         const file = std.Io.Dir.cwd().openFile(io.rt(), self.dirs_path, .{ .mode = .write_only }) catch |err| switch (err) {
    166             error.FileNotFound => try std.Io.Dir.cwd().createFile(io.rt(), self.dirs_path, .{ .truncate = false }),
    167             else => return err,
    168         };
    169         defer file.close(io.rt());
    170 
    171         const now = zul.DateTime.now(io.rt());
    172         var ts_buf: [64]u8 = undefined;
    173         var ts_writer = std.Io.Writer.fixed(&ts_buf);
    174         try now.format(&ts_writer);
    175         const ts = ts_buf[0..ts_writer.end];
    176 
    177         var fbuf: [4096]u8 = undefined;
    178         var fw = file.writer(io.rt(), &fbuf);
    179         try fw.seekTo(try file.length(io.rt()));
    180         try fw.interface.print("{s}|{s}\n", .{ ts, abs_path });
    181         try fw.interface.flush();
    182     }
    183 
    184     pub fn logFile(self: *Db, file_path: []const u8, file_type: []const u8, action: []const u8) !void {
    185         const abs_path = try self.resolvePath(file_path);
    186         defer self.allocator.free(abs_path);
    187 
    188         const file = std.Io.Dir.cwd().openFile(io.rt(), self.files_path, .{ .mode = .write_only }) catch |err| switch (err) {
    189             error.FileNotFound => try std.Io.Dir.cwd().createFile(io.rt(), self.files_path, .{ .truncate = false }),
    190             else => return err,
    191         };
    192         defer file.close(io.rt());
    193 
    194         const now = zul.DateTime.now(io.rt());
    195         var ts_buf: [64]u8 = undefined;
    196         var ts_writer = std.Io.Writer.fixed(&ts_buf);
    197         try now.format(&ts_writer);
    198         const ts = ts_buf[0..ts_writer.end];
    199 
    200         var fbuf: [4096]u8 = undefined;
    201         var fw = file.writer(io.rt(), &fbuf);
    202         try fw.seekTo(try file.length(io.rt()));
    203         try fw.interface.print("{s}|{s}|{s}|{s}\n", .{ ts, file_type, action, abs_path });
    204         try fw.interface.flush();
    205     }
    206 
    207     pub fn cleanup(self: *Db) !void {
    208         try self.cleanupFile(self.dirs_path, 1);
    209         try self.cleanupFile(self.files_path, 3);
    210     }
    211 
    212     fn cleanupFile(self: *Db, path: []const u8, path_col_idx: usize) !void {
    213         io.warn("Cleaning up {s}...\n", .{path});
    214 
    215         const content = try std.Io.Dir.cwd().readFileAlloc(io.rt(), path, self.allocator, .limited(64 * 1024 * 1024));
    216         defer self.allocator.free(content);
    217 
    218         var seen = std.StringHashMap(void).init(self.allocator);
    219         defer seen.deinit();
    220 
    221         var lines = std.ArrayListUnmanaged([]const u8).empty;
    222         defer lines.deinit(self.allocator);
    223 
    224         var total_entries: usize = 0;
    225         var missing_entries: usize = 0;
    226         var it = std.mem.splitBackwardsScalar(u8, content, '\n');
    227         while (it.next()) |line| {
    228             const trimmed = std.mem.trim(u8, line, " \r\t");
    229             if (trimmed.len == 0) continue;
    230             total_entries += 1;
    231 
    232             var line_it = std.mem.splitScalar(u8, trimmed, '|');
    233             var item_path: ?[]const u8 = null;
    234             var i: usize = 0;
    235             while (line_it.next()) |col| : (i += 1) {
    236                 if (i == path_col_idx) {
    237                     item_path = col;
    238                     break;
    239                 }
    240             }
    241 
    242             const p = item_path orelse continue;
    243 
    244             if (seen.contains(p)) continue;
    245 
    246             std.Io.Dir.cwd().access(io.rt(), p, .{}) catch |err| {
    247                 if (err == error.FileNotFound) {
    248                     missing_entries += 1;
    249                     continue;
    250                 }
    251                 return err;
    252             };
    253 
    254             try seen.put(p, {});
    255             try lines.append(self.allocator, trimmed);
    256         }
    257 
    258         const duplicates = total_entries - lines.items.len - missing_entries;
    259         io.warn("  Entries: {d} total, {d} unique, {d} missing, {d} duplicates removed\n", .{ total_entries, lines.items.len, missing_entries, duplicates });
    260 
    261         const write_file = try std.Io.Dir.cwd().createFile(io.rt(), path, .{ .truncate = true });
    262         defer write_file.close(io.rt());
    263 
    264         var write_buf: [4096]u8 = undefined;
    265         var writer = write_file.writer(io.rt(), &write_buf);
    266 
    267         var i: usize = lines.items.len;
    268         while (i > 0) {
    269             i -= 1;
    270             try writer.interface.print("{s}\n", .{lines.items[i]});
    271         }
    272         try writer.end();
    273     }
    274 };
    275 
    276 pub fn getDefaultDbPath(allocator: std.mem.Allocator) ![]const u8 {
    277     if (io.getenv("HOME")) |home| {
    278         return std.fs.path.join(allocator, &.{ home, ".magdalena" });
    279     }
    280     return error.HomeNotFound;
    281 }
    282 
    283 // Parses a `dirs.log` body (`timestamp|path` lines) newest-first, keeping only
    284 // the first occurrence of each path. Pure logic, no IO — split out for testing.
    285 fn parseDirLog(allocator: std.mem.Allocator, content: []const u8) ![]DirectoryEntry {
    286     var list = std.ArrayListUnmanaged(DirectoryEntry).empty;
    287     var seen = std.StringHashMap(void).init(allocator);
    288     defer seen.deinit();
    289 
    290     var it = std.mem.splitBackwardsScalar(u8, content, '\n');
    291     while (it.next()) |line| {
    292         const trimmed = std.mem.trim(u8, line, " \r\t");
    293         if (trimmed.len == 0) continue;
    294 
    295         var line_it = std.mem.splitScalar(u8, trimmed, '|');
    296         const ts = line_it.next() orelse continue;
    297         const dir_path = line_it.next() orelse continue;
    298 
    299         if (seen.contains(dir_path)) continue;
    300         try seen.put(dir_path, {});
    301 
    302         try list.append(allocator, .{
    303             .path = try allocator.dupe(u8, dir_path),
    304             .timestamp = try allocator.dupe(u8, ts),
    305         });
    306     }
    307 
    308     return list.toOwnedSlice(allocator);
    309 }
    310 
    311 // Parses a `files.log` body (`timestamp|type|action|path` lines) newest-first,
    312 // keeping only the first occurrence of each path.
    313 fn parseFileLog(allocator: std.mem.Allocator, content: []const u8) ![]FileEntry {
    314     var list = std.ArrayListUnmanaged(FileEntry).empty;
    315     var seen = std.StringHashMap(void).init(allocator);
    316     defer seen.deinit();
    317 
    318     var it = std.mem.splitBackwardsScalar(u8, content, '\n');
    319     while (it.next()) |line| {
    320         const trimmed = std.mem.trim(u8, line, " \r\t");
    321         if (trimmed.len == 0) continue;
    322 
    323         var line_it = std.mem.splitScalar(u8, trimmed, '|');
    324         const ts = line_it.next() orelse continue;
    325         const ftype = line_it.next() orelse continue;
    326         const action = line_it.next() orelse continue;
    327         const fpath = line_it.next() orelse continue;
    328 
    329         if (seen.contains(fpath)) continue;
    330         try seen.put(fpath, {});
    331 
    332         try list.append(allocator, .{
    333             .path = try allocator.dupe(u8, fpath),
    334             .file_type = try allocator.dupe(u8, ftype),
    335             .action = try allocator.dupe(u8, action),
    336             .timestamp = try allocator.dupe(u8, ts),
    337         });
    338     }
    339 
    340     return list.toOwnedSlice(allocator);
    341 }
    342 
    343 const testing = std.testing;
    344 
    345 test "parseDirLog: newest-first, dedup by path" {
    346     var arena = std.heap.ArenaAllocator.init(testing.allocator);
    347     defer arena.deinit();
    348 
    349     const content =
    350         "2024-01-01T00:00:00Z|/a\n" ++
    351         "2024-01-02T00:00:00Z|/b\n" ++
    352         "2024-01-03T00:00:00Z|/a\n";
    353 
    354     const entries = try parseDirLog(arena.allocator(), content);
    355     try testing.expectEqual(@as(usize, 2), entries.len);
    356     try testing.expectEqualStrings("/a", entries[0].path);
    357     try testing.expectEqualStrings("2024-01-03T00:00:00Z", entries[0].timestamp.?);
    358     try testing.expectEqualStrings("/b", entries[1].path);
    359 }
    360 
    361 test "parseDirLog: skips blank and malformed lines" {
    362     var arena = std.heap.ArenaAllocator.init(testing.allocator);
    363     defer arena.deinit();
    364 
    365     const content = "\n   \n2024|/only\nno-pipe-here\n";
    366     const entries = try parseDirLog(arena.allocator(), content);
    367     try testing.expectEqual(@as(usize, 1), entries.len);
    368     try testing.expectEqualStrings("/only", entries[0].path);
    369 }
    370 
    371 test "parseDirLog: empty input yields no entries" {
    372     var arena = std.heap.ArenaAllocator.init(testing.allocator);
    373     defer arena.deinit();
    374     const entries = try parseDirLog(arena.allocator(), "");
    375     try testing.expectEqual(@as(usize, 0), entries.len);
    376 }
    377 
    378 test "parseFileLog: newest-first dedup keeps type and action" {
    379     var arena = std.heap.ArenaAllocator.init(testing.allocator);
    380     defer arena.deinit();
    381 
    382     const content =
    383         "t1|zig|open|/x\n" ++
    384         "t2|md|edit|/y\n" ++
    385         "t3|rs|run|/x\n";
    386 
    387     const entries = try parseFileLog(arena.allocator(), content);
    388     try testing.expectEqual(@as(usize, 2), entries.len);
    389     try testing.expectEqualStrings("/x", entries[0].path);
    390     try testing.expectEqualStrings("rs", entries[0].file_type);
    391     try testing.expectEqualStrings("run", entries[0].action);
    392     try testing.expectEqualStrings("t3", entries[0].timestamp.?);
    393     try testing.expectEqualStrings("/y", entries[1].path);
    394 }
    395 
    396 test "parseFileLog: ignores lines with too few columns" {
    397     var arena = std.heap.ArenaAllocator.init(testing.allocator);
    398     defer arena.deinit();
    399 
    400     const content = "t1|zig|open\nt2|md|edit|/y\n";
    401     const entries = try parseFileLog(arena.allocator(), content);
    402     try testing.expectEqual(@as(usize, 1), entries.len);
    403     try testing.expectEqualStrings("/y", entries[0].path);
    404 }