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 }