Something went wrong. Try again.
interactive shell navigation and history
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708const std = @import("std");const assert = std.debug.assert;const input_output = @import("input_output.zig");
const max_log_bytes: usize = 64 * 1024 * 1024;
/// Each log line is `timestamp|path`. New lines go at the end.pub const DirectoryEntry = struct { path: []const u8, timestamp: ?[]const u8 = null,
const column_count = 2;
fn fromColumns(columns: [column_count][]const u8) DirectoryEntry { return .{ .timestamp = columns[0], .path = columns[1] }; }
fn dupe(self: DirectoryEntry, allocator: std.mem.Allocator) !DirectoryEntry { return .{ .path = try allocator.dupe(u8, self.path), .timestamp = try dupeOptionalText(allocator, self.timestamp), }; }};
/// Each log line is `timestamp|type|action|path`. New lines go at the end.pub const FileEntry = struct { path: []const u8, file_type: []const u8, action: []const u8, timestamp: ?[]const u8 = null,
const column_count = 4;
fn fromColumns(columns: [column_count][]const u8) FileEntry { return .{ .timestamp = columns[0], .file_type = columns[1], .action = columns[2], .path = columns[3], }; }
fn dupe(self: FileEntry, allocator: std.mem.Allocator) !FileEntry { return .{ .path = try allocator.dupe(u8, self.path), .file_type = try allocator.dupe(u8, self.file_type), .action = try allocator.dupe(u8, self.action), .timestamp = try dupeOptionalText(allocator, self.timestamp), }; }};
fn dupeOptionalText(allocator: std.mem.Allocator, text: ?[]const u8) !?[]const u8 { const value = text orelse return null; const copy = try allocator.dupe(u8, value); assert(copy.len == value.len); return copy;}
pub const SearchResult = struct { directories: []DirectoryEntry, files: []FileEntry,};
pub const Database = struct { allocator: std.mem.Allocator, base_path: []const u8, directories_path: []const u8, files_path: []const u8,
pub fn init(allocator: std.mem.Allocator, path: []const u8) !Database { assert(path.len > 0); const base_path = try allocator.dupe(u8, path); assert(base_path.len == path.len); errdefer allocator.free(base_path);
const directories_path = try std.Io.Dir.path.join(allocator, &.{ base_path, "directories.log", }); assert(directories_path.len > base_path.len); errdefer allocator.free(directories_path);
const files_path = try std.Io.Dir.path.join(allocator, &.{ base_path, "files.log" }); assert(files_path.len > base_path.len); errdefer allocator.free(files_path);
const result = Database{ .allocator = allocator, .base_path = base_path, .directories_path = directories_path, .files_path = files_path, };
std.Io.Dir.cwd().createDirPath(input_output.runtime(), base_path) catch |err| { if (err != error.PathAlreadyExists) return err; }; restrictDirectoryPerms(base_path); migrateDirectoriesLog(allocator, base_path, directories_path);
const working_directory = std.Io.Dir.cwd(); const runtime_handle = input_output.runtime(); for ([_][]const u8{ directories_path, files_path }) |log_path| { assert(log_path.len > 0); var log_file = working_directory.openFile(runtime_handle, log_path, .{ .mode = .read_write, .allow_directory = false, .path_only = false, }) catch |err| { if (err == error.FileNotFound) { const created = try input_output.createFile( runtime_handle, working_directory, log_path, .{ .truncate = false, .lock = .none, .mode = 0o600 }, ); created.close(runtime_handle); continue; } return err; }; log_file.setPermissions(runtime_handle, .fromMode(0o600)) catch |err| { input_output.warn( "failed to restrict permissions on {s}: {}\n", .{ log_path, err }, ); }; log_file.close(runtime_handle); }
return result; }
fn restrictDirectoryPerms(path: []const u8) void { assert(path.len > 0); var directory = std.Io.Dir.cwd().openDir(input_output.runtime(), path, .{ .access_sub_paths = true, .iterate = true, .follow_symlinks = true, }) catch |err| { input_output.warn("failed to open history directory {s}: {}\n", .{ path, err }); return; }; defer directory.close(input_output.runtime()); directory.setPermissions(input_output.runtime(), .fromMode(0o700)) catch |err| { input_output.warn("failed to restrict permissions on {s}: {}\n", .{ path, err }); }; }
fn migrateDirectoriesLog( allocator: std.mem.Allocator, base_path: []const u8, directories_path: []const u8, ) void { assert(base_path.len > 0); assert(directories_path.len > 0); const legacy_path = std.Io.Dir.path.join(allocator, &.{ base_path, "dirs.log" }) catch |err| { input_output.warn("failed to build legacy history path: {}\n", .{err}); return; }; defer allocator.free(legacy_path); assert(legacy_path.len > base_path.len);
std.Io.Dir.cwd().access( input_output.runtime(), directories_path, input_output.exists_access, ) catch |new_err| { if (new_err != error.FileNotFound) return; std.Io.Dir.cwd().access( input_output.runtime(), legacy_path, input_output.exists_access, ) catch return; std.Io.Dir.renameAbsolute( legacy_path, directories_path, input_output.runtime(), ) catch |err| { input_output.warn( "failed to migrate {s} to {s}: {}\n", .{ legacy_path, directories_path, err }, ); }; return; }; }
pub fn deinit(self: *Database) void { assert(self.base_path.len > 0); assert(self.directories_path.len > 0); assert(self.files_path.len > 0); self.allocator.free(self.base_path); self.allocator.free(self.directories_path); self.allocator.free(self.files_path); }
fn createLogArena(self: *Database) !*std.heap.ArenaAllocator { const arena = try self.allocator.create(std.heap.ArenaAllocator); arena.* = std.heap.ArenaAllocator.init(self.allocator); return arena; }
fn destroyLogArena(self: *Database, arena: *std.heap.ArenaAllocator) void { arena.deinit(); self.allocator.destroy(arena); }
fn readLogBody(self: *Database, path: []const u8) ![]u8 { assert(path.len > 0); return std.Io.Dir.cwd().readFileAlloc( input_output.runtime(), path, self.allocator, .limited(max_log_bytes), ); }
/// Reads the log at path and returns its entries, newest first. If the /// log does not exist, it returns an empty list. Parsed is used as an /// arena-owned container; nothing JSON-related happens here. fn readLog(self: *Database, comptime Entry: type, path: []const u8) !std.json.Parsed([]Entry) { assert(path.len > 0); const arena = try self.createLogArena(); errdefer self.destroyLogArena(arena); const content = self.readLogBody(path) catch |err| { if (err == error.FileNotFound) { return .{ .arena = arena, .value = &.{} }; } return err; }; defer self.allocator.free(content); return .{ .arena = arena, .value = try parseLog(Entry, arena.allocator(), content), }; }
pub fn recentDirectories(self: *Database) !std.json.Parsed([]DirectoryEntry) { return self.readLog(DirectoryEntry, self.directories_path); }
pub fn recentFiles(self: *Database) !std.json.Parsed([]FileEntry) { return self.readLog(FileEntry, self.files_path); }
pub fn searchHistory(self: *Database, query: []const u8) !std.json.Parsed(SearchResult) { const managed_directories = try self.recentDirectories(); defer managed_directories.deinit(); const managed_files = try self.recentFiles(); defer managed_files.deinit();
const arena = try self.createLogArena(); errdefer self.destroyLogArena(arena); const allocator = arena.allocator(); const directories = managed_directories.value; const files = managed_files.value; return .{ .arena = arena, .value = .{ .directories = try filterByPath(DirectoryEntry, allocator, directories, query), .files = try filterByPath(FileEntry, allocator, files, query), }, }; }
// `realPathFileAlloc` returns a sentinel-terminated slice: keeping its // type lets `free` see the extra sentinel byte. fn resolvePath(self: *Database, path: []const u8) ![:0]const u8 { assert(path.len > 0); if (std.Io.Dir.cwd().realPathFileAlloc( input_output.runtime(), path, self.allocator, )) |absolute| { return absolute; } else |err| { if (err == error.FileNotFound) { input_output.warn("path not found for logging: {s}\n", .{path}); } const resolved = try std.Io.Dir.path.resolveAlloc(self.allocator, &.{ ".", path }); defer self.allocator.free(resolved); return try self.allocator.dupeSentinel(u8, resolved, 0); } }
fn openLogForAppend(path: []const u8) !std.Io.File { assert(path.len > 0); if (std.Io.Dir.cwd().openFile(input_output.runtime(), path, .{ .mode = .write_only, .allow_directory = false, .path_only = false, .lock = .exclusive, .lock_nonblocking = false, })) |file| { return file; } else |err| { if (err == error.FileNotFound) { return input_output.createFile( input_output.runtime(), std.Io.Dir.cwd(), path, .{ .truncate = false, .lock = .exclusive, .mode = 0o600 }, ); } return err; } }
fn writeTimestamp(buffer: *[64]u8) ![]const u8 { const micros_total = std.Io.Timestamp.now(input_output.runtime(), .real).toMicroseconds(); assert(micros_total >= 0); const total: u64 = @intCast(micros_total); const secs: u64 = total / 1_000_000; const sub_micros: u32 = @intCast(total % 1_000_000); const epoch_secs = std.time.epoch.EpochSeconds{ .secs = secs }; const year_day = epoch_secs.getEpochDay().calculateYearDay(); const month_day = year_day.calculateMonthDay(); const day_secs = epoch_secs.getDaySeconds(); var timestamp_writer = std.Io.Writer.fixed(buffer); try timestamp_writer.print("{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}", .{ year_day.year, month_day.month.numeric(), month_day.day_index + 1, day_secs.getHoursIntoDay(), day_secs.getMinutesIntoHour(), day_secs.getSecondsIntoMinute(), }); if (sub_micros != 0) { if (sub_micros % 1000 == 0) { try timestamp_writer.print(".{d:0>3}", .{sub_micros / 1000}); } else { try timestamp_writer.print(".{d:0>6}", .{sub_micros}); } } try timestamp_writer.writeAll("Z"); assert(timestamp_writer.end > 0); return buffer[0..timestamp_writer.end]; }
pub fn logDirectory(self: *Database, directory_path: []const u8) !void { try self.appendLogLine(self.directories_path, &.{}, directory_path); }
pub fn logFile( self: *Database, file_path: []const u8, file_type: []const u8, action: []const u8, ) !void { try self.appendLogLine(self.files_path, &.{ file_type, action }, file_path); }
/// Adds a `timestamp|columns...|absolute path` line to the end of the log /// at log_path. fn appendLogLine( self: *Database, log_path: []const u8, columns: []const []const u8, entry_path: []const u8, ) !void { if (entry_path.len == 0) { return error.EmptyPath; } assert(log_path.len > 0); const abs_path = try self.resolvePath(entry_path); assert(abs_path.len > 0); defer self.allocator.free(abs_path);
const log_file = try openLogForAppend(log_path); defer log_file.close(input_output.runtime());
var timestamp_buffer: [64]u8 = undefined; const timestamp = try writeTimestamp(×tamp_buffer);
var file_buffer: [4096]u8 = undefined; var file_writer = log_file.writer(input_output.runtime(), &file_buffer); try file_writer.seekTo(try log_file.length(input_output.runtime())); try file_writer.interface.writeAll(timestamp); for (columns) |column| { try file_writer.interface.print("|{s}", .{column}); } try file_writer.interface.print("|{s}\n", .{abs_path}); try file_writer.interface.flush(); }
pub fn cleanup(self: *Database) !void { try self.cleanupFile(self.directories_path, 1); try self.cleanupFile(self.files_path, 3); }
fn cleanupFile(self: *Database, path: []const u8, comptime path_column_index: usize) !void { assert(path.len > 0); input_output.warn("Cleaning up {s}...\n", .{path});
const content = try self.readLogBody(path); defer self.allocator.free(content);
const retained = try collectRetainedLines(self.allocator, content, path_column_index); defer self.allocator.free(retained.lines);
const unique_count = retained.lines.len; const removed_count = retained.missing_count + retained.inaccessible_count; assert(retained.total_count >= unique_count + removed_count); const duplicates = retained.total_count - unique_count - removed_count; input_output.warn( " Entries: {d} total, {d} unique, {d} missing," ++ " {d} inaccessible, {d} duplicates removed\n", .{ retained.total_count, unique_count, retained.missing_count, retained.inaccessible_count, duplicates, }, );
const temp_path = try self.allocator.print("{s}.tmp", .{path}); defer self.allocator.free(temp_path); { const write_file = try input_output.createFile( input_output.runtime(), std.Io.Dir.cwd(), temp_path, .{ .truncate = true, .lock = .exclusive, .mode = 0o600 }, ); defer write_file.close(input_output.runtime());
var write_buffer: [4096]u8 = undefined; var writer = write_file.writer(input_output.runtime(), &write_buffer);
var index: usize = retained.lines.len; while (index > 0) { assert(index <= retained.lines.len); index -= 1; try writer.interface.print("{s}\n", .{retained.lines[index]}); } try writer.end(); } std.Io.Dir.renameAbsolute(temp_path, path, input_output.runtime()) catch |err| { std.Io.Dir.cwd().deleteFile(input_output.runtime(), temp_path) catch |cleanup_err| { input_output.warn( "failed to remove temp file {s}: {}\n", .{ temp_path, cleanup_err }, ); }; return err; }; }};
const RetainedLogLines = struct { lines: [][]const u8, total_count: usize, missing_count: usize, inaccessible_count: usize,};
fn collectRetainedLines( allocator: std.mem.Allocator, content: []const u8, comptime path_column_index: usize,) !RetainedLogLines { var seen = std.StringHashMap(void).init(allocator); defer seen.deinit();
var lines = std.ArrayList([]const u8).empty; errdefer lines.deinit(allocator);
var total_count: usize = 0; var missing_count: usize = 0; var inaccessible_count: usize = 0; var line_split = std.mem.splitBackwardsScalar(u8, content, '\n'); while (line_split.next()) |line| { const trimmed = std.mem.trim(u8, line, " \r\t"); if (trimmed.len == 0) { continue; } total_count += 1; assert(total_count <= content.len + 1);
const entry_path = extractPathColumn(trimmed, path_column_index) orelse continue; if (seen.contains(entry_path)) { continue; }
std.Io.Dir.cwd().access( input_output.runtime(), entry_path, input_output.exists_access, ) catch |err| { if (err == error.FileNotFound) { missing_count += 1; continue; } input_output.warn("skipping inaccessible entry {s}: {}\n", .{ entry_path, err }); inaccessible_count += 1; continue; };
try seen.put(entry_path, {}); try lines.append(allocator, trimmed); }
const retained = RetainedLogLines{ .lines = try lines.toOwnedSlice(allocator), .total_count = total_count, .missing_count = missing_count, .inaccessible_count = inaccessible_count, }; assert(retained.lines.len <= retained.total_count); return retained;}
fn extractPathColumn(line: []const u8, comptime path_column_index: usize) ?[]const u8 { assert(line.len > 0); var column_split = std.mem.splitScalar(u8, line, '|'); var column_index: usize = 0; while (column_split.next()) |column| { if (column_index == path_column_index) { return column; } column_index += 1; } return null;}
pub fn getDefaultDatabasePath(allocator: std.mem.Allocator) ![]const u8 { if (input_output.getenv("HOME")) |home| { if (home.len == 0) { return error.HomeNotFound; } const result = try std.Io.Dir.path.join(allocator, &.{ home, ".magdalena" }); assert(result.len > home.len); return result; } return error.HomeNotFound;}
/// Parses log content and returns the newest entry for each path, newest/// first. It skips lines that have too few columns or an empty path. It/// ignores extra columns. The path is always the last column.fn parseLog(comptime Entry: type, allocator: std.mem.Allocator, content: []const u8) ![]Entry { const column_count = Entry.column_count; comptime assert(column_count >= 2); var list = std.ArrayList(Entry).empty; errdefer list.deinit(allocator); var seen = std.StringHashMap(void).init(allocator); defer seen.deinit();
var line_split = std.mem.splitBackwardsScalar(u8, content, '\n'); lines: while (line_split.next()) |line| { const trimmed = std.mem.trim(u8, line, " \r\t"); if (trimmed.len == 0) { continue; }
var columns: [column_count][]const u8 = undefined; var column_split = std.mem.splitScalar(u8, trimmed, '|'); for (&columns) |*column| { column.* = column_split.next() orelse continue :lines; } const path = columns[column_count - 1]; if (path.len == 0) { continue; }
const gop = try seen.getOrPut(path); if (gop.found_existing) { continue; }
const entry = try Entry.fromColumns(columns).dupe(allocator); assert(entry.path.len == path.len); try list.append(allocator, entry); }
return list.toOwnedSlice(allocator);}
/// Returns copies of the entries whose path contains query. The copies use/// allocator.fn filterByPath( comptime Entry: type, allocator: std.mem.Allocator, entries: []const Entry, query: []const u8,) ![]Entry { var list = std.ArrayList(Entry).empty; for (entries) |entry| { assert(entry.path.len > 0); if (std.mem.find(u8, entry.path, query) == null) { continue; } try list.append(allocator, try entry.dupe(allocator)); } return list.toOwnedSlice(allocator);}
const testing = std.testing;
test "parseLog directories: newest-first, dedup by path" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit();
const content = "2024-01-01T00:00:00Z|/a\n" ++ "2024-01-02T00:00:00Z|/b\n" ++ "2024-01-03T00:00:00Z|/a\n";
const entries = try parseLog(DirectoryEntry, arena.allocator(), content); try testing.expectEqual(@as(usize, 2), entries.len); try testing.expectEqualStrings("/a", entries[0].path); try testing.expectEqualStrings("2024-01-03T00:00:00Z", entries[0].timestamp.?); try testing.expectEqualStrings("/b", entries[1].path);}
test "parseLog directories: skips blank and malformed lines" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit();
const content = "\n \n2024|/only\nno-pipe-here\n"; const entries = try parseLog(DirectoryEntry, arena.allocator(), content); try testing.expectEqual(@as(usize, 1), entries.len); try testing.expectEqualStrings("/only", entries[0].path);}
test "parseLog directories: skips entries with empty paths" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit();
const content = "2024|\n2024|/kept\n"; const entries = try parseLog(DirectoryEntry, arena.allocator(), content); try testing.expectEqual(@as(usize, 1), entries.len); try testing.expectEqualStrings("/kept", entries[0].path);}
test "parseLog directories: empty input yields no entries" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); const entries = try parseLog(DirectoryEntry, arena.allocator(), ""); try testing.expectEqual(@as(usize, 0), entries.len);}
test "parseLog directories: extra columns are ignored" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); const entries = try parseLog(DirectoryEntry, arena.allocator(), "t|/p|unexpected\n"); try testing.expectEqual(@as(usize, 1), entries.len); try testing.expectEqualStrings("/p", entries[0].path);}
test "parseLog files: newest-first dedup keeps type and action" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit();
const content = "t1|zig|open|/x\n" ++ "t2|md|edit|/y\n" ++ "t3|rs|run|/x\n";
const entries = try parseLog(FileEntry, arena.allocator(), content); try testing.expectEqual(@as(usize, 2), entries.len); try testing.expectEqualStrings("/x", entries[0].path); try testing.expectEqualStrings("rs", entries[0].file_type); try testing.expectEqualStrings("run", entries[0].action); try testing.expectEqualStrings("t3", entries[0].timestamp.?); try testing.expectEqualStrings("/y", entries[1].path);}
test "parseLog files: ignores lines with too few columns" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit();
const content = "t1|zig|open\nt2|md|edit|/y\n"; const entries = try parseLog(FileEntry, arena.allocator(), content); try testing.expectEqual(@as(usize, 1), entries.len); try testing.expectEqualStrings("/y", entries[0].path);}
test "parseLog files: skips entries with empty paths" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit();
const entries = try parseLog(FileEntry, arena.allocator(), "t1|zig|open|\n"); try testing.expectEqual(@as(usize, 0), entries.len);}
test "parseLog files: empty fields are kept, not skipped" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit();
const entries = try parseLog(FileEntry, arena.allocator(), "t1||open|/x\n"); try testing.expectEqual(@as(usize, 1), entries.len); try testing.expectEqualStrings("", entries[0].file_type);}
test "extractPathColumn: returns the indexed column" { try testing.expectEqualStrings("/p", extractPathColumn("t|/p", 1).?); try testing.expectEqualStrings("t", extractPathColumn("t|/p", 0).?);}
test "extractPathColumn: missing column yields null" { try testing.expect(extractPathColumn("t|/p", 3) == null); try testing.expect(extractPathColumn("t", 1) == null);}