Commit Diff


commit - 901ed9b95d701f2ea92f2192810727a510191ff0
commit + 27d50783b9f968de96a425fdaa212471e74c8cd3
blob - 37ae75008306305032d1674861c875bc7c8644fd
blob + c61b4bb40e8d5eab8df4f7620c62914300444eed
--- README.md
+++ README.md
@@ -13,10 +13,7 @@ A command-line tool for interactive shell navigation a
 ## Dependencies
 
 - Zig 0.16.0
-- ripgrep
-- fzf
-- bat
-- fd
+- ugrep
 - memcached (optional)
 
 ## Building
@@ -30,16 +27,16 @@ zig build -Doptimize=ReleaseSafe
 ## Usage
 
 ```
-magdalena recent-dirs
+magdalena recent-directories
 magdalena recent-files
 magdalena favorites
-magdalena goto-dir
+magdalena goto-directory
 magdalena goto-file
-magdalena log-dir <path>
+magdalena log-directory <path>
 magdalena log-file <path> [type] [action]
 magdalena search <query>
 magdalena look-file [--depth <n>]
-magdalena look-dir [--depth <n>]
+magdalena look-directory [--depth <n>]
 magdalena grep
 ```
 
@@ -55,11 +52,11 @@ magdalena grep
 ./zig-out/bin/magdalena
 
 # Show recent directories
-./zig-out/bin/magdalena recent-dirs
+./zig-out/bin/magdalena recent-directories
 
 # Search history
 ./zig-out/bin/magdalena search "myquery"
 
 # Explore directory with depth 3
-./zig-out/bin/magdalena look-dir --depth 3
+./zig-out/bin/magdalena look-directory --depth 3
 ```
blob - 74868a0de07d2ee2d5c9b477726259bfd2ffdd33
blob + 6cbfc90996f0b0b60d8faf73d688c16ce443c780
--- build.zig
+++ build.zig
@@ -4,11 +4,16 @@ pub fn build(b: *std.Build) void {
     const target = b.standardTargetOptions(.{});
     const optimize = b.standardOptimizeOption(.{});
 
-    const zul = b.dependency("zul", .{
+    const vaxis = b.dependency("vaxis", .{
         .target = target,
         .optimize = optimize,
     });
 
+    const fuzzig = b.dependency("fuzzig", .{
+        .target = target,
+        .optimize = optimize,
+    });
+
     const exe = b.addExecutable(.{
         .name = "magdalena",
         .root_module = b.createModule(.{
@@ -16,12 +21,18 @@ pub fn build(b: *std.Build) void {
             .target = target,
             .optimize = optimize,
             .link_libc = true,
+            .strip = optimize != .Debug,
+            .stack_protector = optimize != .Debug,
             .imports = &.{
-                .{ .name = "zul", .module = zul.module("zul") },
+                .{ .name = "vaxis", .module = vaxis.module("vaxis") },
+                .{ .name = "fuzzig", .module = fuzzig.module("fuzzig") },
             },
         }),
     });
 
+    exe.pie = true;
+    exe.link_z_relro = true;
+
     b.installArtifact(exe);
 
     const run_cmd = b.addRunArtifact(exe);
@@ -36,10 +47,11 @@ pub fn build(b: *std.Build) void {
         .root_module = b.createModule(.{
             .root_source_file = b.path("src/main.zig"),
             .target = target,
-            .optimize = optimize,
+            .optimize = .Debug,
             .link_libc = true,
             .imports = &.{
-                .{ .name = "zul", .module = zul.module("zul") },
+                .{ .name = "vaxis", .module = vaxis.module("vaxis") },
+                .{ .name = "fuzzig", .module = fuzzig.module("fuzzig") },
             },
         }),
     });
blob - 2fb866ba65a9b9d83f4c6dd35c8bd6527be9c300
blob + c2d8bc1a4db577a6b9094766c01577ed5a679318
--- build.zig.zon
+++ build.zig.zon
@@ -4,10 +4,14 @@
     .fingerprint = 0xd065e535ca3c8b31,
     .minimum_zig_version = "0.16.0",
     .dependencies = .{
-        .zul = .{
-            .url = "https://github.com/karlseguin/zul/archive/146f9d5b2238c3a621b96345adf03490900c2fe2.tar.gz",
-            .hash = "zul-0.0.0-1oDot2KwBwC9c43wC7V9y4-xxg0a_d9okFDcJPhqmije",
+        .vaxis = .{
+            .url = "git+https://github.com/rockorager/libvaxis#f37c42a3b324131c131d066767968e1f5b976453",
+            .hash = "vaxis-0.6.0-BWNV_AIQDADLTDo5jCn27pBWJBvi-sTrSkSKwITQWgUV",
         },
+        .fuzzig = .{
+            .url = "git+https://codeberg.org/fjebaker/fuzzig#97ed5ef57c2a11cbc21db061a2ea093c3df64de2",
+            .hash = "fuzzig-0.1.3-Ji0xit9oAQAxavWq4UDsI9S0nsUVOsqZbxUbZUzlKu4x",
+        },
     },
     .paths = .{
         "build.zig",
blob - a4b9994dc0d8b8f2550eb2485707c42db3286f04
blob + fcc7b2269b621e0e8345d106c9f2567f8b0264b7
--- config.json
+++ config.json
@@ -2,16 +2,6 @@
 	"editor": "vim",
 	"max_depth": 5,
 	"ignored_patterns": [".git", ".jj", ".zig-cache", "node_modules", "target"],
-	"fzf_opts": [
-		"--highlight-line",
-		"--ansi",
-		"--layout=reverse",
-		"--border=rounded",
-		"--color=bg+:#2a1f2e,bg:-1,spinner:#c084b8,hl:#e08060",
-		"--color=fg:#c8b8bf,header:#c4607a,info:#7a6e7a,pointer:#c084b8",
-		"--color=marker:#e06fad,fg+:#f0dfe5,prompt:#7a9fd4,hl+:#f0a070",
-		"--color=border:#3d2a42"
-	],
 	"favorites": ["~/src"],
 	"openers": [
 		{
blob - 4d2ce948ed673c91f5b3f0a8b15d30ecfe05723b (mode 644)
blob + /dev/null
--- src/cfg.zig
+++ /dev/null
@@ -1,125 +0,0 @@
-const std = @import("std");
-const io = @import("io.zig");
-
-pub const Config = struct {
-    openers: []Opener = &.{},
-    ignored_patterns: []const []const u8 = &.{},
-    fzf_opts: []const []const u8 = &.{},
-    favorites: []const []const u8 = &.{},
-    editor: ?[]const u8 = null,
-    max_depth: usize = 3,
-    overrides: []FolderOverride = &.{},
-
-    pub const Opener = struct {
-        extensions: []const []const u8,
-        action: []const u8,
-        command: []const u8,
-    };
-};
-
-// Per-directory settings applied when cwd matches `path` exactly.
-// All override fields are optional; only non-null ones replace the base config.
-pub const FolderOverride = struct {
-    path: []const u8,
-    openers: ?[]Config.Opener = null,
-    ignored_patterns: ?[]const []const u8 = null,
-    fzf_opts: ?[]const []const u8 = null,
-    favorites: ?[]const []const u8 = null,
-    editor: ?[]const u8 = null,
-    max_depth: ?usize = null,
-};
-
-// Return a Config with the first override whose path exactly matches cwd applied on top.
-// The returned Config borrows memory from the parsed Config — no extra allocation needed.
-pub fn applyFolderOverride(base: Config, cwd: []const u8) Config {
-    for (base.overrides) |*ov| {
-        if (!std.mem.eql(u8, cwd, ov.path)) continue;
-        var result = base;
-        if (ov.openers) |v| result.openers = v;
-        if (ov.ignored_patterns) |v| result.ignored_patterns = v;
-        if (ov.fzf_opts) |v| result.fzf_opts = v;
-        if (ov.favorites) |v| result.favorites = v;
-        if (ov.editor) |v| result.editor = v;
-        if (ov.max_depth) |v| result.max_depth = v;
-        return result;
-    }
-    return base;
-}
-
-pub fn loadConfig(allocator: std.mem.Allocator) ?std.json.Parsed(Config) {
-    const home = io.getenv("HOME") orelse {
-        io.warn("HOME not set, skipping config load\n", .{});
-        return null;
-    };
-    const path = std.fs.path.join(allocator, &.{ home, ".config", "magdalena", "config.json" }) catch {
-        io.warn("failed to allocate config path\n", .{});
-        return null;
-    };
-    defer allocator.free(path);
-
-    const content = std.Io.Dir.cwd().readFileAlloc(io.rt(), path, allocator, .unlimited) catch |err| {
-        if (err != error.FileNotFound) {
-            io.warn("failed to read config {s}: {}\n", .{ path, err });
-        }
-        return null;
-    };
-    defer allocator.free(content);
-
-    return std.json.parseFromSlice(Config, allocator, content, .{
-        .ignore_unknown_fields = true,
-        .allocate = .alloc_always,
-    }) catch |err| {
-        io.warn("failed to parse config {s}: {}\n", .{ path, err });
-        return null;
-    };
-}
-
-const testing = std.testing;
-
-test "applyFolderOverride: no overrides returns base" {
-    const base = Config{ .max_depth = 3, .editor = "vim" };
-    const got = applyFolderOverride(base, "/anywhere");
-    try testing.expectEqual(@as(usize, 3), got.max_depth);
-    try testing.expectEqualStrings("vim", got.editor.?);
-}
-
-test "applyFolderOverride: matching path overrides only provided fields" {
-    const ignored = [_][]const u8{ "node_modules", ".git" };
-    var overrides = [_]FolderOverride{
-        .{ .path = "/work", .max_depth = 9, .editor = "nvim" },
-    };
-    const base = Config{
-        .max_depth = 3,
-        .editor = "vim",
-        .ignored_patterns = &ignored,
-        .overrides = &overrides,
-    };
-
-    const got = applyFolderOverride(base, "/work");
-    try testing.expectEqual(@as(usize, 9), got.max_depth);
-    try testing.expectEqualStrings("nvim", got.editor.?);
-    // A field left null on the override keeps the base value.
-    try testing.expectEqual(base.ignored_patterns.ptr, got.ignored_patterns.ptr);
-}
-
-test "applyFolderOverride: non-matching path leaves base untouched" {
-    var overrides = [_]FolderOverride{
-        .{ .path = "/work", .max_depth = 9 },
-    };
-    const base = Config{ .max_depth = 3, .editor = "vim", .overrides = &overrides };
-
-    const got = applyFolderOverride(base, "/elsewhere");
-    try testing.expectEqual(@as(usize, 3), got.max_depth);
-    try testing.expectEqualStrings("vim", got.editor.?);
-}
-
-test "applyFolderOverride: first matching override wins" {
-    var overrides = [_]FolderOverride{
-        .{ .path = "/work", .max_depth = 5 },
-        .{ .path = "/work", .max_depth = 8 },
-    };
-    const base = Config{ .max_depth = 3, .overrides = &overrides };
-
-    const got = applyFolderOverride(base, "/work");
-    try testing.expectEqual(@as(usize, 5), got.max_depth);
-}
blob - /dev/null
blob + 97ad8d0c25bcf0700acd252aeb7025c2bd3f2b59 (mode 644)
--- /dev/null
+++ src/command_line.zig
@@ -0,0 +1,566 @@
+const std = @import("std");
+const assert = std.debug.assert;
+const input_output = @import("input_output.zig");
+
+pub const Action = enum {
+    recent_directories,
+    recent_files,
+    favorites,
+    search,
+    goto_directory,
+    goto_file,
+    look_file,
+    look_directory,
+    grep,
+    cleanup,
+    log_directory,
+    log_file,
+    help,
+};
+
+pub const Arguments = struct {
+    action: Action,
+    query: ?[]const u8 = null,
+    log_path: ?[]const u8 = null,
+    file_type: ?[]const u8 = null,
+    file_action: ?[]const u8 = null,
+    depth: ?usize = null,
+
+    _command_line: CommandLineArguments,
+
+    pub fn deinit(self: Arguments) void {
+        self._command_line.deinit();
+    }
+};
+
+const subcommand_table: []const struct {
+    name: []const u8,
+    action: Action,
+} = &.{
+    .{ .name = "favorites", .action = .favorites },
+    .{ .name = "goto-directory", .action = .goto_directory },
+    .{ .name = "goto-file", .action = .goto_file },
+    .{ .name = "grep", .action = .grep },
+    .{ .name = "log-directory", .action = .log_directory },
+    .{ .name = "log-file", .action = .log_file },
+    .{ .name = "look-directory", .action = .look_directory },
+    .{ .name = "look-file", .action = .look_file },
+    .{ .name = "recent-directories", .action = .recent_directories },
+    .{ .name = "recent-files", .action = .recent_files },
+    .{ .name = "search", .action = .search },
+};
+
+comptime {
+    for (subcommand_table, 0..) |subcommand, index| {
+        assert(subcommand.name.len > 0);
+        if (index > 0) {
+            const order = std.mem.order(u8, subcommand_table[index - 1].name, subcommand.name);
+            assert(order == .lt);
+        }
+    }
+}
+
+pub const CommandLineArguments = struct {
+    _arena: *std.heap.ArenaAllocator,
+    _lookup: std.StringHashMapUnmanaged([]const u8),
+
+    executable: []const u8,
+    tail: []const [:0]const u8,
+
+    pub fn parse(parent: std.mem.Allocator, source: std.process.Args) !CommandLineArguments {
+        const arena = try parent.create(std.heap.ArenaAllocator);
+        errdefer parent.destroy(arena);
+
+        arena.* = std.heap.ArenaAllocator.init(parent);
+        errdefer arena.deinit();
+
+        const items = try source.toSlice(arena.allocator());
+        const parsed = try finishParse(arena, items);
+        assert(parsed._arena == arena);
+        return parsed;
+    }
+
+    fn finishParse(
+        arena: *std.heap.ArenaAllocator,
+        items: []const [:0]const u8,
+    ) !CommandLineArguments {
+        const allocator = arena.allocator();
+
+        var lookup: std.StringHashMapUnmanaged([]const u8) = .{};
+
+        if (items.len == 0) {
+            return .{ .executable = "", .tail = &.{}, ._arena = arena, ._lookup = lookup };
+        }
+
+        const executable = items[0];
+
+        var index: usize = 1;
+        var tail_start: usize = 1;
+
+        while (index < items.len) {
+            assert(index < items.len);
+            const argument = items[index];
+            if (argument.len == 1) {
+                break;
+            }
+            if (argument[0] != '-') {
+                break;
+            }
+            assert(argument.len > 1);
+            if (argument[1] == '-') {
+                if (argument.len == 2) {
+                    break;
+                }
+                const key_value = KeyValue.from(argument[2..], items, &index);
+                try lookup.put(allocator, key_value.key, key_value.value);
+            } else {
+                const key_value = KeyValue.from(argument[1..], items, &index);
+                const key = key_value.key;
+                assert(key.len > 0);
+                for (0..key.len - 1) |offset| {
+                    assert(offset + 1 < key.len);
+                    try lookup.put(allocator, key[offset .. offset + 1], "");
+                }
+                try lookup.put(allocator, key[key.len - 1 ..], key_value.value);
+            }
+            tail_start = index;
+        }
+        assert(tail_start <= items.len);
+
+        return .{
+            .executable = executable,
+            .tail = items[tail_start..],
+            ._arena = arena,
+            ._lookup = lookup,
+        };
+    }
+
+    pub fn deinit(self: CommandLineArguments) void {
+        const arena = self._arena;
+        const allocator = arena.child_allocator;
+        arena.deinit();
+        allocator.destroy(arena);
+    }
+
+    pub fn contains(self: *const CommandLineArguments, name: []const u8) bool {
+        assert(name.len > 0);
+        return self._lookup.contains(name);
+    }
+
+    pub fn get(self: *const CommandLineArguments, name: []const u8) ?[]const u8 {
+        assert(name.len > 0);
+        return self._lookup.get(name);
+    }
+};
+
+const KeyValue = struct {
+    key: []const u8,
+    value: []const u8,
+
+    fn from(key: []const u8, items: []const [:0]const u8, index: *usize) KeyValue {
+        assert(key.len > 0);
+        assert(items.len > 0);
+        const item_index = index.*;
+        assert(item_index < items.len);
+        if (std.mem.indexOfScalarPos(u8, key, 0, '=')) |position| {
+            assert(position < key.len);
+            assert(position + 1 <= key.len);
+            index.* = item_index + 1;
+            return .{ .key = key[0..position], .value = key[position + 1 ..] };
+        }
+        if (item_index == items.len - 1) {
+            index.* = item_index + 1;
+            return .{ .key = key, .value = "" };
+        }
+        assert(item_index + 1 < items.len);
+        const next = items[item_index + 1];
+        if (next.len > 0) {
+            if (next[0] == '-') {
+                index.* = item_index + 1;
+                return .{ .key = key, .value = "" };
+            }
+        }
+        index.* = item_index + 2;
+        return .{ .key = key, .value = next };
+    }
+};
+
+pub fn parseArguments(allocator: std.mem.Allocator, source: std.process.Args) !Arguments {
+    const command_line = try CommandLineArguments.parse(allocator, source);
+    errdefer command_line.deinit();
+    const parsed = try resolveArguments(command_line);
+    assert(parsed._command_line._arena == command_line._arena);
+    return parsed;
+}
+
+fn resolveArguments(command_line: CommandLineArguments) !Arguments {
+    var parsed_arguments = Arguments{
+        .action = .help,
+        ._command_line = command_line,
+    };
+
+    if (command_line.contains("help")) {
+        return parsed_arguments;
+    }
+    if (command_line.contains("-h")) {
+        return parsed_arguments;
+    }
+    if (command_line.contains("--help")) {
+        return parsed_arguments;
+    }
+    if (command_line.contains("clean")) {
+        parsed_arguments.action = .cleanup;
+        return parsed_arguments;
+    }
+    if (command_line.contains("c")) {
+        parsed_arguments.action = .cleanup;
+        return parsed_arguments;
+    }
+
+    parsed_arguments.depth = try parseDepthFlag(&command_line);
+    if (parsed_arguments.depth == null) {
+        parsed_arguments.depth = try scanTailDepth(command_line.tail);
+    }
+
+    if (command_line.tail.len == 0) {
+        return parsed_arguments;
+    }
+
+    const command_name = command_line.tail[0];
+    assert(command_line.tail.len > 0);
+    var found = false;
+    for (subcommand_table, 0..) |subcommand, subcommand_index| {
+        assert(subcommand_index < subcommand_table.len);
+        assert(subcommand.name.len > 0);
+        if (std.mem.eql(u8, command_name, subcommand.name)) {
+            parsed_arguments.action = subcommand.action;
+            found = true;
+            break;
+        }
+    }
+    if (found) {
+        fillActionArguments(&parsed_arguments, command_line.tail);
+    } else {
+        parsed_arguments.action = .help;
+    }
+    if (!found) {
+        assert(parsed_arguments.action == .help);
+    }
+    return parsed_arguments;
+}
+
+fn parseDepthFlag(command_line: *const CommandLineArguments) !?usize {
+    if (command_line.get("depth")) |text| {
+        return try std.fmt.parseInt(usize, text, 10);
+    }
+    if (command_line.get("d")) |text| {
+        return try std.fmt.parseInt(usize, text, 10);
+    }
+    return null;
+}
+
+fn scanTailDepth(tail: []const [:0]const u8) !?usize {
+    const prefix = "--depth=";
+    assert(prefix.len > 0);
+    for (tail, 0..) |argument, index| {
+        assert(index < tail.len);
+        if (std.mem.startsWith(u8, argument, prefix)) {
+            assert(argument.len >= prefix.len);
+            return try std.fmt.parseInt(usize, argument[prefix.len..], 10);
+        }
+        if (std.mem.eql(u8, argument, "--depth")) {
+            if (index + 1 < tail.len) {
+                assert(index + 1 < tail.len);
+                return try std.fmt.parseInt(usize, tail[index + 1], 10);
+            }
+            return null;
+        }
+        if (std.mem.eql(u8, argument, "-d")) {
+            if (index + 1 < tail.len) {
+                assert(index + 1 < tail.len);
+                return try std.fmt.parseInt(usize, tail[index + 1], 10);
+            }
+            return null;
+        }
+    }
+    return null;
+}
+
+fn fillActionArguments(parsed_arguments: *Arguments, tail: []const [:0]const u8) void {
+    assert(tail.len > 0);
+    assert(parsed_arguments.action != .help);
+    if (tail.len == 1) {
+        return;
+    }
+    const argument = tail[1];
+    switch (parsed_arguments.action) {
+        .search => {
+            parsed_arguments.query = argument;
+        },
+        .log_directory => {
+            parsed_arguments.log_path = argument;
+        },
+        .log_file => {
+            parsed_arguments.log_path = argument;
+            if (tail.len > 2) {
+                parsed_arguments.file_type = tail[2];
+            }
+            if (tail.len > 3) {
+                parsed_arguments.file_action = tail[3];
+            }
+        },
+        else => {},
+    }
+}
+
+pub fn printUsage() !void {
+    var buffer: [4096]u8 = undefined;
+    var writer = input_output.getStdout(&buffer);
+    try writer.print(
+        \\Usage:
+        \\  magdalena recent-directories
+        \\  magdalena recent-files
+        \\  magdalena favorites
+        \\  magdalena goto-directory
+        \\  magdalena goto-file
+        \\  magdalena log-directory <path>
+        \\  magdalena log-file <path> [type] [action]
+        \\  magdalena search <query>
+        \\  magdalena look-file [--depth <n>]
+        \\  magdalena look-directory [--depth <n>]
+        \\  magdalena grep
+        \\
+        \\Options:
+        \\  -c, --clean             Perform cleanup
+        \\  -h, --help              Show this help
+        \\
+    , .{});
+    try writer.end();
+}
+
+const testing = std.testing;
+
+fn testCommandLineArguments(items: []const [:0]const u8) !CommandLineArguments {
+    const arena = try testing.allocator.create(std.heap.ArenaAllocator);
+    errdefer testing.allocator.destroy(arena);
+    arena.* = std.heap.ArenaAllocator.init(testing.allocator);
+    errdefer arena.deinit();
+    const parsed = try CommandLineArguments.finishParse(arena, items);
+    assert(parsed._arena == arena);
+    assert(parsed.tail.len <= items.len);
+    return parsed;
+}
+
+fn testArguments(items: []const [:0]const u8) !Arguments {
+    const command_line = try testCommandLineArguments(items);
+    errdefer command_line.deinit();
+    const parsed = try resolveArguments(command_line);
+    assert(parsed._command_line._arena == command_line._arena);
+    assert(parsed._command_line.tail.len <= items.len);
+    return parsed;
+}
+
+test "CommandLineArguments: empty argv" {
+    var command_line = try testCommandLineArguments(&.{});
+    defer command_line.deinit();
+    try testing.expectEqualStrings("", command_line.executable);
+    try testing.expectEqual(@as(usize, 0), command_line.tail.len);
+}
+
+test "CommandLineArguments: executable only" {
+    var command_line = try testCommandLineArguments(&[_][:0]const u8{"magdalena"});
+    defer command_line.deinit();
+    try testing.expectEqualStrings("magdalena", command_line.executable);
+    try testing.expectEqual(@as(usize, 0), command_line.tail.len);
+}
+
+test "CommandLineArguments: flags with and without values" {
+    var command_line = try testCommandLineArguments(&[_][:0]const u8{
+        "bin",
+        "--level",
+        "info",
+        "--silent",
+        "-p",
+        "5432",
+        "-x",
+    });
+    defer command_line.deinit();
+    try testing.expectEqualStrings("bin", command_line.executable);
+    try testing.expectEqual(@as(usize, 0), command_line.tail.len);
+    try testing.expectEqualStrings("info", command_line.get("level").?);
+    try testing.expect(command_line.contains("silent"));
+    try testing.expectEqualStrings("", command_line.get("silent").?);
+    try testing.expectEqualStrings("5432", command_line.get("p").?);
+    try testing.expect(command_line.contains("x"));
+}
+
+test "CommandLineArguments: key=value form" {
+    var command_line = try testCommandLineArguments(&[_][:0]const u8{
+        "bin",
+        "--level=error",
+        "-p=6669",
+    });
+    defer command_line.deinit();
+    try testing.expectEqualStrings("error", command_line.get("level").?);
+    try testing.expectEqualStrings("6669", command_line.get("p").?);
+}
+
+test "CommandLineArguments: bundled single-char flags" {
+    var command_line = try testCommandLineArguments(&[_][:0]const u8{
+        "bin",
+        "-xvf",
+        "file.tar.gz",
+    });
+    defer command_line.deinit();
+    try testing.expect(command_line.contains("x"));
+    try testing.expectEqualStrings("", command_line.get("x").?);
+    try testing.expect(command_line.contains("v"));
+    try testing.expectEqualStrings("file.tar.gz", command_line.get("f").?);
+}
+
+test "CommandLineArguments: tail begins at first positional" {
+    var command_line = try testCommandLineArguments(&[_][:0]const u8{
+        "bin",
+        "-l",
+        "--k",
+        "x",
+        "ts",
+        "-p=6669",
+        "hello",
+    });
+    defer command_line.deinit();
+    try testing.expect(command_line.contains("l"));
+    try testing.expectEqualStrings("x", command_line.get("k").?);
+    try testing.expectEqual(@as(usize, 3), command_line.tail.len);
+    try testing.expectEqualStrings("ts", command_line.tail[0]);
+    try testing.expectEqualStrings("-p=6669", command_line.tail[1]);
+    try testing.expectEqualStrings("hello", command_line.tail[2]);
+}
+
+test "CommandLineArguments: bare double dash starts the tail" {
+    var command_line = try testCommandLineArguments(&[_][:0]const u8{
+        "bin",
+        "--",
+        "--depth",
+        "4",
+    });
+    defer command_line.deinit();
+    try testing.expectEqual(@as(usize, 3), command_line.tail.len);
+    try testing.expectEqualStrings("--", command_line.tail[0]);
+}
+
+test "CommandLineArguments: empty argument is consumed as the value" {
+    var command_line = try testCommandLineArguments(&[_][:0]const u8{ "bin", "--key", "", "tail" });
+    defer command_line.deinit();
+    try testing.expectEqualStrings("", command_line.get("key").?);
+    try testing.expectEqualStrings("tail", command_line.tail[0]);
+}
+
+test "resolveArguments: no arguments is help" {
+    var arguments = try testArguments(&[_][:0]const u8{"magdalena"});
+    defer arguments.deinit();
+    try testing.expectEqual(Action.help, arguments.action);
+}
+
+test "resolveArguments: --help and -h" {
+    for ([_][:0]const u8{ "--help", "-h", "help" }) |flag| {
+        var arguments = try testArguments(&[_][:0]const u8{ "magdalena", flag });
+        defer arguments.deinit();
+        try testing.expectEqual(Action.help, arguments.action);
+    }
+}
+
+test "resolveArguments: unknown command falls back to help" {
+    var arguments = try testArguments(&[_][:0]const u8{ "magdalena", "bogus" });
+    defer arguments.deinit();
+    try testing.expectEqual(Action.help, arguments.action);
+}
+
+test "resolveArguments: cleanup is triggered by -c and --clean flags" {
+    for ([_][:0]const u8{ "-c", "--clean" }) |flag| {
+        var arguments = try testArguments(&[_][:0]const u8{ "magdalena", flag });
+        defer arguments.deinit();
+        try testing.expectEqual(Action.cleanup, arguments.action);
+    }
+
+    var bare = try testArguments(&[_][:0]const u8{ "magdalena", "clean" });
+    defer bare.deinit();
+    try testing.expectEqual(Action.help, bare.action);
+}
+
+test "resolveArguments: simple subcommands" {
+    const cases = [_]struct { command: [:0]const u8, action: Action }{
+        .{ .command = "recent-directories", .action = .recent_directories },
+        .{ .command = "recent-files", .action = .recent_files },
+        .{ .command = "favorites", .action = .favorites },
+        .{ .command = "goto-directory", .action = .goto_directory },
+        .{ .command = "goto-file", .action = .goto_file },
+        .{ .command = "grep", .action = .grep },
+    };
+    for (cases) |case| {
+        var arguments = try testArguments(&[_][:0]const u8{ "magdalena", case.command });
+        defer arguments.deinit();
+        try testing.expectEqual(case.action, arguments.action);
+    }
+}
+
+test "resolveArguments: search carries query" {
+    var arguments = try testArguments(&[_][:0]const u8{ "magdalena", "search", "needle" });
+    defer arguments.deinit();
+    try testing.expectEqual(Action.search, arguments.action);
+    try testing.expectEqualStrings("needle", arguments.query.?);
+}
+
+test "resolveArguments: log-file carries path, type and action" {
+    var arguments = try testArguments(&[_][:0]const u8{
+        "magdalena",
+        "log-file",
+        "/tmp/x.zig",
+        "zig",
+        "open",
+    });
+    defer arguments.deinit();
+    try testing.expectEqual(Action.log_file, arguments.action);
+    try testing.expectEqualStrings("/tmp/x.zig", arguments.log_path.?);
+    try testing.expectEqualStrings("zig", arguments.file_type.?);
+    try testing.expectEqualStrings("open", arguments.file_action.?);
+}
+
+test "resolveArguments: depth before command via --depth, --depth= and -d" {
+    const cases = [_][:0]const u8{ "--depth", "--depth=4", "-d" };
+    inline for (cases) |form| {
+        const items = if (std.mem.eql(u8, form, "--depth=4"))
+            &[_][:0]const u8{ "magdalena", "--depth=4", "look-directory" }
+        else
+            &[_][:0]const u8{ "magdalena", form, "4", "look-directory" };
+        var arguments = try testArguments(items);
+        defer arguments.deinit();
+        try testing.expectEqual(Action.look_directory, arguments.action);
+        try testing.expectEqual(@as(usize, 4), arguments.depth.?);
+    }
+}
+
+test "resolveArguments: depth after command via tail scan" {
+    var arguments = try testArguments(&[_][:0]const u8{ "magdalena", "look-file", "--depth", "7" });
+    defer arguments.deinit();
+    try testing.expectEqual(Action.look_file, arguments.action);
+    try testing.expectEqual(@as(usize, 7), arguments.depth.?);
+}
+
+test "resolveArguments: invalid depth is an error, not a default" {
+    try testing.expectError(
+        error.InvalidCharacter,
+        testArguments(&[_][:0]const u8{ "magdalena", "--depth", "deep", "look-directory" }),
+    );
+    try testing.expectError(
+        error.InvalidCharacter,
+        testArguments(&[_][:0]const u8{ "magdalena", "look-directory", "--depth=deep" }),
+    );
+}
+
+test "resolveArguments: missing depth value leaves depth null" {
+    var arguments = try testArguments(&[_][:0]const u8{ "magdalena", "look-directory", "--depth" });
+    defer arguments.deinit();
+    try testing.expectEqual(Action.look_directory, arguments.action);
+    try testing.expect(arguments.depth == null);
+}
blob - e7e32675b7303c9a6a5deb82d0def49d533d80bb (mode 644)
blob + /dev/null
--- src/cli.zig
+++ /dev/null
@@ -1,423 +0,0 @@
-const std = @import("std");
-const io = @import("io.zig");
-
-pub const Action = enum {
-    recent_dirs,
-    recent_files,
-    favorites,
-    search,
-    goto_dir,
-    goto_file,
-    look_file,
-    look_dir,
-    grep,
-    cleanup,
-    log_dir,
-    log_file,
-    help,
-};
-
-pub const Args = struct {
-    action: Action,
-    query: ?[]const u8 = null,
-    log_path: ?[]const u8 = null,
-    file_type: ?[]const u8 = null,
-    file_action: ?[]const u8 = null,
-    depth: ?usize = null,
-
-    _cla: CommandLineArgs,
-
-    pub fn deinit(self: Args) void {
-        self._cla.deinit();
-    }
-};
-
-// Minimal command-line parser. Replaces zul.CommandLineArgs, whose `parse`
-// still calls the removed `std.process.argsWithAllocator`; the parsing
-// semantics (flag/value/tail handling) are preserved exactly.
-pub const CommandLineArgs = struct {
-    _arena: *std.heap.ArenaAllocator,
-    _lookup: std.StringHashMapUnmanaged([]const u8),
-
-    exe: []const u8,
-    tail: []const [:0]const u8,
-
-    pub fn parse(parent: std.mem.Allocator, source: std.process.Args) !CommandLineArgs {
-        const arena = try parent.create(std.heap.ArenaAllocator);
-        errdefer parent.destroy(arena);
-
-        arena.* = std.heap.ArenaAllocator.init(parent);
-        errdefer arena.deinit();
-
-        const items = try source.toSlice(arena.allocator());
-        return finishParse(arena, items);
-    }
-
-    // Parses an already-collected argv. Split out from `parse` so tests can
-    // feed a literal slice without constructing a `std.process.Args`.
-    fn finishParse(arena: *std.heap.ArenaAllocator, items: []const [:0]const u8) !CommandLineArgs {
-        const allocator = arena.allocator();
-
-        var lookup: std.StringHashMapUnmanaged([]const u8) = .{};
-
-        if (items.len == 0) {
-            return .{ .exe = "", .tail = &.{}, ._arena = arena, ._lookup = lookup };
-        }
-
-        const exe = items[0];
-
-        var i: usize = 1;
-        var tail_start: usize = 1;
-
-        while (i < items.len) {
-            const arg = items[i];
-            if (arg.len == 1 or arg[0] != '-') {
-                // can't be a valid parameter, so it must be the start of our tail
-                break;
-            }
-
-            if (arg[1] == '-') {
-                const kv = KeyValue.from(arg[2..], items, &i);
-                try lookup.put(allocator, kv.key, kv.value);
-            } else {
-                const kv = KeyValue.from(arg[1..], items, &i);
-                const key = kv.key;
-
-                // -xvf file.tar.gz parses into x=>"", v=>"", f=>"file.tar.gz"
-                for (0..key.len - 1) |j| {
-                    try lookup.put(allocator, key[j .. j + 1], "");
-                }
-                try lookup.put(allocator, key[key.len - 1 ..], kv.value);
-            }
-            tail_start = i;
-        }
-
-        return .{
-            .exe = exe,
-            .tail = items[tail_start..],
-            ._arena = arena,
-            ._lookup = lookup,
-        };
-    }
-
-    pub fn deinit(self: CommandLineArgs) void {
-        const arena = self._arena;
-        const allocator = arena.child_allocator;
-        arena.deinit();
-        allocator.destroy(arena);
-    }
-
-    pub fn contains(self: *const CommandLineArgs, name: []const u8) bool {
-        return self._lookup.contains(name);
-    }
-
-    pub fn get(self: *const CommandLineArgs, name: []const u8) ?[]const u8 {
-        return self._lookup.get(name);
-    }
-};
-
-const KeyValue = struct {
-    key: []const u8,
-    value: []const u8,
-
-    fn from(key: []const u8, items: []const [:0]const u8, i: *usize) KeyValue {
-        const item_index = i.*;
-        if (std.mem.indexOfScalarPos(u8, key, 0, '=')) |pos| {
-            // this parameter is in the form of --key=value, or -k=value
-            i.* = item_index + 1;
-            return .{ .key = key[0..pos], .value = key[pos + 1 ..] };
-        }
-
-        if (item_index == items.len - 1 or items[item_index + 1][0] == '-') {
-            // key is at the end of the arguments OR the next argument starts
-            // with a '-'. This means this key has no value.
-            i.* = item_index + 1;
-            return .{ .key = key, .value = "" };
-        }
-
-        // skip the current key, and the next arg (which is our value)
-        i.* = item_index + 2;
-        return .{ .key = key, .value = items[item_index + 1] };
-    }
-};
-
-pub fn parseArgs(allocator: std.mem.Allocator, source: std.process.Args) !Args {
-    const cla = try CommandLineArgs.parse(allocator, source);
-    errdefer cla.deinit();
-    return resolveArgs(cla);
-}
-
-// Maps a parsed command line onto an `Args`. Split from `parseArgs` so tests
-// can drive it from a `CommandLineArgs` built off a literal slice. On success
-// the returned `Args` owns `cla` (freed via `Args.deinit`).
-fn resolveArgs(cla: CommandLineArgs) !Args {
-    var res = Args{
-        .action = .help,
-        ._cla = cla,
-    };
-
-    if (cla.contains("help") or cla.contains("-h") or cla.contains("--help")) {
-        res.action = .help;
-        return res;
-    }
-
-    if (cla.contains("clean") or cla.contains("c")) {
-        res.action = .cleanup;
-        return res;
-    }
-
-    if (cla.get("depth")) |d| {
-        res.depth = try std.fmt.parseInt(usize, d, 10);
-    } else if (cla.get("d")) |d| {
-        res.depth = try std.fmt.parseInt(usize, d, 10);
-    }
-
-    if (res.depth == null) {
-        for (cla.tail, 0..) |arg, i| {
-            if (std.mem.startsWith(u8, arg, "--depth=")) {
-                res.depth = try std.fmt.parseInt(usize, arg["--depth=".len..], 10);
-            } else if (std.mem.eql(u8, arg, "--depth") or std.mem.eql(u8, arg, "-d")) {
-                if (i + 1 < cla.tail.len) {
-                    res.depth = try std.fmt.parseInt(usize, cla.tail[i + 1], 10);
-                }
-            }
-            if (res.depth != null) break;
-        }
-    }
-
-    if (cla.tail.len == 0) {
-        return res;
-    }
-
-    const cmd_str = cla.tail[0];
-    if (std.mem.eql(u8, cmd_str, "recent-dirs")) {
-        res.action = .recent_dirs;
-    } else if (std.mem.eql(u8, cmd_str, "recent-files")) {
-        res.action = .recent_files;
-    } else if (std.mem.eql(u8, cmd_str, "favorites")) {
-        res.action = .favorites;
-    } else if (std.mem.eql(u8, cmd_str, "search")) {
-        res.action = .search;
-    } else if (std.mem.eql(u8, cmd_str, "goto-dir")) {
-        res.action = .goto_dir;
-    } else if (std.mem.eql(u8, cmd_str, "goto-file")) {
-        res.action = .goto_file;
-    } else if (std.mem.eql(u8, cmd_str, "look-file")) {
-        res.action = .look_file;
-    } else if (std.mem.eql(u8, cmd_str, "look-dir")) {
-        res.action = .look_dir;
-    } else if (std.mem.eql(u8, cmd_str, "grep")) {
-        res.action = .grep;
-    } else if (std.mem.eql(u8, cmd_str, "log-dir")) {
-        res.action = .log_dir;
-    } else if (std.mem.eql(u8, cmd_str, "log-file")) {
-        res.action = .log_file;
-    } else {
-        res.action = .help;
-        return res;
-    }
-
-    if (cla.tail.len > 1) {
-        const arg = cla.tail[1];
-        if (res.action == .search) {
-            res.query = arg;
-        } else if (res.action == .log_dir) {
-            res.log_path = arg;
-        } else if (res.action == .log_file) {
-            res.log_path = arg;
-            if (cla.tail.len > 2) res.file_type = cla.tail[2];
-            if (cla.tail.len > 3) res.file_action = cla.tail[3];
-        }
-    }
-
-    return res;
-}
-
-pub fn printUsage() !void {
-    var buf: [4096]u8 = undefined;
-    var writer = io.getStdout(&buf);
-    try writer.print(
-        \\Usage:
-        \\  magdalena recent-dirs
-        \\  magdalena recent-files
-        \\  magdalena favorites
-        \\  magdalena goto-dir
-        \\  magdalena goto-file
-        \\  magdalena log-dir <path>
-        \\  magdalena log-file <path> [type] [action]
-        \\  magdalena search <query>
-        \\  magdalena look-file [--depth <n>]
-        \\  magdalena look-dir [--depth <n>]
-        \\  magdalena grep
-        \\
-        \\Options:
-        \\  -c, --clean             Perform cleanup
-        \\  -h, --help              Show this help
-        \\
-    , .{});
-    try writer.end();
-}
-
-const testing = std.testing;
-
-// Builds a CommandLineArgs straight from a literal argv, bypassing
-// std.process.Args. Mirrors `parse`'s ownership shape exactly (errdefer +
-// error-union return). Caller must `defer cla.deinit()`.
-fn testCla(items: []const [:0]const u8) !CommandLineArgs {
-    const arena = try testing.allocator.create(std.heap.ArenaAllocator);
-    errdefer testing.allocator.destroy(arena);
-    arena.* = std.heap.ArenaAllocator.init(testing.allocator);
-    errdefer arena.deinit();
-    return CommandLineArgs.finishParse(arena, items);
-}
-
-// Builds an Args from a literal argv, mirroring `parseArgs`. Caller must
-// `defer args.deinit()`.
-fn testArgs(items: []const [:0]const u8) !Args {
-    const cla = try testCla(items);
-    errdefer cla.deinit();
-    return resolveArgs(cla);
-}
-
-test "CommandLineArgs: empty argv" {
-    var cla = try testCla(&.{});
-    defer cla.deinit();
-    try testing.expectEqualStrings("", cla.exe);
-    try testing.expectEqual(@as(usize, 0), cla.tail.len);
-}
-
-test "CommandLineArgs: exe only" {
-    var cla = try testCla(&[_][:0]const u8{"magdalena"});
-    defer cla.deinit();
-    try testing.expectEqualStrings("magdalena", cla.exe);
-    try testing.expectEqual(@as(usize, 0), cla.tail.len);
-}
-
-test "CommandLineArgs: flags with and without values" {
-    var cla = try testCla(&[_][:0]const u8{ "bin", "--level", "info", "--silent", "-p", "5432", "-x" });
-    defer cla.deinit();
-    try testing.expectEqualStrings("bin", cla.exe);
-    try testing.expectEqual(@as(usize, 0), cla.tail.len);
-    try testing.expectEqualStrings("info", cla.get("level").?);
-    try testing.expect(cla.contains("silent"));
-    try testing.expectEqualStrings("", cla.get("silent").?);
-    try testing.expectEqualStrings("5432", cla.get("p").?);
-    try testing.expect(cla.contains("x"));
-}
-
-test "CommandLineArgs: key=value form" {
-    var cla = try testCla(&[_][:0]const u8{ "bin", "--level=error", "-p=6669" });
-    defer cla.deinit();
-    try testing.expectEqualStrings("error", cla.get("level").?);
-    try testing.expectEqualStrings("6669", cla.get("p").?);
-}
-
-test "CommandLineArgs: bundled single-char flags" {
-    var cla = try testCla(&[_][:0]const u8{ "bin", "-xvf", "file.tar.gz" });
-    defer cla.deinit();
-    try testing.expect(cla.contains("x"));
-    try testing.expectEqualStrings("", cla.get("x").?);
-    try testing.expect(cla.contains("v"));
-    try testing.expectEqualStrings("file.tar.gz", cla.get("f").?);
-}
-
-test "CommandLineArgs: tail begins at first positional" {
-    // `-l` is followed by `--k`, which starts with '-', so `-l` takes no value;
-    // `--k` consumes the following `x`; the tail then starts at the first
-    // non-flag token.
-    var cla = try testCla(&[_][:0]const u8{ "bin", "-l", "--k", "x", "ts", "-p=6669", "hello" });
-    defer cla.deinit();
-    try testing.expect(cla.contains("l"));
-    try testing.expectEqualStrings("x", cla.get("k").?);
-    try testing.expectEqual(@as(usize, 3), cla.tail.len);
-    try testing.expectEqualStrings("ts", cla.tail[0]);
-    try testing.expectEqualStrings("-p=6669", cla.tail[1]);
-    try testing.expectEqualStrings("hello", cla.tail[2]);
-}
-
-test "resolveArgs: no args is help" {
-    var args = try testArgs(&[_][:0]const u8{"magdalena"});
-    defer args.deinit();
-    try testing.expectEqual(Action.help, args.action);
-}
-
-test "resolveArgs: --help and -h" {
-    for ([_][:0]const u8{ "--help", "-h", "help" }) |flag| {
-        var args = try testArgs(&[_][:0]const u8{ "magdalena", flag });
-        defer args.deinit();
-        try testing.expectEqual(Action.help, args.action);
-    }
-}
-
-test "resolveArgs: unknown command falls back to help" {
-    var args = try testArgs(&[_][:0]const u8{ "magdalena", "bogus" });
-    defer args.deinit();
-    try testing.expectEqual(Action.help, args.action);
-}
-
-test "resolveArgs: cleanup is triggered by -c and --clean flags" {
-    // Cleanup is a flag, not a subcommand: a bare `clean` positional is not
-    // recognized and falls through to help.
-    for ([_][:0]const u8{ "-c", "--clean" }) |flag| {
-        var args = try testArgs(&[_][:0]const u8{ "magdalena", flag });
-        defer args.deinit();
-        try testing.expectEqual(Action.cleanup, args.action);
-    }
-
-    var bare = try testArgs(&[_][:0]const u8{ "magdalena", "clean" });
-    defer bare.deinit();
-    try testing.expectEqual(Action.help, bare.action);
-}
-
-test "resolveArgs: simple subcommands" {
-    const cases = [_]struct { cmd: [:0]const u8, action: Action }{
-        .{ .cmd = "recent-dirs", .action = .recent_dirs },
-        .{ .cmd = "recent-files", .action = .recent_files },
-        .{ .cmd = "favorites", .action = .favorites },
-        .{ .cmd = "goto-dir", .action = .goto_dir },
-        .{ .cmd = "goto-file", .action = .goto_file },
-        .{ .cmd = "grep", .action = .grep },
-    };
-    for (cases) |c| {
-        var args = try testArgs(&[_][:0]const u8{ "magdalena", c.cmd });
-        defer args.deinit();
-        try testing.expectEqual(c.action, args.action);
-    }
-}
-
-test "resolveArgs: search carries query" {
-    var args = try testArgs(&[_][:0]const u8{ "magdalena", "search", "needle" });
-    defer args.deinit();
-    try testing.expectEqual(Action.search, args.action);
-    try testing.expectEqualStrings("needle", args.query.?);
-}
-
-test "resolveArgs: log-file carries path, type and action" {
-    var args = try testArgs(&[_][:0]const u8{ "magdalena", "log-file", "/tmp/x.zig", "zig", "open" });
-    defer args.deinit();
-    try testing.expectEqual(Action.log_file, args.action);
-    try testing.expectEqualStrings("/tmp/x.zig", args.log_path.?);
-    try testing.expectEqualStrings("zig", args.file_type.?);
-    try testing.expectEqualStrings("open", args.file_action.?);
-}
-
-test "resolveArgs: depth before command via --depth, --depth= and -d" {
-    const cases = [_][:0]const u8{ "--depth", "--depth=4", "-d" };
-    inline for (cases) |form| {
-        const items = if (std.mem.eql(u8, form, "--depth=4"))
-            &[_][:0]const u8{ "magdalena", "--depth=4", "look-dir" }
-        else
-            &[_][:0]const u8{ "magdalena", form, "4", "look-dir" };
-        var args = try testArgs(items);
-        defer args.deinit();
-        try testing.expectEqual(Action.look_dir, args.action);
-        try testing.expectEqual(@as(usize, 4), args.depth.?);
-    }
-}
-
-test "resolveArgs: depth after command via tail scan" {
-    var args = try testArgs(&[_][:0]const u8{ "magdalena", "look-file", "--depth", "7" });
-    defer args.deinit();
-    try testing.expectEqual(Action.look_file, args.action);
-    try testing.expectEqual(@as(usize, 7), args.depth.?);
-}
blob - /dev/null
blob + 05b833ff56a5ef435751169c25c42f33dd2290e7 (mode 644)
--- /dev/null
+++ src/config.zig
@@ -0,0 +1,170 @@
+const std = @import("std");
+const assert = std.debug.assert;
+const input_output = @import("input_output.zig");
+
+pub const Theme = struct {
+    prompt: ?[]const u8 = null,
+    match: ?[]const u8 = null,
+    cursor_row: ?[]const u8 = null,
+    status: ?[]const u8 = null,
+    preview_current: ?[]const u8 = null,
+};
+
+pub const Config = struct {
+    openers: []Opener = &.{},
+    ignored_patterns: []const []const u8 = &.{},
+    favorites: []const []const u8 = &.{},
+    editor: ?[]const u8 = null,
+    max_depth: usize = 3,
+    theme: Theme = .{},
+    overrides: []FolderOverride = &.{},
+
+    pub const Opener = struct {
+        extensions: []const []const u8,
+        action: []const u8,
+        command: []const u8,
+    };
+};
+
+pub const FolderOverride = struct {
+    path: []const u8,
+    openers: ?[]Config.Opener = null,
+    ignored_patterns: ?[]const []const u8 = null,
+    favorites: ?[]const []const u8 = null,
+    editor: ?[]const u8 = null,
+    max_depth: ?usize = null,
+    theme: ?Theme = null,
+};
+
+pub fn applyFolderOverride(base: Config, current_directory: []const u8) Config {
+    for (base.overrides, 0..) |*override, override_index| {
+        assert(override_index < base.overrides.len);
+        assert(override.path.len > 0);
+        if (!std.mem.eql(u8, current_directory, override.path)) continue;
+        var result = base;
+        if (override.openers) |openers| result.openers = openers;
+        if (override.ignored_patterns) |patterns| result.ignored_patterns = patterns;
+        if (override.favorites) |favorites| result.favorites = favorites;
+        if (override.editor) |editor| result.editor = editor;
+        if (override.max_depth) |depth| result.max_depth = depth;
+        if (override.theme) |theme| result.theme = theme;
+        return result;
+    }
+    return base;
+}
+
+const max_config_bytes: usize = 1024 * 1024;
+
+pub fn loadConfig(allocator: std.mem.Allocator) ?std.json.Parsed(Config) {
+    const home = input_output.getenv("HOME") orelse {
+        input_output.warn("HOME not set, skipping config load\n", .{});
+        return null;
+    };
+    if (home.len == 0) {
+        input_output.warn("HOME is empty, skipping config load\n", .{});
+        return null;
+    }
+    assert(home.len > 0);
+    const current_directory = std.Io.Dir.cwd();
+    const path_parts = [_][]const u8{ home, ".config", "magdalena", "config.json" };
+    const path = std.fs.path.join(allocator, &path_parts) catch {
+        input_output.warn("failed to allocate config path\n", .{});
+        return null;
+    };
+    defer allocator.free(path);
+
+    const content = current_directory.readFileAlloc(
+        input_output.runtime(),
+        path,
+        allocator,
+        .limited(max_config_bytes),
+    ) catch |err| {
+        if (err == error.StreamTooLong) {
+            input_output.warn(
+                "config too large (over {d} bytes), ignoring {s}\n",
+                .{ max_config_bytes, path },
+            );
+            return null;
+        }
+        if (err != error.FileNotFound) {
+            input_output.warn("failed to read config {s}: {}\n", .{ path, err });
+        }
+        return null;
+    };
+    defer allocator.free(content);
+
+    return std.json.parseFromSlice(Config, allocator, content, .{
+        .ignore_unknown_fields = true,
+        .allocate = .alloc_always,
+    }) catch |err| {
+        input_output.warn("failed to parse config {s}: {}\n", .{ path, err });
+        return null;
+    };
+}
+
+const testing = std.testing;
+
+test "applyFolderOverride: no overrides returns base" {
+    const base = Config{ .max_depth = 3, .editor = "vim" };
+    const got = applyFolderOverride(base, "/anywhere");
+    try testing.expectEqual(@as(usize, 3), got.max_depth);
+    try testing.expectEqualStrings("vim", got.editor.?);
+}
+
+test "applyFolderOverride: matching path overrides only provided fields" {
+    const ignored = [_][]const u8{ "node_modules", ".git" };
+    var overrides = [_]FolderOverride{
+        .{ .path = "/work", .max_depth = 9, .editor = "nvim" },
+    };
+    const base = Config{
+        .max_depth = 3,
+        .editor = "vim",
+        .ignored_patterns = &ignored,
+        .overrides = &overrides,
+    };
+
+    const got = applyFolderOverride(base, "/work");
+    try testing.expectEqual(@as(usize, 9), got.max_depth);
+    try testing.expectEqualStrings("nvim", got.editor.?);
+    try testing.expectEqual(base.ignored_patterns.ptr, got.ignored_patterns.ptr);
+}
+
+test "applyFolderOverride: non-matching path leaves base untouched" {
+    var overrides = [_]FolderOverride{
+        .{ .path = "/work", .max_depth = 9 },
+    };
+    const base = Config{ .max_depth = 3, .editor = "vim", .overrides = &overrides };
+
+    const got = applyFolderOverride(base, "/elsewhere");
+    try testing.expectEqual(@as(usize, 3), got.max_depth);
+    try testing.expectEqualStrings("vim", got.editor.?);
+}
+
+test "applyFolderOverride: first matching override wins" {
+    var overrides = [_]FolderOverride{
+        .{ .path = "/work", .max_depth = 5 },
+        .{ .path = "/work", .max_depth = 8 },
+    };
+    const base = Config{ .max_depth = 3, .overrides = &overrides };
+
+    const got = applyFolderOverride(base, "/work");
+    try testing.expectEqual(@as(usize, 5), got.max_depth);
+}
+
+test "applyFolderOverride: theme replaces wholesale" {
+    var overrides = [_]FolderOverride{
+        .{ .path = "/work", .theme = .{ .prompt = "9" } },
+    };
+    const base = Config{
+        .theme = .{ .match = "3" },
+        .overrides = &overrides,
+    };
+
+    const got = applyFolderOverride(base, "/work");
+    try testing.expectEqualStrings("9", got.theme.prompt.?);
+    try testing.expect(got.theme.match == null);
+
+    const kept = applyFolderOverride(base, "/elsewhere");
+    try testing.expectEqualStrings("3", kept.theme.match.?);
+    try testing.expect(kept.theme.prompt == null);
+}
blob - 33160d1c8fe4ff92174d0103c610815c96dde818 (mode 644)
blob + /dev/null
--- src/db.zig
+++ /dev/null
@@ -1,421 +0,0 @@
-const std = @import("std");
-const zul = @import("zul");
-const io = @import("io.zig");
-
-pub const DirectoryEntry = struct {
-    path: []const u8,
-    timestamp: ?[]const u8 = null,
-};
-
-pub const FileEntry = struct {
-    path: []const u8,
-    file_type: []const u8,
-    action: []const u8,
-    timestamp: ?[]const u8 = null,
-};
-
-pub const SearchResult = struct {
-    directories: []DirectoryEntry,
-    files: []FileEntry,
-};
-
-pub const Db = struct {
-    allocator: std.mem.Allocator,
-    base_path: []const u8,
-    dirs_path: []const u8,
-    files_path: []const u8,
-
-    pub fn init(allocator: std.mem.Allocator, path: []const u8) !Db {
-        const base_path = try allocator.dupe(u8, path);
-        errdefer allocator.free(base_path);
-
-        const dirs_path = try std.fs.path.join(allocator, &.{ base_path, "dirs.log" });
-        errdefer allocator.free(dirs_path);
-
-        const files_path = try std.fs.path.join(allocator, &.{ base_path, "files.log" });
-        errdefer allocator.free(files_path);
-
-        const res = Db{
-            .allocator = allocator,
-            .base_path = base_path,
-            .dirs_path = dirs_path,
-            .files_path = files_path,
-        };
-
-        std.Io.Dir.cwd().createDirPath(io.rt(), base_path) catch |err| {
-            if (err != error.PathAlreadyExists) return err;
-        };
-        restrictDirPerms(base_path);
-
-        for (&[_][]const u8{ dirs_path, files_path }) |p| {
-            var f = std.Io.Dir.cwd().openFile(io.rt(), p, .{ .mode = .read_write }) catch |err| {
-                if (err == error.FileNotFound) {
-                    const created = try std.Io.Dir.cwd().createFile(io.rt(), p, .{ .truncate = false, .permissions = .fromMode(0o600) });
-                    created.close(io.rt());
-                    continue;
-                }
-                return err;
-            };
-            // Best effort: tighten perms on pre-existing files too.
-            f.setPermissions(io.rt(), .fromMode(0o600)) catch {};
-            f.close(io.rt());
-        }
-
-        return res;
-    }
-
-    // History logs hold absolute paths with timestamps. Restrict to owner on
-    // a best-effort basis; failures never fail init (read-only parent, ACLs).
-    fn restrictDirPerms(path: []const u8) void {
-        var dir = std.Io.Dir.cwd().openDir(io.rt(), path, .{ .iterate = true }) catch return;
-        defer dir.close(io.rt());
-        dir.setPermissions(io.rt(), .fromMode(0o700)) catch {};
-    }
-
-    pub fn deinit(self: *Db) void {
-        self.allocator.free(self.base_path);
-        self.allocator.free(self.dirs_path);
-        self.allocator.free(self.files_path);
-    }
-
-    pub fn recentDirs(self: *Db) !zul.Managed([]DirectoryEntry) {
-        const content = std.Io.Dir.cwd().readFileAlloc(io.rt(), self.dirs_path, self.allocator, .limited(64 * 1024 * 1024)) catch |err| {
-            if (err == error.FileNotFound) return zul.Managed([]DirectoryEntry).fromJson(try std.json.parseFromSlice([]DirectoryEntry, self.allocator, "[]", .{}));
-            return err;
-        };
-        defer self.allocator.free(content);
-
-        var arena = try self.allocator.create(std.heap.ArenaAllocator);
-        errdefer {
-            arena.deinit();
-            self.allocator.destroy(arena);
-        }
-        arena.* = std.heap.ArenaAllocator.init(self.allocator);
-
-        return .{
-            .arena = arena,
-            .value = try parseDirLog(arena.allocator(), content),
-        };
-    }
-
-    pub fn recentFiles(self: *Db) !zul.Managed([]FileEntry) {
-        const content = std.Io.Dir.cwd().readFileAlloc(io.rt(), self.files_path, self.allocator, .limited(64 * 1024 * 1024)) catch |err| {
-            if (err == error.FileNotFound) return zul.Managed([]FileEntry).fromJson(try std.json.parseFromSlice([]FileEntry, self.allocator, "[]", .{}));
-            return err;
-        };
-        defer self.allocator.free(content);
-
-        var arena = try self.allocator.create(std.heap.ArenaAllocator);
-        errdefer {
-            arena.deinit();
-            self.allocator.destroy(arena);
-        }
-        arena.* = std.heap.ArenaAllocator.init(self.allocator);
-
-        return .{
-            .arena = arena,
-            .value = try parseFileLog(arena.allocator(), content),
-        };
-    }
-
-    pub fn searchHistory(self: *Db, query_str: []const u8) !zul.Managed(SearchResult) {
-        const managed_dirs = try self.recentDirs();
-        defer managed_dirs.deinit();
-        const managed_files = try self.recentFiles();
-        defer managed_files.deinit();
-
-        var arena = try self.allocator.create(std.heap.ArenaAllocator);
-        errdefer {
-            arena.deinit();
-            self.allocator.destroy(arena);
-        }
-        arena.* = std.heap.ArenaAllocator.init(self.allocator);
-        const allocator = arena.allocator();
-
-        var dir_list = std.ArrayListUnmanaged(DirectoryEntry).empty;
-        for (managed_dirs.value) |d| {
-            if (std.mem.indexOf(u8, d.path, query_str) != null) {
-                try dir_list.append(allocator, .{
-                    .path = try allocator.dupe(u8, d.path),
-                    .timestamp = if (d.timestamp) |ts| try allocator.dupe(u8, ts) else null,
-                });
-            }
-        }
-
-        var file_list = std.ArrayListUnmanaged(FileEntry).empty;
-        for (managed_files.value) |f| {
-            if (std.mem.indexOf(u8, f.path, query_str) != null) {
-                try file_list.append(allocator, .{
-                    .path = try allocator.dupe(u8, f.path),
-                    .file_type = try allocator.dupe(u8, f.file_type),
-                    .action = try allocator.dupe(u8, f.action),
-                    .timestamp = if (f.timestamp) |ts| try allocator.dupe(u8, ts) else null,
-                });
-            }
-        }
-
-        return .{
-            .arena = arena,
-            .value = .{
-                .directories = try dir_list.toOwnedSlice(allocator),
-                .files = try file_list.toOwnedSlice(allocator),
-            },
-        };
-    }
-
-    fn resolvePath(self: *Db, path: []const u8) ![]const u8 {
-        return std.Io.Dir.cwd().realPathFileAlloc(io.rt(), path, self.allocator) catch |err| {
-            if (err == error.FileNotFound) {
-                io.warn("path not found for logging: {s}\n", .{path});
-            }
-            return try std.fs.path.resolve(self.allocator, &.{ ".", path });
-        };
-    }
-
-    pub fn logDir(self: *Db, dir_path: []const u8) !void {
-        const abs_path = try self.resolvePath(dir_path);
-        defer self.allocator.free(abs_path);
-
-        const file = std.Io.Dir.cwd().openFile(io.rt(), self.dirs_path, .{ .mode = .write_only }) catch |err| switch (err) {
-            error.FileNotFound => try std.Io.Dir.cwd().createFile(io.rt(), self.dirs_path, .{ .truncate = false }),
-            else => return err,
-        };
-        defer file.close(io.rt());
-
-        const now = zul.DateTime.now(io.rt());
-        var ts_buf: [64]u8 = undefined;
-        var ts_writer = std.Io.Writer.fixed(&ts_buf);
-        try now.format(&ts_writer);
-        const ts = ts_buf[0..ts_writer.end];
-
-        var fbuf: [4096]u8 = undefined;
-        var fw = file.writer(io.rt(), &fbuf);
-        try fw.seekTo(try file.length(io.rt()));
-        try fw.interface.print("{s}|{s}\n", .{ ts, abs_path });
-        try fw.interface.flush();
-    }
-
-    pub fn logFile(self: *Db, file_path: []const u8, file_type: []const u8, action: []const u8) !void {
-        const abs_path = try self.resolvePath(file_path);
-        defer self.allocator.free(abs_path);
-
-        const file = std.Io.Dir.cwd().openFile(io.rt(), self.files_path, .{ .mode = .write_only }) catch |err| switch (err) {
-            error.FileNotFound => try std.Io.Dir.cwd().createFile(io.rt(), self.files_path, .{ .truncate = false }),
-            else => return err,
-        };
-        defer file.close(io.rt());
-
-        const now = zul.DateTime.now(io.rt());
-        var ts_buf: [64]u8 = undefined;
-        var ts_writer = std.Io.Writer.fixed(&ts_buf);
-        try now.format(&ts_writer);
-        const ts = ts_buf[0..ts_writer.end];
-
-        var fbuf: [4096]u8 = undefined;
-        var fw = file.writer(io.rt(), &fbuf);
-        try fw.seekTo(try file.length(io.rt()));
-        try fw.interface.print("{s}|{s}|{s}|{s}\n", .{ ts, file_type, action, abs_path });
-        try fw.interface.flush();
-    }
-
-    pub fn cleanup(self: *Db) !void {
-        try self.cleanupFile(self.dirs_path, 1);
-        try self.cleanupFile(self.files_path, 3);
-    }
-
-    fn cleanupFile(self: *Db, path: []const u8, path_col_idx: usize) !void {
-        io.warn("Cleaning up {s}...\n", .{path});
-
-        const content = try std.Io.Dir.cwd().readFileAlloc(io.rt(), path, self.allocator, .limited(64 * 1024 * 1024));
-        defer self.allocator.free(content);
-
-        var seen = std.StringHashMap(void).init(self.allocator);
-        defer seen.deinit();
-
-        var lines = std.ArrayListUnmanaged([]const u8).empty;
-        defer lines.deinit(self.allocator);
-
-        var total_entries: usize = 0;
-        var missing_entries: usize = 0;
-        var inaccessible_entries: usize = 0;
-        var it = std.mem.splitBackwardsScalar(u8, content, '\n');
-        while (it.next()) |line| {
-            const trimmed = std.mem.trim(u8, line, " \r\t");
-            if (trimmed.len == 0) continue;
-            total_entries += 1;
-
-            var line_it = std.mem.splitScalar(u8, trimmed, '|');
-            var item_path: ?[]const u8 = null;
-            var i: usize = 0;
-            while (line_it.next()) |col| : (i += 1) {
-                if (i == path_col_idx) {
-                    item_path = col;
-                    break;
-                }
-            }
-
-            const p = item_path orelse continue;
-
-            if (seen.contains(p)) continue;
-
-            std.Io.Dir.cwd().access(io.rt(), p, .{}) catch |err| {
-                if (err == error.FileNotFound) {
-                    missing_entries += 1;
-                    continue;
-                }
-                io.warn("skipping inaccessible entry {s}: {}\n", .{ p, err });
-                inaccessible_entries += 1;
-                continue;
-            };
-
-            try seen.put(p, {});
-            try lines.append(self.allocator, trimmed);
-        }
-
-        const duplicates = total_entries - lines.items.len - missing_entries - inaccessible_entries;
-        io.warn("  Entries: {d} total, {d} unique, {d} missing, {d} inaccessible, {d} duplicates removed\n", .{ total_entries, lines.items.len, missing_entries, inaccessible_entries, duplicates });
-
-        const write_file = try std.Io.Dir.cwd().createFile(io.rt(), path, .{ .truncate = true, .permissions = .fromMode(0o600) });
-        defer write_file.close(io.rt());
-
-        var write_buf: [4096]u8 = undefined;
-        var writer = write_file.writer(io.rt(), &write_buf);
-
-        var i: usize = lines.items.len;
-        while (i > 0) {
-            i -= 1;
-            try writer.interface.print("{s}\n", .{lines.items[i]});
-        }
-        try writer.end();
-    }
-};
-
-pub fn getDefaultDbPath(allocator: std.mem.Allocator) ![]const u8 {
-    if (io.getenv("HOME")) |home| {
-        return std.fs.path.join(allocator, &.{ home, ".magdalena" });
-    }
-    return error.HomeNotFound;
-}
-
-// Parses a `dirs.log` body (`timestamp|path` lines) newest-first, keeping only
-// the first occurrence of each path. Pure logic, no IO — split out for testing.
-fn parseDirLog(allocator: std.mem.Allocator, content: []const u8) ![]DirectoryEntry {
-    var list = std.ArrayListUnmanaged(DirectoryEntry).empty;
-    var seen = std.StringHashMap(void).init(allocator);
-    defer seen.deinit();
-
-    var it = std.mem.splitBackwardsScalar(u8, content, '\n');
-    while (it.next()) |line| {
-        const trimmed = std.mem.trim(u8, line, " \r\t");
-        if (trimmed.len == 0) continue;
-
-        var line_it = std.mem.splitScalar(u8, trimmed, '|');
-        const ts = line_it.next() orelse continue;
-        const dir_path = line_it.next() orelse continue;
-
-        if (seen.contains(dir_path)) continue;
-        try seen.put(dir_path, {});
-
-        try list.append(allocator, .{
-            .path = try allocator.dupe(u8, dir_path),
-            .timestamp = try allocator.dupe(u8, ts),
-        });
-    }
-
-    return list.toOwnedSlice(allocator);
-}
-
-// Parses a `files.log` body (`timestamp|type|action|path` lines) newest-first,
-// keeping only the first occurrence of each path.
-fn parseFileLog(allocator: std.mem.Allocator, content: []const u8) ![]FileEntry {
-    var list = std.ArrayListUnmanaged(FileEntry).empty;
-    var seen = std.StringHashMap(void).init(allocator);
-    defer seen.deinit();
-
-    var it = std.mem.splitBackwardsScalar(u8, content, '\n');
-    while (it.next()) |line| {
-        const trimmed = std.mem.trim(u8, line, " \r\t");
-        if (trimmed.len == 0) continue;
-
-        var line_it = std.mem.splitScalar(u8, trimmed, '|');
-        const ts = line_it.next() orelse continue;
-        const ftype = line_it.next() orelse continue;
-        const action = line_it.next() orelse continue;
-        const fpath = line_it.next() orelse continue;
-
-        if (seen.contains(fpath)) continue;
-        try seen.put(fpath, {});
-
-        try list.append(allocator, .{
-            .path = try allocator.dupe(u8, fpath),
-            .file_type = try allocator.dupe(u8, ftype),
-            .action = try allocator.dupe(u8, action),
-            .timestamp = try allocator.dupe(u8, ts),
-        });
-    }
-
-    return list.toOwnedSlice(allocator);
-}
-
-const testing = std.testing;
-
-test "parseDirLog: 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 parseDirLog(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 "parseDirLog: 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 parseDirLog(arena.allocator(), content);
-    try testing.expectEqual(@as(usize, 1), entries.len);
-    try testing.expectEqualStrings("/only", entries[0].path);
-}
-
-test "parseDirLog: empty input yields no entries" {
-    var arena = std.heap.ArenaAllocator.init(testing.allocator);
-    defer arena.deinit();
-    const entries = try parseDirLog(arena.allocator(), "");
-    try testing.expectEqual(@as(usize, 0), entries.len);
-}
-
-test "parseFileLog: 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 parseFileLog(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 "parseFileLog: 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 parseFileLog(arena.allocator(), content);
-    try testing.expectEqual(@as(usize, 1), entries.len);
-    try testing.expectEqualStrings("/y", entries[0].path);
-}
blob - /dev/null
blob + ed420fff23c845cd660318299a6f4d9e9acf5948 (mode 644)
--- /dev/null
+++ src/database.zig
@@ -0,0 +1,799 @@
+const std = @import("std");
+const assert = std.debug.assert;
+const input_output = @import("input_output.zig");
+
+const max_log_bytes: usize = 64 * 1024 * 1024;
+
+comptime {
+    assert(max_log_bytes > 0);
+}
+
+pub const DirectoryEntry = struct {
+    path: []const u8,
+    timestamp: ?[]const u8 = null,
+};
+
+pub const FileEntry = struct {
+    path: []const u8,
+    file_type: []const u8,
+    action: []const u8,
+    timestamp: ?[]const u8 = null,
+};
+
+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.fs.path.join(allocator, &.{
+            base_path,
+            "directories.log",
+        });
+        assert(directories_path.len > base_path.len);
+        errdefer allocator.free(directories_path);
+
+        const files_path = try std.fs.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,
+        };
+        assert(result.base_path.len == path.len);
+        assert(result.directories_path.len > result.base_path.len);
+        assert(result.files_path.len > result.base_path.len);
+
+        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 log_paths = [_][]const u8{ directories_path, files_path };
+        assert(log_paths.len == 2);
+        const working_directory = std.Io.Dir.cwd();
+        const runtime_handle = input_output.runtime();
+        for (log_paths, 0..) |log_path, log_index| {
+            assert(log_index < log_paths.len);
+            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 working_directory.createFile(runtime_handle, log_path, .{
+                        .read = false,
+                        .truncate = false,
+                        .exclusive = false,
+                        .lock = .none,
+                        .lock_nonblocking = false,
+                        .permissions = .fromMode(0o600),
+                        .resolve_beneath = false,
+                    });
+                    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.fs.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);
+
+        const access_options: std.Io.Dir.AccessOptions = .{
+            .follow_symlinks = true,
+            .read = false,
+            .write = false,
+            .execute = false,
+        };
+        std.Io.Dir.cwd().access(
+            input_output.runtime(),
+            directories_path,
+            access_options,
+        ) catch |new_err| {
+            if (new_err != error.FileNotFound) return;
+            std.Io.Dir.cwd().access(
+                input_output.runtime(),
+                legacy_path,
+                access_options,
+            ) 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);
+        assert(arena.child_allocator.ptr == self.allocator.ptr);
+        return 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),
+        );
+    }
+
+    fn emptyManagedList(self: *Database, comptime T: type) !std.json.Parsed(T) {
+        const parsed = try std.json.parseFromSlice(T, self.allocator, "[]", .{
+            .allocate = .alloc_always,
+            .ignore_unknown_fields = true,
+        });
+        const managed = parsed;
+        assert(managed.value.len == 0);
+        return managed;
+    }
+
+    pub fn recentDirectories(self: *Database) !std.json.Parsed([]DirectoryEntry) {
+        assert(self.directories_path.len > 0);
+        const content = self.readLogBody(self.directories_path) catch |err| {
+            if (err == error.FileNotFound) {
+                return try self.emptyManagedList([]DirectoryEntry);
+            }
+            return err;
+        };
+        defer self.allocator.free(content);
+
+        const arena = try self.createLogArena();
+        errdefer {
+            arena.deinit();
+            self.allocator.destroy(arena);
+        }
+        assert(arena.child_allocator.ptr == self.allocator.ptr);
+
+        return .{
+            .arena = arena,
+            .value = try parseDirectoryLog(arena.allocator(), content),
+        };
+    }
+
+    pub fn recentFiles(self: *Database) !std.json.Parsed([]FileEntry) {
+        assert(self.files_path.len > 0);
+        const content = self.readLogBody(self.files_path) catch |err| {
+            if (err == error.FileNotFound) {
+                return try self.emptyManagedList([]FileEntry);
+            }
+            return err;
+        };
+        defer self.allocator.free(content);
+
+        const arena = try self.createLogArena();
+        errdefer {
+            arena.deinit();
+            self.allocator.destroy(arena);
+        }
+        assert(arena.child_allocator.ptr == self.allocator.ptr);
+
+        return .{
+            .arena = arena,
+            .value = try parseFileLog(arena.allocator(), content),
+        };
+    }
+
+    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 {
+            arena.deinit();
+            self.allocator.destroy(arena);
+        }
+        assert(arena.child_allocator.ptr == self.allocator.ptr);
+        const allocator = arena.allocator();
+
+        var dir_list = std.ArrayListUnmanaged(DirectoryEntry).empty;
+        for (managed_directories.value, 0..) |entry, entry_index| {
+            assert(entry_index < managed_directories.value.len);
+            assert(entry.path.len > 0);
+            if (std.mem.indexOf(u8, entry.path, query) == null) {
+                continue;
+            }
+            try dir_list.append(allocator, .{
+                .path = try allocator.dupe(u8, entry.path),
+                .timestamp = try dupeOptionalText(allocator, entry.timestamp),
+            });
+        }
+
+        var file_list = std.ArrayListUnmanaged(FileEntry).empty;
+        for (managed_files.value, 0..) |entry, entry_index| {
+            assert(entry_index < managed_files.value.len);
+            assert(entry.path.len > 0);
+            if (std.mem.indexOf(u8, entry.path, query) == null) {
+                continue;
+            }
+            try file_list.append(allocator, .{
+                .path = try allocator.dupe(u8, entry.path),
+                .file_type = try allocator.dupe(u8, entry.file_type),
+                .action = try allocator.dupe(u8, entry.action),
+                .timestamp = try dupeOptionalText(allocator, entry.timestamp),
+            });
+        }
+
+        return .{
+            .arena = arena,
+            .value = .{
+                .directories = try dir_list.toOwnedSlice(allocator),
+                .files = try file_list.toOwnedSlice(allocator),
+            },
+        };
+    }
+
+    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;
+    }
+
+    fn resolvePath(self: *Database, path: []const u8) ![]const u8 {
+        assert(path.len > 0);
+        if (std.Io.Dir.cwd().realPathFileAlloc(
+            input_output.runtime(),
+            path,
+            self.allocator,
+        )) |absolute| {
+            assert(absolute.len > 0);
+            defer self.allocator.free(absolute);
+            const copy = try self.allocator.dupe(u8, absolute);
+            assert(copy.len == absolute.len);
+            return copy;
+        } else |err| {
+            if (err == error.FileNotFound) {
+                input_output.warn("path not found for logging: {s}\n", .{path});
+            }
+            return try std.fs.path.resolve(self.allocator, &.{ ".", path });
+        }
+    }
+
+    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 std.Io.Dir.cwd().createFile(input_output.runtime(), path, .{
+                    .read = false,
+                    .truncate = false,
+                    .exclusive = false,
+                    .lock = .exclusive,
+                    .lock_nonblocking = false,
+                    .permissions = .fromMode(0o600),
+                    .resolve_beneath = false,
+                });
+            }
+            return err;
+        }
+    }
+
+    fn writeTimestamp(buffer: *[64]u8) ![]const u8 {
+        assert(buffer.len == 64);
+        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 {
+        if (directory_path.len == 0) {
+            return error.EmptyPath;
+        }
+        assert(self.directories_path.len > 0);
+        const abs_path = try self.resolvePath(directory_path);
+        assert(abs_path.len > 0);
+        defer self.allocator.free(abs_path);
+
+        const log_file = try openLogForAppend(self.directories_path);
+        defer log_file.close(input_output.runtime());
+
+        var timestamp_buffer: [64]u8 = undefined;
+        const timestamp = try writeTimestamp(&timestamp_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.print("{s}|{s}\n", .{ timestamp, abs_path });
+        try file_writer.interface.flush();
+    }
+
+    pub fn logFile(
+        self: *Database,
+        file_path: []const u8,
+        file_type: []const u8,
+        action: []const u8,
+    ) !void {
+        if (file_path.len == 0) {
+            return error.EmptyPath;
+        }
+        assert(self.files_path.len > 0);
+        const abs_path = try self.resolvePath(file_path);
+        assert(abs_path.len > 0);
+        defer self.allocator.free(abs_path);
+
+        const log_file = try openLogForAppend(self.files_path);
+        defer log_file.close(input_output.runtime());
+
+        var timestamp_buffer: [64]u8 = undefined;
+        const timestamp = try writeTimestamp(&timestamp_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.print("{s}|{s}|{s}|{s}\n", .{
+            timestamp,
+            file_type,
+            action,
+            abs_path,
+        });
+        try file_writer.interface.flush();
+    }
+
+    pub fn cleanup(self: *Database) !void {
+        assert(self.directories_path.len > 0);
+        assert(self.files_path.len > 0);
+        try self.cleanupFile(self.directories_path, 1);
+        try self.cleanupFile(self.files_path, 3);
+    }
+
+    fn cleanupFile(self: *Database, path: []const u8, path_column_index: usize) !void {
+        assert(path.len > 0);
+        if (path_column_index != 1) {
+            assert(path_column_index == 3);
+        }
+        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 std.fmt.allocPrint(self.allocator, "{s}.tmp", .{path});
+        defer self.allocator.free(temp_path);
+        {
+            const write_file = try std.Io.Dir.cwd().createFile(input_output.runtime(), temp_path, .{
+                .read = false,
+                .truncate = true,
+                .exclusive = false,
+                .lock = .exclusive,
+                .lock_nonblocking = false,
+                .permissions = .fromMode(0o600),
+                .resolve_beneath = false,
+            });
+            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,
+    path_column_index: usize,
+) !RetainedLogLines {
+    if (path_column_index != 1) {
+        assert(path_column_index == 3);
+    }
+    var seen = std.StringHashMap(void).init(allocator);
+    defer seen.deinit();
+
+    var lines = std.ArrayListUnmanaged([]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, .{
+            .follow_symlinks = true,
+            .read = false,
+            .write = false,
+            .execute = false,
+        }) 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, 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;
+    }
+    assert(column_index <= path_column_index);
+    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.fs.path.join(allocator, &.{ home, ".magdalena" });
+        assert(result.len > home.len);
+        return result;
+    }
+    return error.HomeNotFound;
+}
+
+fn seenInsert(seen: *std.StringHashMap(void), path: []const u8) !bool {
+    assert(path.len > 0);
+    if (seen.contains(path)) {
+        return false;
+    }
+    try seen.put(path, {});
+    return true;
+}
+
+fn parseDirectoryLog(allocator: std.mem.Allocator, content: []const u8) ![]DirectoryEntry {
+    var list = std.ArrayListUnmanaged(DirectoryEntry).empty;
+    errdefer list.deinit(allocator);
+    var seen = std.StringHashMap(void).init(allocator);
+    defer seen.deinit();
+
+    var line_split = std.mem.splitBackwardsScalar(u8, content, '\n');
+    var line_count: usize = 0;
+    while (line_split.next()) |line| {
+        line_count += 1;
+        assert(line_count <= content.len + 1);
+        const trimmed = std.mem.trim(u8, line, " \r\t");
+        if (trimmed.len == 0) {
+            continue;
+        }
+
+        var column_split = std.mem.splitScalar(u8, trimmed, '|');
+        const timestamp = column_split.next() orelse continue;
+        const directory_path = column_split.next() orelse continue;
+        if (directory_path.len == 0) {
+            continue;
+        }
+
+        if (!try seenInsert(&seen, directory_path)) {
+            continue;
+        }
+
+        try list.append(allocator, .{
+            .path = try allocator.dupe(u8, directory_path),
+            .timestamp = try allocator.dupe(u8, timestamp),
+        });
+        assert(list.items[list.items.len - 1].path.len == directory_path.len);
+        assert(list.items[list.items.len - 1].timestamp.?.len == timestamp.len);
+    }
+
+    return list.toOwnedSlice(allocator);
+}
+
+fn parseFileLog(allocator: std.mem.Allocator, content: []const u8) ![]FileEntry {
+    var list = std.ArrayListUnmanaged(FileEntry).empty;
+    errdefer list.deinit(allocator);
+    var seen = std.StringHashMap(void).init(allocator);
+    defer seen.deinit();
+
+    var line_split = std.mem.splitBackwardsScalar(u8, content, '\n');
+    var line_count: usize = 0;
+    while (line_split.next()) |line| {
+        line_count += 1;
+        assert(line_count <= content.len + 1);
+        const trimmed = std.mem.trim(u8, line, " \r\t");
+        if (trimmed.len == 0) {
+            continue;
+        }
+
+        var column_split = std.mem.splitScalar(u8, trimmed, '|');
+        const timestamp = column_split.next() orelse continue;
+        const file_type = column_split.next() orelse continue;
+        const action = column_split.next() orelse continue;
+        const file_path = column_split.next() orelse continue;
+        if (file_path.len == 0) {
+            continue;
+        }
+
+        if (!try seenInsert(&seen, file_path)) {
+            continue;
+        }
+
+        try list.append(allocator, .{
+            .path = try allocator.dupe(u8, file_path),
+            .file_type = try allocator.dupe(u8, file_type),
+            .action = try allocator.dupe(u8, action),
+            .timestamp = try allocator.dupe(u8, timestamp),
+        });
+        assert(list.items[list.items.len - 1].path.len == file_path.len);
+        assert(list.items[list.items.len - 1].timestamp.?.len == timestamp.len);
+    }
+
+    return list.toOwnedSlice(allocator);
+}
+
+const testing = std.testing;
+
+test "parseDirectoryLog: 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 parseDirectoryLog(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 "parseDirectoryLog: 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 parseDirectoryLog(arena.allocator(), content);
+    try testing.expectEqual(@as(usize, 1), entries.len);
+    try testing.expectEqualStrings("/only", entries[0].path);
+}
+
+test "parseDirectoryLog: 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 parseDirectoryLog(arena.allocator(), content);
+    try testing.expectEqual(@as(usize, 1), entries.len);
+    try testing.expectEqualStrings("/kept", entries[0].path);
+}
+
+test "parseDirectoryLog: empty input yields no entries" {
+    var arena = std.heap.ArenaAllocator.init(testing.allocator);
+    defer arena.deinit();
+    const entries = try parseDirectoryLog(arena.allocator(), "");
+    try testing.expectEqual(@as(usize, 0), entries.len);
+}
+
+test "parseDirectoryLog: extra columns are ignored" {
+    var arena = std.heap.ArenaAllocator.init(testing.allocator);
+    defer arena.deinit();
+    const entries = try parseDirectoryLog(arena.allocator(), "t|/p|unexpected\n");
+    try testing.expectEqual(@as(usize, 1), entries.len);
+    try testing.expectEqualStrings("/p", entries[0].path);
+}
+
+test "parseFileLog: 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 parseFileLog(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 "parseFileLog: 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 parseFileLog(arena.allocator(), content);
+    try testing.expectEqual(@as(usize, 1), entries.len);
+    try testing.expectEqualStrings("/y", entries[0].path);
+}
+
+test "parseFileLog: skips entries with empty paths" {
+    var arena = std.heap.ArenaAllocator.init(testing.allocator);
+    defer arena.deinit();
+
+    const entries = try parseFileLog(arena.allocator(), "t1|zig|open|\n");
+    try testing.expectEqual(@as(usize, 0), entries.len);
+}
+
+test "parseFileLog: empty fields are kept, not skipped" {
+    var arena = std.heap.ArenaAllocator.init(testing.allocator);
+    defer arena.deinit();
+
+    const entries = try parseFileLog(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);
+}
blob - 258bee76c6beb8aae2d1ef555f6ae553207ff677 (mode 644)
blob + /dev/null
--- src/fzf.zig
+++ /dev/null
@@ -1,637 +0,0 @@
-const std = @import("std");
-
-const cache = @import("memcached.zig");
-const cfg = @import("cfg.zig");
-const hist = @import("db.zig");
-const io = @import("io.zig");
-
-fn gotoFile(allocator: std.mem.Allocator, db: ?*hist.Db, file_path: []const u8, line: ?[]const u8, config: *const cfg.Config) !void {
-    const ext = if (std.mem.lastIndexOfScalar(u8, file_path, '.')) |idx| file_path[idx + 1 ..] else "";
-
-    var action: []const u8 = "text_editor";
-    var command: ?[]const u8 = null;
-
-    for (config.openers) |opener| {
-        for (opener.extensions) |e| {
-            if (std.mem.eql(u8, e, ext)) {
-                action = opener.action;
-                command = opener.command;
-                break;
-            }
-        }
-        if (command != null) break;
-    }
-
-    if (db) |d| {
-        d.logFile(file_path, ext, action) catch |err| io.warn("failed to log file: {}\n", .{err});
-    }
-
-    if (std.Io.Dir.openFileAbsolute(io.rt(), "/dev/tty", .{ .mode = .read_write })) |tty| {
-        if (std.c.dup2(tty.handle, std.posix.STDIN_FILENO) < 0) io.warn("failed to redirect stdin to tty\n", .{});
-        if (std.c.dup2(tty.handle, std.posix.STDOUT_FILENO) < 0) io.warn("failed to redirect stdout to tty\n", .{});
-        tty.close(io.rt());
-    } else |err| {
-        io.warn("failed to open /dev/tty: {}\n", .{err});
-    }
-
-    const parent_dir = std.fs.path.dirname(file_path) orelse ".";
-    const base_name = std.fs.path.basename(file_path);
-    try std.process.setCurrentPath(io.rt(), parent_dir);
-
-    if (command) |cmd| {
-        return std.process.replace(io.rt(), .{ .argv = &.{ cmd, base_name } });
-    }
-
-    const editor = config.editor orelse (io.getenv("EDITOR") orelse return error.EditorNotSet);
-
-    if (line) |l| {
-        const line_arg = try std.fmt.allocPrint(allocator, "+{s}", .{l});
-        defer allocator.free(line_arg);
-        return std.process.replace(io.rt(), .{ .argv = &.{ editor, line_arg, base_name } });
-    }
-
-    return std.process.replace(io.rt(), .{ .argv = &.{ editor, base_name } });
-}
-
-const StdIo = std.process.SpawnOptions.StdIo;
-
-const Fzf = struct {
-    argv: [][]const u8,
-    allocator: std.mem.Allocator,
-    stdin: StdIo = .pipe,
-    stdout: StdIo = .pipe,
-    stderr: StdIo = .inherit,
-
-    pub fn init(allocator: std.mem.Allocator, fzf_opts: []const []const u8, extra_opts: []const []const u8) !Fzf {
-        var argv = try allocator.alloc([]const u8, 1 + fzf_opts.len + extra_opts.len);
-        errdefer allocator.free(argv);
-
-        var i: usize = 0;
-        argv[i] = "fzf";
-        i += 1;
-        for (fzf_opts) |opt| {
-            argv[i] = opt;
-            i += 1;
-        }
-        for (extra_opts) |opt| {
-            argv[i] = opt;
-            i += 1;
-        }
-
-        return .{
-            .argv = argv,
-            .allocator = allocator,
-        };
-    }
-
-    pub fn spawn(self: *Fzf) !std.process.Child {
-        return std.process.spawn(io.rt(), .{
-            .argv = self.argv,
-            .stdin = self.stdin,
-            .stdout = self.stdout,
-            .stderr = self.stderr,
-        });
-    }
-
-    pub fn deinit(self: *Fzf) void {
-        self.allocator.free(self.argv);
-    }
-};
-
-// ignored_patterns entries are matched by exact path component name, not globs.
-fn shouldSkip(path: []const u8, ignored_patterns: []const []const u8) bool {
-    var it = std.mem.splitScalar(u8, path, std.fs.path.sep);
-    while (it.next()) |component| {
-        for (ignored_patterns) |pattern| {
-            if (std.mem.eql(u8, component, pattern)) {
-                return true;
-            }
-        }
-    }
-    return false;
-}
-
-fn runFzfPicker(allocator: std.mem.Allocator, fzf_opts: []const []const u8, items: []const []const u8) ![]u8 {
-    var fzf = try Fzf.init(allocator, fzf_opts, &[_][]const u8{});
-    defer fzf.deinit();
-    var child = try fzf.spawn();
-
-    if (child.stdin) |stdin| {
-        for (items) |item| {
-            stdin.writeStreamingAll(io.rt(), item) catch |err| {
-                if (err == error.BrokenPipe) break;
-                return err;
-            };
-            stdin.writeStreamingAll(io.rt(), "\n") catch |err| {
-                if (err == error.BrokenPipe) break;
-                return err;
-            };
-        }
-        stdin.close(io.rt());
-        child.stdin = null;
-    }
-
-    var stdout_data: ?[]u8 = null;
-    defer if (stdout_data) |d| allocator.free(d);
-
-    if (child.stdout) |stdout| {
-        var buffer: [4096]u8 = undefined;
-        var sr = stdout.reader(io.rt(), &buffer);
-        stdout_data = try sr.interface.allocRemaining(allocator, .limited(64 * 1024 * 1024));
-    }
-
-    const term = try child.wait(io.rt());
-    if (term != .exited or term.exited != 0) return error.UserAbort;
-    const selected = std.mem.trim(u8, stdout_data orelse "", " \n\r\t");
-    if (selected.len == 0) return error.UserAbort;
-
-    return allocator.dupe(u8, selected);
-}
-
-pub fn recentDir(allocator: std.mem.Allocator, db: *hist.Db, config: *const cfg.Config) !void {
-    const managed_dirs = try db.recentDirs();
-    defer managed_dirs.deinit();
-    const dirs = managed_dirs.value;
-
-    if (dirs.len == 0) {
-        io.warn("No recent directories found.\n", .{});
-        return;
-    }
-
-    var items = std.ArrayListUnmanaged([]const u8).empty;
-    defer items.deinit(allocator);
-
-    for (dirs) |dir| {
-        if (shouldSkip(dir.path, config.ignored_patterns)) continue;
-        try items.append(allocator, dir.path);
-    }
-
-    const selected = try runFzfPicker(allocator, config.fzf_opts, items.items);
-    defer allocator.free(selected);
-
-    std.Io.Dir.accessAbsolute(io.rt(), selected, .{}) catch |err| {
-        io.warn("Selected directory no longer exists: {s} ({})\n", .{ selected, err });
-        return error.UserAbort;
-    };
-
-    var out_buf: [4096]u8 = undefined;
-    var out = io.getStdout(&out_buf);
-    try out.print("{s}\n", .{selected});
-    try out.end();
-}
-
-pub fn favorites(allocator: std.mem.Allocator, db: *hist.Db, config: *const cfg.Config) !void {
-    if (config.favorites.len == 0) {
-        io.warn("No favorites found in config.\n", .{});
-        return;
-    }
-
-    var items = std.ArrayListUnmanaged([]const u8).empty;
-    defer {
-        for (items.items) |item| allocator.free(item);
-        items.deinit(allocator);
-    }
-
-    for (config.favorites) |entry| {
-        const expanded = try expandPath(allocator, entry);
-        try items.append(allocator, expanded);
-    }
-
-    const selected = try runFzfPicker(allocator, config.fzf_opts, items.items);
-    defer allocator.free(selected);
-
-    const is_dir = blk: {
-        var dir = std.Io.Dir.openDirAbsolute(io.rt(), selected, .{}) catch break :blk false;
-        dir.close(io.rt());
-        break :blk true;
-    };
-
-    if (is_dir) {
-        try db.logDir(selected);
-        var out_buf: [4096]u8 = undefined;
-        var out = io.getStdout(&out_buf);
-        try out.print("{s}\n", .{selected});
-        try out.end();
-    } else {
-        try gotoFile(allocator, db, selected, null, config);
-    }
-}
-
-fn expandPath(allocator: std.mem.Allocator, path: []const u8) ![]const u8 {
-    var res = std.ArrayListUnmanaged(u8).empty;
-    errdefer res.deinit(allocator);
-
-    const home = io.getenv("HOME") orelse return error.HomeNotFound;
-
-    var i: usize = 0;
-    while (i < path.len) {
-        if (path[i] == '~' and (i == 0 or path[i - 1] == std.fs.path.sep)) {
-            try res.appendSlice(allocator, home);
-            i += 1;
-        } else if (std.mem.startsWith(u8, path[i..], "$HOME")) {
-            try res.appendSlice(allocator, home);
-            i += 5;
-        } else {
-            try res.append(allocator, path[i]);
-            i += 1;
-        }
-    }
-
-    return res.toOwnedSlice(allocator);
-}
-
-pub fn recentFile(allocator: std.mem.Allocator, db: *hist.Db, config: *const cfg.Config) !void {
-    const managed_files = try db.recentFiles();
-    defer managed_files.deinit();
-    const files = managed_files.value;
-
-    if (files.len == 0) {
-        io.warn("No recent files found.\n", .{});
-        return;
-    }
-
-    var items = std.ArrayListUnmanaged([]const u8).empty;
-    defer items.deinit(allocator);
-
-    for (files) |file| {
-        if (shouldSkip(file.path, config.ignored_patterns)) continue;
-        try items.append(allocator, file.path);
-    }
-
-    const selected = try runFzfPicker(allocator, config.fzf_opts, items.items);
-    defer allocator.free(selected);
-
-    try std.Io.Dir.accessAbsolute(io.rt(), selected, .{});
-
-    try gotoFile(allocator, db, selected, null, config);
-}
-
-// fzf `--bind change:reload:...` runs via shell. Patterns come from config,
-// so single-quote them safely: `'` becomes `'\''` inside outer `'...'`.
-fn appendShellEscapedContent(buf: *std.ArrayListUnmanaged(u8), allocator: std.mem.Allocator, s: []const u8) !void {
-    for (s) |c| {
-        if (c == '\'') {
-            try buf.appendSlice(allocator, "'\\''");
-        } else {
-            try buf.append(allocator, c);
-        }
-    }
-}
-
-fn buildRgReloadCommand(allocator: std.mem.Allocator, ignored_patterns: []const []const u8) ![]u8 {
-    var rg_cmd = std.ArrayListUnmanaged(u8).empty;
-    errdefer rg_cmd.deinit(allocator);
-    try rg_cmd.appendSlice(allocator, "change:reload:rg --column --line-number --no-heading --color=always --smart-case");
-    for (ignored_patterns) |pattern| {
-        try rg_cmd.appendSlice(allocator, " --glob '!");
-        try appendShellEscapedContent(&rg_cmd, allocator, pattern);
-        try rg_cmd.appendSlice(allocator, "/*'");
-        try rg_cmd.appendSlice(allocator, " --glob '!");
-        try appendShellEscapedContent(&rg_cmd, allocator, pattern);
-        try rg_cmd.appendSlice(allocator, "'");
-    }
-    try rg_cmd.appendSlice(allocator, " {q} || true");
-    return rg_cmd.toOwnedSlice(allocator);
-}
-
-pub fn grep(allocator: std.mem.Allocator, db: *hist.Db, config: *const cfg.Config) !void {
-    const initial_query = "";
-
-    const rg_reload = try buildRgReloadCommand(allocator, config.ignored_patterns);
-    defer allocator.free(rg_reload);
-
-    var fzf = try Fzf.init(allocator, config.fzf_opts, &[_][]const u8{
-        "--disabled",
-        "--query",
-        initial_query,
-        "--bind",
-        rg_reload,
-        "--delimiter",
-        ":",
-        "--preview",
-        "bat --highlight-line {2} {1}",
-        "--preview-window",
-        "right,60%,border-left,+{2}+3/3,~3",
-    });
-    defer fzf.deinit();
-
-    fzf.stdin = .inherit;
-    fzf.stdout = .pipe;
-    fzf.stderr = .inherit;
-
-    var child = try fzf.spawn();
-
-    var stdout_data: ?[]u8 = null;
-    defer if (stdout_data) |d| allocator.free(d);
-
-    if (child.stdout) |stdout| {
-        var buffer: [4096]u8 = undefined;
-        var sr = stdout.reader(io.rt(), &buffer);
-        stdout_data = try sr.interface.allocRemaining(allocator, .limited(64 * 1024 * 1024));
-    }
-
-    const term = try child.wait(io.rt());
-    if (term != .exited or term.exited != 0) return error.UserAbort;
-    const selected = std.mem.trim(u8, stdout_data orelse "", " \n\r\t");
-    if (selected.len == 0) return error.UserAbort;
-
-    var it = std.mem.splitScalar(u8, selected, ':');
-    if (it.next()) |file| {
-        const line = it.next();
-        try gotoFile(allocator, db, file, line, config);
-    }
-}
-
-pub fn lookFile(allocator: std.mem.Allocator, db: *hist.Db, config: *const cfg.Config, depth: ?usize) !void {
-    try runFzfWithWalker(allocator, db, .file, config, depth);
-}
-
-pub fn lookDir(allocator: std.mem.Allocator, db: *hist.Db, config: *const cfg.Config, depth: ?usize) !void {
-    try runFzfWithWalker(allocator, db, .directory, config, depth);
-}
-
-const Entry = struct {
-    path: []const u8,
-    mtime: i128,
-};
-
-fn getMaxDirMtime(ignored_patterns: []const []const u8, max_depth: usize) !i128 {
-    var max_mtime: i128 = 0;
-
-    if (std.Io.Dir.cwd().statFile(io.rt(), ".", .{})) |stat| {
-        max_mtime = stat.mtime.nanoseconds;
-    } else |_| {}
-
-    var root = try std.Io.Dir.cwd().openDir(io.rt(), ".", .{ .iterate = true });
-    defer root.close(io.rt());
-    try scanMaxMtime(&root, 0, max_depth, ignored_patterns, &max_mtime);
-
-    return max_mtime;
-}
-
-// Recurses one open directory per call frame, so each handle is closed exactly
-// once by its own `defer` as the recursion unwinds.
-fn scanMaxMtime(dir: *std.Io.Dir, depth: usize, max_depth: usize, ignored_patterns: []const []const u8, max_mtime: *i128) !void {
-    var it = dir.iterate();
-    while (try it.next(io.rt())) |entry| {
-        var skip = false;
-        for (ignored_patterns) |pattern| {
-            if (std.mem.eql(u8, entry.name, pattern)) {
-                skip = true;
-                break;
-            }
-        }
-        if (skip) continue;
-
-        const is_dir = entry.kind == .directory;
-        if (is_dir or entry.kind == .sym_link) {
-            const stat = dir.statFile(io.rt(), entry.name, .{}) catch |err| {
-                if (err != error.FileNotFound and err != error.AccessDenied) {
-                    io.warn("failed to stat {s}: {}\n", .{ entry.name, err });
-                }
-                continue;
-            };
-            if (stat.kind == .directory) {
-                if (stat.mtime.nanoseconds > max_mtime.*) max_mtime.* = stat.mtime.nanoseconds;
-
-                if (is_dir and depth + 1 < max_depth) {
-                    var sub_dir = dir.openDir(io.rt(), entry.name, .{ .iterate = true }) catch |err| {
-                        io.warn("failed to open directory {s}: {}\n", .{ entry.name, err });
-                        continue;
-                    };
-                    defer sub_dir.close(io.rt());
-                    try scanMaxMtime(&sub_dir, depth + 1, max_depth, ignored_patterns, max_mtime);
-                }
-            }
-        }
-    }
-}
-
-// Recurses one open directory per call frame (closed by its own `defer`),
-// collecting matching entries with their path relative to the walk root.
-fn collectEntries(
-    allocator: std.mem.Allocator,
-    dir: *std.Io.Dir,
-    prefix: ?[]const u8,
-    depth: usize,
-    max_depth: usize,
-    kind: std.Io.File.Kind,
-    ignored_patterns: []const []const u8,
-    entries: *std.ArrayListUnmanaged(Entry),
-) !void {
-    var it = dir.iterate();
-    while (try it.next(io.rt())) |entry| {
-        const entry_path = if (prefix) |p|
-            try std.fs.path.join(allocator, &.{ p, entry.name })
-        else
-            try allocator.dupe(u8, entry.name);
-        defer allocator.free(entry_path);
-
-        if (shouldSkip(entry_path, ignored_patterns)) continue;
-
-        if (entry.kind == kind or entry.kind == .sym_link) {
-            if (dir.statFile(io.rt(), entry.name, .{})) |stat| {
-                if (stat.kind == kind) {
-                    try entries.append(allocator, .{
-                        .path = try allocator.dupe(u8, entry_path),
-                        .mtime = stat.mtime.nanoseconds,
-                    });
-                }
-            } else |err| {
-                if (err != error.FileNotFound and err != error.AccessDenied) {
-                    io.warn("failed to stat {s}: {}\n", .{ entry.name, err });
-                }
-            }
-        }
-
-        if (entry.kind == .directory and depth + 1 < max_depth) {
-            var sub_dir = dir.openDir(io.rt(), entry.name, .{ .iterate = true }) catch |err| {
-                io.warn("failed to open directory {s}: {}\n", .{ entry.name, err });
-                continue;
-            };
-            defer sub_dir.close(io.rt());
-            try collectEntries(allocator, &sub_dir, entry_path, depth + 1, max_depth, kind, ignored_patterns, entries);
-        }
-    }
-}
-
-// Cached entries come from unauthenticated localhost memcached. Never trust
-// them blindly: reject absolute paths, `..` escapes, empty/NUL paths.
-fn isSafeCachedPath(p: []const u8) bool {
-    if (p.len == 0) return false;
-    if (std.mem.indexOfScalar(u8, p, 0) != null) return false;
-    if (std.fs.path.isAbsolute(p)) return false;
-    var it = std.mem.splitScalar(u8, p, std.fs.path.sep);
-    while (it.next()) |comp| {
-        if (std.mem.eql(u8, comp, "..")) return false;
-    }
-    return true;
-}
-
-fn runFzfWithWalker(allocator: std.mem.Allocator, db: *hist.Db, kind: std.Io.File.Kind, config: *const cfg.Config, depth: ?usize) !void {
-    const is_dir = kind == .directory;
-    const max_depth = depth orelse config.max_depth;
-
-    var entries = std.ArrayListUnmanaged(Entry).empty;
-    defer {
-        for (entries.items) |e| allocator.free(e.path);
-        entries.deinit(allocator);
-    }
-
-    var mc = cache.Memcached.init(allocator, "127.0.0.1", 11211) catch |err| blk: {
-        if (err != error.ConnectionRefused) {
-            io.warn("failed to initialize memcached: {}\n", .{err});
-        }
-        break :blk null;
-    };
-    defer if (mc) |*m| m.deinit();
-
-    var cache_key: ?[]const u8 = null;
-    defer if (cache_key) |k| allocator.free(k);
-
-    var cache_hit = false;
-    if (mc) |*m| {
-        const cwd = blk: {
-            break :blk std.process.currentPathAlloc(io.rt(), allocator) catch |err| {
-                io.warn("failed to get cwd for cache: {}\n", .{err});
-                break :blk null;
-            };
-        };
-        if (cwd) |c| {
-            defer allocator.free(c);
-
-            const hash = std.hash.Crc32.hash(c);
-            const max_dir_mtime = getMaxDirMtime(config.ignored_patterns, max_depth) catch blk: {
-                break :blk @as(i128, 0);
-            };
-            const mtime = @divTrunc(max_dir_mtime, std.time.ns_per_s);
-            cache_key = try std.fmt.allocPrint(allocator, "magdalena:look:{s}:{x}:{d}:{d}", .{ if (is_dir) "dir" else "file", hash, mtime, max_depth });
-            if (cache_key) |key| {
-                // Cache lives in unauthenticated localhost memcached. Any
-                // failure or corruption is a miss, never fatal.
-                const maybe_data: ?[]u8 = m.get(key) catch |err| blk: {
-                    io.warn("ignoring cache read failure: {}\n", .{err});
-                    break :blk null;
-                };
-                if (maybe_data) |data| {
-                    defer allocator.free(data);
-                    const maybe_parsed: ?std.json.Parsed([]Entry) = std.json.parseFromSlice([]Entry, allocator, data, .{}) catch |err| blk: {
-                        io.warn("ignoring corrupt cache entry: {}\n", .{err});
-                        break :blk null;
-                    };
-                    if (maybe_parsed) |parsed| {
-                        defer parsed.deinit();
-                        var valid: usize = 0;
-                        for (parsed.value) |e| {
-                            if (!isSafeCachedPath(e.path)) continue;
-                            try entries.append(allocator, .{
-                                .path = try allocator.dupe(u8, e.path),
-                                .mtime = e.mtime,
-                            });
-                            valid += 1;
-                        }
-                        // Parseable but fully bogus entries fall through to a
-                        // fresh walk, which also overwrites the poisoned key.
-                        if (valid > 0 or parsed.value.len == 0) cache_hit = true;
-                    }
-                }
-            }
-        }
-    }
-
-    if (!cache_hit) {
-        var root = try std.Io.Dir.cwd().openDir(io.rt(), ".", .{ .iterate = true });
-        defer root.close(io.rt());
-        try collectEntries(allocator, &root, null, 0, max_depth, kind, config.ignored_patterns, &entries);
-
-        if (mc) |*m| {
-            if (cache_key) |key| {
-                const json_data = try std.json.Stringify.valueAlloc(allocator, entries.items, .{});
-                defer allocator.free(json_data);
-                m.set(key, json_data, 3600) catch |err| {
-                    io.warn("failed to write cache: {}\n", .{err});
-                };
-            }
-        }
-    }
-
-    std.mem.sortUnstable(Entry, entries.items, {}, struct {
-        fn lessThan(_: void, a: Entry, b: Entry) bool {
-            return a.mtime > b.mtime;
-        }
-    }.lessThan);
-
-    var items = std.ArrayListUnmanaged([]const u8).empty;
-    defer items.deinit(allocator);
-    for (entries.items) |entry| {
-        try items.append(allocator, entry.path);
-    }
-
-    const selected = try runFzfPicker(allocator, config.fzf_opts, items.items);
-    defer allocator.free(selected);
-
-    const clean_path = if (std.mem.startsWith(u8, selected, "./")) selected[2..] else selected;
-
-    if (clean_path.len > 0) {
-        std.Io.Dir.cwd().access(io.rt(), clean_path, .{}) catch |err| {
-            io.warn("Selected item no longer exists or is inaccessible: {s} ({})\n", .{ clean_path, err });
-            return error.UserAbort;
-        };
-        if (is_dir) {
-            var out_buf: [4096]u8 = undefined;
-            var out = io.getStdout(&out_buf);
-            try out.print("{s}\n", .{clean_path});
-            try out.end();
-        } else {
-            try gotoFile(allocator, db, clean_path, null, config);
-        }
-    }
-}
-
-const testing = std.testing;
-
-test "shouldSkip: matches an exact path component" {
-    const ignored = [_][]const u8{ "node_modules", ".git" };
-    try testing.expect(shouldSkip("src/node_modules/pkg/x.js", &ignored));
-    try testing.expect(shouldSkip(".git/config", &ignored));
-    try testing.expect(shouldSkip("a/b/.git", &ignored));
-}
-
-test "shouldSkip: partial component is not a match" {
-    const ignored = [_][]const u8{"node_modules"};
-    try testing.expect(!shouldSkip("src/lib/main.zig", &ignored));
-    try testing.expect(!shouldSkip("src/node_modules_old/x", &ignored));
-}
-
-test "shouldSkip: empty pattern set never skips" {
-    try testing.expect(!shouldSkip("a/b/c", &.{}));
-}
-
-test "isSafeCachedPath: accepts plain relative paths" {
-    try testing.expect(isSafeCachedPath("src/main.zig"));
-    try testing.expect(isSafeCachedPath("./a/b"));
-}
-
-test "isSafeCachedPath: rejects escapes and junk" {
-    try testing.expect(!isSafeCachedPath(""));
-    try testing.expect(!isSafeCachedPath("/etc/passwd"));
-    try testing.expect(!isSafeCachedPath("a/../../etc/passwd"));
-    try testing.expect(!isSafeCachedPath(".."));
-    try testing.expect(!isSafeCachedPath("a\x00b"));
-}
-
-test "buildRgReloadCommand: plain patterns stay single-quoted" {
-    const ignored = [_][]const u8{ "node_modules", ".git" };
-    const cmd = try buildRgReloadCommand(testing.allocator, &ignored);
-    defer testing.allocator.free(cmd);
-    try testing.expect(std.mem.indexOf(u8, cmd, "--glob '!node_modules/*'") != null);
-    try testing.expect(std.mem.indexOf(u8, cmd, "--glob '!.git'") != null);
-}
-
-test "buildRgReloadCommand: single quote cannot break out" {
-    const ignored = [_][]const u8{"a'b"};
-    const cmd = try buildRgReloadCommand(testing.allocator, &ignored);
-    defer testing.allocator.free(cmd);
-    try testing.expect(std.mem.indexOf(u8, cmd, "'!a'\\''b/*'") != null);
-    try testing.expect(std.mem.indexOf(u8, cmd, "'!a'\\''b'") != null);
-}
blob - /dev/null
blob + 7f1e27eee5b0f801e9b8bb86fa834f8a6e2c14f0 (mode 644)
--- /dev/null
+++ src/input_output.zig
@@ -0,0 +1,83 @@
+const std = @import("std");
+const assert = std.debug.assert;
+
+var rt_handle: ?std.Io = null;
+var env_map: ?*std.process.Environ.Map = null;
+
+pub fn init(io_handle: std.Io, environ: *std.process.Environ.Map) void {
+    rt_handle = io_handle;
+    env_map = environ;
+    assert(rt_handle != null);
+    assert(env_map != null);
+}
+
+pub fn runtime() std.Io {
+    assert(rt_handle != null);
+    return rt_handle.?;
+}
+
+pub fn getenv(key: []const u8) ?[]const u8 {
+    assert(key.len > 0);
+    assert(env_map != null);
+    if (env_map) |map| {
+        return map.get(key);
+    }
+    return null;
+}
+
+pub fn environMap() *std.process.Environ.Map {
+    if (env_map) |map| {
+        return map;
+    }
+    fatal("io used before init", .{});
+}
+
+pub const Writer = struct {
+    inner: std.Io.File.Writer,
+    failed: bool = false,
+
+    pub fn print(self: *Writer, comptime fmt: []const u8, args: anytype) !void {
+        assert(fmt.len > 0);
+        if (self.failed) return;
+        self.inner.interface.print(fmt, args) catch |err| {
+            if (err == error.WriteFailed) {
+                self.failed = true;
+                return;
+            }
+            return err;
+        };
+    }
+
+    pub fn end(self: *Writer) !void {
+        if (self.failed) return;
+        self.inner.interface.flush() catch |err| {
+            if (err == error.WriteFailed) {
+                self.failed = true;
+                return;
+            }
+            return err;
+        };
+    }
+};
+
+pub fn getStdout(buffer: []u8) Writer {
+    assert(buffer.len > 0);
+    return .{ .inner = std.Io.File.stdout().writerStreaming(runtime(), buffer) };
+}
+
+pub fn warn(comptime fmt: []const u8, args: anytype) void {
+    assert(fmt.len > 0);
+    var buffer: [4096]u8 = undefined;
+    var writer = std.Io.File.stderr().writerStreaming(runtime(), &buffer);
+    writer.interface.print(fmt, args) catch |err| {
+        std.log.warn("failed to write to stderr: {}", .{err});
+    };
+    writer.interface.flush() catch |err| {
+        std.log.warn("failed to flush stderr writer: {}", .{err});
+    };
+}
+
+pub fn fatal(comptime fmt: []const u8, args: anytype) noreturn {
+    warn(fmt ++ "\n", args);
+    std.process.exit(1);
+}
blob - afdb924d98de999b342f0f16c57e0be5b140a1b0 (mode 644)
blob + /dev/null
--- src/io.zig
+++ /dev/null
@@ -1,69 +0,0 @@
-const std = @import("std");
-
-// Process-wide handles, populated once from `std.process.Init` in `main`. The
-// app is a short-lived single-threaded CLI, so threading these through every
-// call site would be pure noise; ambient state keeps the IO surface small.
-// They are optionals (not `undefined`) so that any use before `init` is a
-// checked unwrap panic rather than undefined behavior.
-var rt_handle: ?std.Io = null;
-var env_map: ?*std.process.Environ.Map = null;
-
-pub fn init(io_handle: std.Io, environ: *std.process.Environ.Map) void {
-    rt_handle = io_handle;
-    env_map = environ;
-}
-
-pub fn rt() std.Io {
-    return rt_handle.?;
-}
-
-pub fn getenv(key: []const u8) ?[]const u8 {
-    return env_map.?.get(key);
-}
-
-pub const Writer = struct {
-    inner: std.Io.File.Writer,
-    failed: bool = false,
-
-    pub fn print(self: *Writer, comptime fmt: []const u8, args: anytype) !void {
-        if (self.failed) return;
-        self.inner.interface.print(fmt, args) catch |err| {
-            if (err == error.WriteFailed) {
-                self.failed = true;
-                return;
-            }
-            return err;
-        };
-    }
-
-    pub fn end(self: *Writer) !void {
-        if (self.failed) return;
-        self.inner.interface.flush() catch |err| {
-            if (err == error.WriteFailed) {
-                self.failed = true;
-                return;
-            }
-            return err;
-        };
-    }
-};
-
-pub fn getStdout(buf: []u8) Writer {
-    return .{ .inner = std.Io.File.stdout().writerStreaming(rt(), buf) };
-}
-
-pub fn warn(comptime fmt: []const u8, args: anytype) void {
-    var buf: [4096]u8 = undefined;
-    var writer = std.Io.File.stderr().writerStreaming(rt(), &buf);
-    writer.interface.print(fmt, args) catch |err| {
-        std.log.warn("failed to write to stderr: {}", .{err});
-    };
-    writer.interface.flush() catch |err| {
-        std.log.warn("failed to flush stderr writer: {}", .{err});
-    };
-}
-
-pub fn fatal(comptime fmt: []const u8, args: anytype) noreturn {
-    warn(fmt ++ "\n", args);
-    std.process.exit(1);
-}
blob - 649121766a95937a1654f74083b5709f43ce17e7
blob + 35e914d18d1d839d5c5f9357afe5a232f55bc653
--- src/main.zig
+++ src/main.zig
@@ -1,133 +1,176 @@
 const std = @import("std");
-const cli = @import("cli.zig");
-const cfg = @import("cfg.zig");
-const fzf = @import("fzf.zig");
-const hist = @import("db.zig");
-const io = @import("io.zig");
+const assert = std.debug.assert;
+const command_line = @import("command_line.zig");
+const config = @import("config.zig");
+const navigate = @import("navigate.zig");
+const database = @import("database.zig");
+const input_output = @import("input_output.zig");
 
-test {
-    _ = cli;
-    _ = cfg;
-    _ = fzf;
-    _ = hist;
-}
-
 pub fn main(init: std.process.Init) void {
-    io.init(init.io, init.environ_map);
+    input_output.init(init.io, init.environ_map);
     const allocator = init.gpa;
 
-    const args = cli.parseArgs(allocator, init.minimal.args) catch |err| {
-        io.fatal("failed to parse arguments: {}", .{err});
+    const arguments = command_line.parseArguments(allocator, init.minimal.args) catch |err| {
+        input_output.fatal("failed to parse arguments: {}", .{err});
     };
-    defer args.deinit();
+    defer arguments.deinit();
 
-    if (args.action == .help) {
-        cli.printUsage() catch |err| {
-            io.fatal("failed to print usage: {}", .{err});
+    if (arguments.action == .help) {
+        command_line.printUsage() catch |err| {
+            input_output.fatal("failed to print usage: {}", .{err});
         };
         return;
     }
 
-    const parsed_config = cfg.loadConfig(allocator);
-    defer if (parsed_config) |c| c.deinit();
-    const default_config = cfg.Config{};
-    const base_config = if (parsed_config) |c| c.value else default_config;
+    const parsed_config = config.loadConfig(allocator);
+    defer if (parsed_config) |app_config| app_config.deinit();
+    const default_config = config.Config{};
+    const base_config = if (parsed_config) |app_config| app_config.value else default_config;
 
-    var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
-    const cwd = blk: {
-        const n = std.process.currentPath(io.rt(), &cwd_buf) catch |err| {
-            io.warn("failed to get cwd for folder overrides: {}\n", .{err});
-            break :blk "";
-        };
-        break :blk cwd_buf[0..n];
+    var current_directory_buffer: [std.fs.max_path_bytes]u8 = undefined;
+    const current_directory = currentDirectory(&current_directory_buffer);
+    const app_config = config.applyFolderOverride(base_config, current_directory);
+
+    const database_path = database.getDefaultDatabasePath(allocator) catch |err| {
+        input_output.fatal("failed to get database path: {}", .{err});
     };
-    const config = cfg.applyFolderOverride(base_config, cwd);
+    defer allocator.free(database_path);
 
-    const db_path = hist.getDefaultDbPath(allocator) catch |err| {
-        io.fatal("failed to get database path: {}", .{err});
+    var history = database.Database.init(allocator, database_path) catch |err| {
+        input_output.fatal("failed to initialize database: {}", .{err});
     };
-    defer allocator.free(db_path);
+    defer history.deinit();
 
-    var db = hist.Db.init(allocator, db_path) catch |err| {
-        io.fatal("failed to initialize database: {}", .{err});
+    runAction(allocator, &history, arguments, &app_config) catch |err| {
+        if (err == error.UserAbort) std.process.exit(1);
+        input_output.fatal("action failed: {}", .{err});
     };
-    defer db.deinit();
+}
 
-    runAction(allocator, &db, args, &config) catch |err| {
-        if (err == error.UserAbort) {
-            std.process.exit(1);
-        }
-        io.fatal("action failed: {}", .{err});
+fn currentDirectory(buffer: *[std.fs.max_path_bytes]u8) []const u8 {
+    const count = std.process.currentPath(input_output.runtime(), buffer) catch |err| {
+        input_output.warn("failed to get working directory for folder overrides: {}\n", .{err});
+        return "";
     };
+    assert(count <= buffer.len);
+    return buffer[0..count];
 }
 
-fn runAction(allocator: std.mem.Allocator, db: *hist.Db, args: cli.Args, config: *const cfg.Config) !void {
-    var buf: [4096]u8 = undefined;
-    var writer = io.getStdout(&buf);
+fn runAction(
+    allocator: std.mem.Allocator,
+    history: *database.Database,
+    arguments: command_line.Arguments,
+    app_config: *const config.Config,
+) !void {
+    assert(history.base_path.len > 0);
+    var buffer: [4096]u8 = undefined;
+    var writer = input_output.getStdout(&buffer);
+    assert(!writer.failed);
 
-    switch (args.action) {
-        .recent_dirs => {
-            const managed = try db.recentDirs();
-            defer managed.deinit();
-            for (managed.value) |d| {
-                try writer.print("{s}|{s}\n", .{ d.timestamp orelse "-", d.path });
-            }
+    switch (arguments.action) {
+        .recent_directories => {
+            try printRecentDirectories(&writer, history);
         },
         .recent_files => {
-            const managed = try db.recentFiles();
-            defer managed.deinit();
-            for (managed.value) |f| {
-                try writer.print("{s}|{s}|{s}|{s}\n", .{ f.timestamp orelse "-", f.file_type, f.action, f.path });
-            }
+            try printRecentFiles(&writer, history);
         },
         .favorites => {
-            try fzf.favorites(allocator, db, config);
+            try navigate.favorites(allocator, history, app_config);
         },
         .search => {
-            if (args.query) |query| {
-                const managed = try db.searchHistory(query);
-                defer managed.deinit();
-                const results = managed.value;
-
-                for (results.directories) |d| {
-                    try writer.print("D|{s}\n", .{d.path});
-                }
-                for (results.files) |f| {
-                    try writer.print("F|{s}|{s}|{s}\n", .{ f.file_type, f.action, f.path });
-                }
-            }
+            try printSearchResults(&writer, history, arguments.query);
         },
-        .goto_dir => {
-            try fzf.recentDir(allocator, db, config);
+        .goto_directory => {
+            try navigate.recentDirectory(allocator, history, app_config);
         },
         .goto_file => {
-            try fzf.recentFile(allocator, db, config);
+            try navigate.recentFile(allocator, history, app_config);
         },
         .look_file => {
-            try fzf.lookFile(allocator, db, config, args.depth);
+            try navigate.lookFile(allocator, history, app_config, arguments.depth);
         },
-        .look_dir => {
-            try fzf.lookDir(allocator, db, config, args.depth);
+        .look_directory => {
+            try navigate.lookDirectory(allocator, history, app_config, arguments.depth);
         },
         .grep => {
-            try fzf.grep(allocator, db, config);
+            try navigate.grep(allocator, history, app_config);
         },
         .cleanup => {
-            try db.cleanup();
+            try history.cleanup();
         },
-        .log_dir => {
-            if (args.log_path) |path| {
-                try db.logDir(path);
+        .log_directory => {
+            if (arguments.log_path) |path| {
+                try history.logDirectory(path);
             }
         },
         .log_file => {
-            if (args.log_path) |path| {
-                try db.logFile(path, args.file_type orelse "other", args.file_action orelse "open");
+            if (arguments.log_path) |path| {
+                const file_type = arguments.file_type orelse "other";
+                const file_action = arguments.file_action orelse "open";
+                try history.logFile(path, file_type, file_action);
             }
         },
         .help => {
-            try cli.printUsage();
+            try command_line.printUsage();
         },
     }
+    if (writer.failed) {
+        return error.WriteFailed;
+    }
     try writer.end();
 }
+
+fn printRecentDirectories(writer: *input_output.Writer, history: *database.Database) !void {
+    assert(history.base_path.len > 0);
+    const managed = try history.recentDirectories();
+    defer managed.deinit();
+    for (managed.value) |entry| {
+        assert(entry.path.len > 0);
+        try writer.print("{s}|{s}\n", .{ entry.timestamp orelse "-", entry.path });
+    }
+}
+
+fn printRecentFiles(writer: *input_output.Writer, history: *database.Database) !void {
+    assert(history.base_path.len > 0);
+    const managed = try history.recentFiles();
+    defer managed.deinit();
+    for (managed.value) |entry| {
+        assert(entry.path.len > 0);
+        const timestamp = entry.timestamp orelse "-";
+        try writer.print("{s}|{s}|{s}|{s}\n", .{
+            timestamp,
+            entry.file_type,
+            entry.action,
+            entry.path,
+        });
+    }
+}
+
+fn printSearchResults(
+    writer: *input_output.Writer,
+    history: *database.Database,
+    query: ?[]const u8,
+) !void {
+    assert(history.base_path.len > 0);
+    const needle = query orelse return;
+    const managed = try history.searchHistory(needle);
+    defer managed.deinit();
+    const results = managed.value;
+
+    for (results.directories) |entry| {
+        assert(entry.path.len > 0);
+        try writer.print("D|{s}\n", .{entry.path});
+    }
+    for (results.files) |entry| {
+        assert(entry.path.len > 0);
+        const line = .{ entry.file_type, entry.action, entry.path };
+        try writer.print("F|{s}|{s}|{s}\n", line);
+    }
+}
+
+test {
+    _ = command_line;
+    _ = config;
+    _ = navigate;
+    _ = database;
+}
blob - cbb414bde50183b33c6ff57c94a3f2bb1812b46a
blob + 8bfcbe892c0f88f389d9b48d9f163522e89981a3
--- src/memcached.zig
+++ src/memcached.zig
@@ -1,20 +1,30 @@
 const std = @import("std");
-const io = @import("io.zig");
+const assert = std.debug.assert;
+const input_output = @import("input_output.zig");
 
 pub const Memcached = struct {
     allocator: std.mem.Allocator,
     stream: std.Io.net.Stream,
-    read_buf: [65536]u8 = @splat(0),
-    write_buf: [4096]u8 = @splat(0),
-    // `null` until first use; the Stream's buffered Reader/Writer hold pointers
-    // into this struct's own buffers, so they can only be created once the
-    // struct sits at its final address (i.e. after `init` returns by value).
-    reader: ?std.Io.net.Stream.Reader = null,
-    writer: ?std.Io.net.Stream.Writer = null,
+    read_buffer: [65536]u8 = @splat(0),
+    write_buffer: [4096]u8 = @splat(0),
+    stream_reader: ?std.Io.net.Stream.Reader = null,
+    stream_writer: ?std.Io.net.Stream.Writer = null,
 
+    const chunk_size: usize = 900 * 1024;
+    const chunk_prefix = "\x00chunked:";
+
+    pub const max_value_bytes: usize = 64 * 1024 * 1024;
+
+    comptime {
+        assert(chunk_size > 0);
+        assert(max_value_bytes > chunk_size);
+    }
+
     pub fn init(allocator: std.mem.Allocator, host: []const u8, port: u16) !Memcached {
+        assert(host.len > 0);
+        assert(port > 0);
         const address = try std.Io.net.IpAddress.parseIp4(host, port);
-        const stream = try address.connect(io.rt(), .{ .mode = .stream });
+        const stream = try address.connect(input_output.runtime(), .{ .mode = .stream });
         return .{
             .allocator = allocator,
             .stream = stream,
@@ -22,73 +32,91 @@ pub const Memcached = struct {
     }
 
     pub fn deinit(self: *Memcached) void {
-        if (self.writer) |*writer| {
-            writer.interface.flush() catch |err| {
-                io.warn("failed to flush memcached on deinit: {}\n", .{err});
+        if (self.stream_writer) |*pending| {
+            pending.interface.flush() catch |err| {
+                input_output.warn("failed to flush memcached on deinit: {}\n", .{err});
             };
         }
-        self.stream.close(io.rt());
+        self.stream.close(input_output.runtime());
     }
 
     fn ensureStarted(self: *Memcached) void {
-        if (self.reader != null) return;
-        self.reader = self.stream.reader(io.rt(), &self.read_buf);
-        self.writer = self.stream.writer(io.rt(), &self.write_buf);
+        if (self.stream_reader != null) {
+            return;
+        }
+        self.stream_reader = self.stream.reader(input_output.runtime(), &self.read_buffer);
+        self.stream_writer = self.stream.writer(input_output.runtime(), &self.write_buffer);
+        assert(self.stream_reader != null);
+        assert(self.stream_writer != null);
     }
 
-    fn r(self: *Memcached) *std.Io.Reader {
+    fn reader(self: *Memcached) *std.Io.Reader {
         self.ensureStarted();
-        return &self.reader.?.interface;
+        assert(self.stream_reader != null);
+        assert(self.stream_writer != null);
+        return &self.stream_reader.?.interface;
     }
 
-    fn w(self: *Memcached) *std.Io.Writer {
+    fn writer(self: *Memcached) *std.Io.Writer {
         self.ensureStarted();
-        return &self.writer.?.interface;
+        assert(self.stream_reader != null);
+        assert(self.stream_writer != null);
+        return &self.stream_writer.?.interface;
     }
 
     fn flushWrite(self: *Memcached) !void {
-        try self.w().flush();
+        try self.writer().flush();
     }
 
     fn bufferedWrite(self: *Memcached, data: []const u8) !void {
-        try self.w().writeAll(data);
+        assert(data.len > 0);
+        try self.writer().writeAll(data);
     }
 
-    const chunk_size = 900 * 1024;
-    const chunk_prefix = "\x00chunked:";
-    // Upper bound for any single value we will send or accept. Answers come
-    // from an unauthenticated localhost server, so never allocate blindly
-    // from a peer-supplied length.
-    pub const max_value_bytes = 64 * 1024 * 1024;
-
-    // Large values are split into chunk keys `{key}:{total}:{i}`. Namespacing
-    // chunks by total length means overwriting with a smaller value cannot
-    // alias stale chunks from a previous larger value; leftovers expire via TTL.
     pub fn set(self: *Memcached, key: []const u8, value: []const u8, exptime: u32) !void {
-        if (value.len > max_value_bytes) return error.ValueTooLarge;
+        assertValidKey(key);
+        if (value.len > max_value_bytes) {
+            return error.ValueTooLarge;
+        }
         if (value.len <= chunk_size) {
             try self.sendSet(key, value, exptime);
+            try self.flushWrite();
             return;
         }
 
-        const num_chunks = (value.len + chunk_size - 1) / chunk_size;
-        var meta_buf: [128]u8 = undefined;
-        const meta = try std.fmt.bufPrint(&meta_buf, chunk_prefix ++ "{d}:{d}", .{ num_chunks, value.len });
+        const chunk_count = try std.math.divCeil(usize, value.len, chunk_size);
+        assert(chunk_count > 1);
+        var meta_buffer: [128]u8 = undefined;
+        const meta = try std.fmt.bufPrint(&meta_buffer, chunk_prefix ++ "{d}:{d}", .{
+            chunk_count,
+            value.len,
+        });
         try self.sendSet(key, meta, exptime);
 
-        var i: usize = 0;
-        while (i < num_chunks) : (i += 1) {
-            const start = i * chunk_size;
-            const end = @min((i + 1) * chunk_size, value.len);
-            var chunk_key_buf: [512]u8 = undefined;
-            const chunk_key = try std.fmt.bufPrint(&chunk_key_buf, "{s}:{d}:{d}", .{ key, value.len, i });
+        var index: usize = 0;
+        while (index < chunk_count) : (index += 1) {
+            assert(index < chunk_count);
+            const start = index * chunk_size;
+            const end = @min((index + 1) * chunk_size, value.len);
+            assert(start < value.len);
+            assert(end <= value.len);
+            assert(start < end);
+            var chunk_key_buffer: [512]u8 = undefined;
+            const chunk_key = try formatChunkKey(&chunk_key_buffer, key, value.len, index);
             try self.sendSet(chunk_key, value[start..end], exptime);
         }
+        try self.flushWrite();
     }
 
     fn sendSet(self: *Memcached, key: []const u8, value: []const u8, exptime: u32) !void {
-        var header_buf: [512]u8 = undefined;
-        const header = try std.fmt.bufPrint(&header_buf, "ms {s} {d} T{d} q\r\n", .{ key, value.len, exptime });
+        assertValidKey(key);
+        var header_buffer: [512]u8 = undefined;
+        const header = try std.fmt.bufPrint(&header_buffer, "ms {s} {d} T{d} q\r\n", .{
+            key,
+            value.len,
+            exptime,
+        });
+        assert(header.len > key.len);
 
         try self.bufferedWrite(header);
         if (value.len > 0) {
@@ -97,110 +125,141 @@ pub const Memcached = struct {
         try self.bufferedWrite("\r\n");
     }
 
-    fn readLine(self: *Memcached, buf: []u8) !?[]const u8 {
-        // Inclusive so the '\n' is consumed from the stream; exclusive would
-        // leave it buffered and desync the following `readExact`.
-        const line = self.r().takeDelimiterInclusive('\n') catch |err| switch (err) {
-            error.EndOfStream => return null,
-            error.StreamTooLong => return error.LineTooLong,
-            else => |e| return e,
+    fn readLine(self: *Memcached, buffer: []u8) !?[]const u8 {
+        assert(buffer.len > 0);
+        const line = self.reader().takeDelimiterInclusive('\n') catch |err| {
+            if (err == error.EndOfStream) {
+                return null;
+            }
+            if (err == error.StreamTooLong) {
+                return error.LineTooLong;
+            }
+            return err;
         };
         const trimmed = std.mem.trim(u8, line, "\r\n");
-        if (buf.len < trimmed.len) return error.BufferTooSmall;
-        @memcpy(buf[0..trimmed.len], trimmed);
-        return buf[0..trimmed.len];
+        if (buffer.len < trimmed.len) {
+            return error.BufferTooSmall;
+        }
+        @memcpy(buffer[0..trimmed.len], trimmed);
+        const result = buffer[0..trimmed.len];
+        assert(result.ptr == buffer.ptr);
+        assert(result.len == trimmed.len);
+        return result;
     }
 
-    fn readExact(self: *Memcached, buf: []u8) !void {
-        self.r().readSliceAll(buf) catch |err| switch (err) {
-            error.EndOfStream => return error.IncompleteRead,
-            else => |e| return e,
+    fn readExact(self: *Memcached, buffer: []u8) !void {
+        assert(buffer.len > 0);
+        self.reader().readSliceAll(buffer) catch |err| {
+            if (err == error.EndOfStream) {
+                return error.IncompleteRead;
+            }
+            return err;
         };
     }
 
     fn consumeCrlf(self: *Memcached) void {
-        const reader = self.r();
-        const first = reader.peekByte() catch return;
+        const input = self.reader();
+        const first = input.peekByte() catch return;
         if (first == '\r') {
-            reader.toss(1);
-            const second = reader.peekByte() catch return;
-            if (second == '\n') reader.toss(1);
-        } else if (first == '\n') {
-            reader.toss(1);
+            input.toss(1);
+            const second = input.peekByte() catch return;
+            if (second == '\n') {
+                input.toss(1);
+            }
+            return;
         }
+        if (first == '\n') {
+            input.toss(1);
+        }
     }
 
     pub fn get(self: *Memcached, key: []const u8) !?[]u8 {
-        var cmd_buf: [512]u8 = undefined;
-        const cmd = try std.fmt.bufPrint(&cmd_buf, "mg {s} v\r\n", .{key});
-        try self.bufferedWrite(cmd);
+        assertValidKey(key);
+        var command_buffer: [512]u8 = undefined;
+        const command_text = try std.fmt.bufPrint(&command_buffer, "mg {s} v\r\n", .{key});
+        assert(command_text.len > "mg  v\r\n".len);
+        try self.bufferedWrite(command_text);
         try self.flushWrite();
 
-        var line_buf: [512]u8 = undefined;
-        const line = (try self.readLine(&line_buf)) orelse return null;
+        var line_buffer: [512]u8 = undefined;
+        const line = (try self.readLine(&line_buffer)) orelse return null;
 
-        if (std.mem.startsWith(u8, line, "VA ")) {
-            const rest = line[3..];
-            const space_idx = std.mem.indexOfScalar(u8, rest, ' ') orelse rest.len;
-            const size = try std.fmt.parseInt(usize, rest[0..space_idx], 10);
-            if (size > max_value_bytes) return error.ValueTooLarge;
+        if (!std.mem.startsWith(u8, line, "VA ")) {
+            return null;
+        }
+        assert(line.len >= "VA ".len);
+        const rest = line["VA ".len..];
+        assert(rest.len < line.len);
+        const space_index = std.mem.indexOfScalar(u8, rest, ' ') orelse rest.len;
+        assert(space_index <= rest.len);
+        const size = try std.fmt.parseInt(usize, rest[0..space_index], 10);
+        if (size > max_value_bytes) {
+            return error.ValueTooLarge;
+        }
 
-            const data = try self.allocator.alloc(u8, size);
-            errdefer self.allocator.free(data);
+        const data = try self.allocator.alloc(u8, size);
+        assert(data.len == size);
+        errdefer self.allocator.free(data);
 
-            if (size > 0) {
-                try self.readExact(data);
-            }
+        if (size > 0) {
+            try self.readExact(data);
+        }
 
-            self.consumeCrlf();
+        self.consumeCrlf();
 
-            if (std.mem.startsWith(u8, data, chunk_prefix)) {
-                defer self.allocator.free(data);
-                return try self.reassembleChunks(key, data);
-            }
-
-            return data;
+        if (std.mem.startsWith(u8, data, chunk_prefix)) {
+            defer self.allocator.free(data);
+            return try self.reassembleChunks(key, data);
         }
 
-        return null;
+        return data;
     }
 
     fn reassembleChunks(self: *Memcached, key: []const u8, meta: []const u8) ![]u8 {
-        var it = std.mem.splitScalar(u8, meta[chunk_prefix.len..], ':');
-        const num_chunks_str = it.next() orelse return error.InvalidCacheData;
-        const total_size_str = it.next() orelse return error.InvalidCacheData;
+        assertValidKey(key);
+        const parsed = try parseChunkMeta(meta);
 
-        const num_chunks = try std.fmt.parseInt(usize, num_chunks_str, 10);
-        const total_size = try std.fmt.parseInt(usize, total_size_str, 10);
-        if (num_chunks == 0) return error.InvalidCacheData;
-        if (total_size > max_value_bytes) return error.ValueTooLarge;
-        if (num_chunks != (total_size + chunk_size - 1) / chunk_size) return error.InvalidCacheData;
-
-        const result = try self.allocator.alloc(u8, total_size);
+        const result = try self.allocator.alloc(u8, parsed.total_bytes);
+        assert(result.len == parsed.total_bytes);
         errdefer self.allocator.free(result);
 
-        var i: usize = 0;
-        while (i < num_chunks) : (i += 1) {
-            var chunk_key_buf: [512]u8 = undefined;
-            const chunk_key = try std.fmt.bufPrint(&chunk_key_buf, "{s}:{d}:{d}", .{ key, total_size, i });
+        var index: usize = 0;
+        while (index < parsed.chunk_count) : (index += 1) {
+            assert(index < parsed.chunk_count);
+            var chunk_key_buffer: [512]u8 = undefined;
+            const chunk_key = try formatChunkKey(&chunk_key_buffer, key, parsed.total_bytes, index);
 
-            var cmd_buf: [512]u8 = undefined;
-            const cmd = try std.fmt.bufPrint(&cmd_buf, "mg {s} v\r\n", .{chunk_key});
-            try self.bufferedWrite(cmd);
+            var command_buffer: [512]u8 = undefined;
+            const command_text = try std.fmt.bufPrint(
+                &command_buffer,
+                "mg {s} v\r\n",
+                .{chunk_key},
+            );
+            assert(command_text.len > "mg  v\r\n".len);
+            try self.bufferedWrite(command_text);
             try self.flushWrite();
 
-            var line_buf: [512]u8 = undefined;
-            const line = (try self.readLine(&line_buf)) orelse return error.CacheChunkMissing;
+            var line_buffer: [512]u8 = undefined;
+            const line = (try self.readLine(&line_buffer)) orelse return error.CacheChunkMissing;
 
-            if (!std.mem.startsWith(u8, line, "VA ")) return error.CacheChunkMissing;
+            if (!std.mem.startsWith(u8, line, "VA ")) {
+                return error.CacheChunkMissing;
+            }
+            assert(line.len >= "VA ".len);
 
-            const rest = line[3..];
-            const space_idx = std.mem.indexOfScalar(u8, rest, ' ') orelse rest.len;
-            const size = try std.fmt.parseInt(usize, rest[0..space_idx], 10);
+            const rest = line["VA ".len..];
+            assert(rest.len < line.len);
+            const space_index = std.mem.indexOfScalar(u8, rest, ' ') orelse rest.len;
+            assert(space_index <= rest.len);
+            const size = try std.fmt.parseInt(usize, rest[0..space_index], 10);
 
-            const chunk_start = i * chunk_size;
-            const chunk_end = @min((i + 1) * chunk_size, total_size);
-            if (size != chunk_end - chunk_start) return error.InvalidChunkSize;
+            const chunk_start = index * chunk_size;
+            const chunk_end = @min((index + 1) * chunk_size, parsed.total_bytes);
+            assert(chunk_end <= parsed.total_bytes);
+            assert(chunk_start < chunk_end);
+            if (size != chunk_end - chunk_start) {
+                return error.InvalidChunkSize;
+            }
 
             if (size > 0) {
                 try self.readExact(result[chunk_start..chunk_end]);
@@ -212,3 +271,87 @@ pub const Memcached = struct {
         return result;
     }
 };
+
+fn assertValidKey(key: []const u8) void {
+    assert(key.len > 0);
+    assert(key.len <= 400);
+    for (key) |byte| {
+        assert(byte != ' ');
+        assert(byte != '\r');
+        assert(byte != '\n');
+    }
+}
+
+const ChunkMeta = struct {
+    chunk_count: usize,
+    total_bytes: usize,
+};
+
+fn parseChunkMeta(meta: []const u8) !ChunkMeta {
+    assert(meta.len > Memcached.chunk_prefix.len);
+    assert(std.mem.startsWith(u8, meta, Memcached.chunk_prefix));
+    var fields = std.mem.splitScalar(u8, meta[Memcached.chunk_prefix.len..], ':');
+    const chunk_count_text = fields.next() orelse return error.InvalidCacheData;
+    const total_bytes_text = fields.next() orelse return error.InvalidCacheData;
+
+    const chunk_count = std.fmt.parseInt(usize, chunk_count_text, 10) catch {
+        return error.InvalidCacheData;
+    };
+    const total_bytes = std.fmt.parseInt(usize, total_bytes_text, 10) catch {
+        return error.InvalidCacheData;
+    };
+    if (chunk_count == 0) {
+        return error.InvalidCacheData;
+    }
+    if (total_bytes > Memcached.max_value_bytes) {
+        return error.ValueTooLarge;
+    }
+    const expected_count = try std.math.divCeil(usize, total_bytes, Memcached.chunk_size);
+    if (chunk_count != expected_count) {
+        return error.InvalidCacheData;
+    }
+    return .{ .chunk_count = chunk_count, .total_bytes = total_bytes };
+}
+
+fn formatChunkKey(buffer: *[512]u8, key: []const u8, total_bytes: usize, index: usize) ![]const u8 {
+    assert(buffer.len == 512);
+    assertValidKey(key);
+    assert(total_bytes <= Memcached.max_value_bytes);
+    assert(total_bytes > 0);
+    const result = try std.fmt.bufPrint(buffer, "{s}:{d}:{d}", .{ key, total_bytes, index });
+    assert(result.len > key.len);
+    return result;
+}
+
+const testing = std.testing;
+
+test "parseChunkMeta: accepts a consistent header" {
+    const meta = try parseChunkMeta("\x00chunked:3:2500000");
+    try testing.expectEqual(@as(usize, 3), meta.chunk_count);
+    try testing.expectEqual(@as(usize, 2500000), meta.total_bytes);
+}
+
+test "parseChunkMeta: rejects zero chunks" {
+    try testing.expectError(error.InvalidCacheData, parseChunkMeta("\x00chunked:0:0"));
+}
+
+test "parseChunkMeta: rejects inconsistent counts" {
+    try testing.expectError(error.InvalidCacheData, parseChunkMeta("\x00chunked:2:2500000"));
+    try testing.expectError(error.InvalidCacheData, parseChunkMeta("\x00chunked:9:2500000"));
+}
+
+test "parseChunkMeta: rejects oversize totals" {
+    try testing.expectError(error.ValueTooLarge, parseChunkMeta("\x00chunked:73:67108865"));
+}
+
+test "parseChunkMeta: rejects malformed fields" {
+    try testing.expectError(error.InvalidCacheData, parseChunkMeta("\x00chunked:3"));
+    try testing.expectError(error.InvalidCacheData, parseChunkMeta("\x00chunked:three:2500000"));
+    try testing.expectError(error.InvalidCacheData, parseChunkMeta("\x00chunked:3:lots"));
+}
+
+test "formatChunkKey: namespaces chunks by total length" {
+    var buffer: [512]u8 = undefined;
+    const key = try formatChunkKey(&buffer, "magdalena:look:directory:9:1:3", 2500000, 2);
+    try testing.expectEqualStrings("magdalena:look:directory:9:1:3:2500000:2", key);
+}
blob - /dev/null
blob + 901d36a23dcc38693c8f459f173eda9db9c54ff5 (mode 644)
--- /dev/null
+++ src/navigate.zig
@@ -0,0 +1,1167 @@
+const std = @import("std");
+const assert = std.debug.assert;
+const cache = @import("memcached.zig");
+const config = @import("config.zig");
+const database = @import("database.zig");
+const input_output = @import("input_output.zig");
+const picker = @import("picker.zig");
+
+const max_supported_depth: usize = 128;
+
+comptime {
+    assert(max_supported_depth > 0);
+}
+
+const Entry = struct {
+    path: []const u8,
+    mtime_ns: i128,
+};
+
+fn gotoFile(
+    history: ?*database.Database,
+    file_path: []const u8,
+    line: ?[]const u8,
+    app_config: *const config.Config,
+) !void {
+    if (file_path.len == 0) {
+        return error.EmptyPath;
+    }
+    const dot_index = std.mem.lastIndexOfScalar(u8, file_path, '.');
+    const extension = if (dot_index) |index| file_path[index + 1 ..] else "";
+    const opener = matchOpener(app_config, extension);
+    assert(opener.action.len > 0);
+
+    if (history) |open_history| {
+        open_history.logFile(file_path, extension, opener.action) catch |err| {
+            input_output.warn("failed to log file: {}\n", .{err});
+        };
+    }
+
+    if (std.Io.Dir.openFileAbsolute(input_output.runtime(), "/dev/tty", .{
+        .mode = .read_write,
+        .allow_directory = false,
+        .path_only = false,
+    })) |tty| {
+        if (std.c.dup2(tty.handle, std.posix.STDIN_FILENO) < 0) {
+            input_output.warn("failed to redirect stdin to tty\n", .{});
+        }
+        if (std.c.dup2(tty.handle, std.posix.STDOUT_FILENO) < 0) {
+            input_output.warn("failed to redirect stdout to tty\n", .{});
+        }
+        tty.close(input_output.runtime());
+    } else |err| {
+        input_output.warn("failed to open /dev/tty: {}\n", .{err});
+    }
+
+    const parent_directory = std.fs.path.dirname(file_path) orelse ".";
+    assert(parent_directory.len > 0);
+    const base_name = std.fs.path.basename(file_path);
+    try std.process.setCurrentPath(input_output.runtime(), parent_directory);
+
+    if (opener.command) |command| {
+        return std.process.replace(input_output.runtime(), .{ .argv = &.{ command, base_name } });
+    }
+
+    const fallback_editor = input_output.getenv("EDITOR") orelse return error.EditorNotSet;
+    const editor = app_config.editor orelse fallback_editor;
+
+    if (line) |line_text| {
+        var line_buf: [32]u8 = undefined;
+        const line_arg = try std.fmt.bufPrint(&line_buf, "+{s}", .{line_text});
+        return std.process.replace(input_output.runtime(), .{
+            .argv = &.{ editor, line_arg, base_name },
+        });
+    }
+
+    return std.process.replace(input_output.runtime(), .{ .argv = &.{ editor, base_name } });
+}
+
+const OpenerMatch = struct {
+    action: []const u8,
+    command: ?[]const u8,
+};
+
+fn matchOpener(app_config: *const config.Config, extension: []const u8) OpenerMatch {
+    for (app_config.openers) |opener| {
+        if (opener.command.len == 0) {
+            continue;
+        }
+        if (opener.action.len == 0) {
+            continue;
+        }
+        for (opener.extensions) |opener_extension| {
+            if (opener_extension.len == 0) {
+                continue;
+            }
+            if (std.mem.eql(u8, opener_extension, extension)) {
+                assert(opener.command.len > 0);
+                assert(opener.action.len > 0);
+                return .{ .action = opener.action, .command = opener.command };
+            }
+        }
+    }
+    return .{ .action = "text_editor", .command = null };
+}
+
+fn shouldSkip(path: []const u8, ignored_patterns: []const []const u8) bool {
+    assert(path.len > 0);
+    var components = std.mem.splitScalar(u8, path, std.fs.path.sep);
+    while (components.next()) |component| {
+        for (ignored_patterns) |pattern| {
+            if (std.mem.eql(u8, component, pattern)) {
+                return true;
+            }
+        }
+    }
+    return false;
+}
+
+fn pickerTheme(app_config: *const config.Config) picker.ResolvedTheme {
+    assert(app_config.max_depth <= max_supported_depth);
+    return picker.resolveTheme(input_output.runtime(), app_config.*);
+}
+
+fn printPath(path: []const u8) !void {
+    assert(path.len > 0);
+    var output_buffer: [4096]u8 = undefined;
+    var out = input_output.getStdout(&output_buffer);
+    try out.print("{s}\n", .{path});
+    try out.end();
+}
+
+pub fn recentDirectory(
+    allocator: std.mem.Allocator,
+    history: *database.Database,
+    app_config: *const config.Config,
+) !void {
+    const managed_directories = try history.recentDirectories();
+    defer managed_directories.deinit();
+    const dirs = managed_directories.value;
+
+    if (dirs.len == 0) {
+        input_output.warn("No recent directories found.\n", .{});
+        return;
+    }
+    assert(dirs.len > 0);
+
+    var items = std.ArrayListUnmanaged([]const u8).empty;
+    defer items.deinit(allocator);
+
+    for (dirs) |directory| {
+        if (shouldSkip(directory.path, app_config.ignored_patterns)) {
+            continue;
+        }
+        try items.append(allocator, directory.path);
+    }
+
+    const theme = pickerTheme(app_config);
+    const selected = try picker.pickFromList(allocator, items.items, theme);
+    defer allocator.free(selected);
+    assert(selected.len > 0);
+
+    std.Io.Dir.accessAbsolute(input_output.runtime(), selected, .{
+        .follow_symlinks = true,
+        .read = false,
+        .write = false,
+        .execute = false,
+    }) catch |err| {
+        input_output.warn("Selected directory no longer exists: {s} ({})\n", .{ selected, err });
+        return error.UserAbort;
+    };
+
+    try printPath(selected);
+}
+
+pub fn favorites(
+    allocator: std.mem.Allocator,
+    history: *database.Database,
+    app_config: *const config.Config,
+) !void {
+    if (app_config.favorites.len == 0) {
+        input_output.warn("No favorites found in config.\n", .{});
+        return;
+    }
+    assert(app_config.favorites.len > 0);
+
+    var items = std.ArrayListUnmanaged([]const u8).empty;
+    defer {
+        for (items.items) |item| allocator.free(item);
+        items.deinit(allocator);
+    }
+
+    for (app_config.favorites) |entry| {
+        const expanded = try expandPath(allocator, entry);
+        try items.append(allocator, expanded);
+    }
+    assert(items.items.len == app_config.favorites.len);
+
+    const theme = pickerTheme(app_config);
+    const selected = try picker.pickFromList(allocator, items.items, theme);
+    defer allocator.free(selected);
+    assert(selected.len > 0);
+
+    const is_dir = isDirectory(selected);
+
+    if (is_dir) {
+        try history.logDirectory(selected);
+        try printPath(selected);
+        return;
+    }
+    try gotoFile(history, selected, null, app_config);
+}
+
+fn isDirectory(path: []const u8) bool {
+    assert(path.len > 0);
+    var directory = std.Io.Dir.openDirAbsolute(input_output.runtime(), path, .{
+        .access_sub_paths = true,
+        .iterate = false,
+        .follow_symlinks = true,
+    }) catch return false;
+    defer directory.close(input_output.runtime());
+    return true;
+}
+
+fn expandPath(allocator: std.mem.Allocator, path: []const u8) ![]const u8 {
+    assert(path.len > 0);
+    var result = std.ArrayListUnmanaged(u8).empty;
+    errdefer result.deinit(allocator);
+
+    const home = input_output.getenv("HOME") orelse return error.HomeNotFound;
+
+    var index: usize = 0;
+    while (index < path.len) {
+        assert(index < path.len);
+        if (path[index] == '~') {
+            if (isTildeExpansion(path, index)) {
+                try result.appendSlice(allocator, home);
+            } else {
+                try result.append(allocator, '~');
+            }
+            index += 1;
+            continue;
+        }
+        if (std.mem.startsWith(u8, path[index..], "$HOME")) {
+            try result.appendSlice(allocator, home);
+            index += "$HOME".len;
+            continue;
+        }
+        try result.append(allocator, path[index]);
+        index += 1;
+    }
+
+    return result.toOwnedSlice(allocator);
+}
+
+fn isTildeExpansion(path: []const u8, index: usize) bool {
+    assert(index < path.len);
+    assert(path[index] == '~');
+    if (index == 0) {
+        return true;
+    }
+    if (path[index - 1] == std.fs.path.sep) {
+        return true;
+    }
+    return false;
+}
+
+pub fn recentFile(
+    allocator: std.mem.Allocator,
+    history: *database.Database,
+    app_config: *const config.Config,
+) !void {
+    const managed_files = try history.recentFiles();
+    defer managed_files.deinit();
+    const files = managed_files.value;
+
+    if (files.len == 0) {
+        input_output.warn("No recent files found.\n", .{});
+        return;
+    }
+    assert(files.len > 0);
+
+    var items = std.ArrayListUnmanaged([]const u8).empty;
+    defer items.deinit(allocator);
+
+    for (files) |file| {
+        if (shouldSkip(file.path, app_config.ignored_patterns)) {
+            continue;
+        }
+        try items.append(allocator, file.path);
+    }
+
+    const theme = pickerTheme(app_config);
+    const selected = try picker.pickFromList(allocator, items.items, theme);
+    defer allocator.free(selected);
+    assert(selected.len > 0);
+
+    try std.Io.Dir.accessAbsolute(input_output.runtime(), selected, .{
+        .follow_symlinks = true,
+        .read = false,
+        .write = false,
+        .execute = false,
+    });
+
+    try gotoFile(history, selected, null, app_config);
+}
+
+pub fn grep(
+    allocator: std.mem.Allocator,
+    history: *database.Database,
+    app_config: *const config.Config,
+) !void {
+    assert(history.base_path.len > 0);
+    const theme = pickerTheme(app_config);
+    const selected = try picker.pickGrepLine(allocator, app_config.ignored_patterns, theme);
+    defer allocator.free(selected);
+    assert(selected.len > 0);
+
+    const target = splitGrepSelection(selected);
+    try gotoFile(history, target.file, target.line, app_config);
+}
+
+const GrepSelection = struct {
+    file: []const u8,
+    line: ?[]const u8,
+};
+
+fn splitGrepSelection(selected: []const u8) GrepSelection {
+    assert(selected.len > 0);
+    var fields = std.mem.splitScalar(u8, selected, ':');
+    if (fields.next()) |file| {
+        const target = GrepSelection{ .file = file, .line = fields.next() };
+        assert(target.file.ptr == selected.ptr);
+        return target;
+    }
+    unreachable;
+}
+
+pub fn lookFile(
+    allocator: std.mem.Allocator,
+    history: *database.Database,
+    app_config: *const config.Config,
+    depth: ?usize,
+) !void {
+    try runFzfWithWalker(allocator, history, .file, app_config, depth);
+}
+
+pub fn lookDirectory(
+    allocator: std.mem.Allocator,
+    history: *database.Database,
+    app_config: *const config.Config,
+    depth: ?usize,
+) !void {
+    try runFzfWithWalker(allocator, history, .directory, app_config, depth);
+}
+
+const WalkItem = struct {
+    path: []const u8,
+    kind: std.Io.File.Kind,
+    stat_kind: ?std.Io.File.Kind,
+    mtime_ns: i128,
+};
+
+const TreeWalker = struct {
+    input_output_handle: std.Io,
+    allocator: std.mem.Allocator,
+    ignored_patterns: []const []const u8,
+    max_depth: usize,
+    stack: std.ArrayListUnmanaged(Frame) = .empty,
+
+    const Frame = struct {
+        directory: std.Io.Dir,
+        prefix: ?[]const u8,
+        depth: usize,
+        iterator: std.Io.Dir.Iterator,
+    };
+
+    fn init(
+        input_output_handle: std.Io,
+        allocator: std.mem.Allocator,
+        root: std.Io.Dir,
+        ignored_patterns: []const []const u8,
+        max_depth: usize,
+    ) !TreeWalker {
+        assert(max_depth <= max_supported_depth);
+        var self = TreeWalker{
+            .input_output_handle = input_output_handle,
+            .allocator = allocator,
+            .ignored_patterns = ignored_patterns,
+            .max_depth = max_depth,
+        };
+        errdefer self.deinit();
+        errdefer root.close(input_output_handle);
+        try self.stack.append(allocator, .{
+            .directory = root,
+            .prefix = null,
+            .depth = 0,
+            .iterator = root.iterate(),
+        });
+        assert(self.stack.items.len == 1);
+        assert(self.stack.capacity >= 1);
+        return self;
+    }
+
+    fn deinit(self: *TreeWalker) void {
+        while (self.stack.items.len > 0) {
+            self.popFrame();
+        }
+        assert(self.stack.items.len == 0);
+        self.stack.deinit(self.allocator);
+    }
+
+    fn popFrame(self: *TreeWalker) void {
+        assert(self.stack.items.len > 0);
+        const top_index = self.stack.items.len - 1;
+        assert(top_index < self.stack.items.len);
+        const frame = self.stack.items[top_index];
+        self.stack.items = self.stack.items[0..top_index];
+        frame.directory.close(self.input_output_handle);
+        if (frame.prefix) |prefix| {
+            self.allocator.free(prefix);
+        }
+    }
+
+    fn next(self: *TreeWalker) !?WalkItem {
+        while (self.stack.items.len > 0) {
+            assert(self.stack.items.len <= self.max_depth + 1);
+            const top_index = self.stack.items.len - 1;
+            assert(top_index < self.stack.items.len);
+            const maybe_entry = try self.stack.items[top_index].iterator.next(
+                self.input_output_handle,
+            );
+            if (maybe_entry == null) {
+                self.popFrame();
+                continue;
+            }
+            const entry = maybe_entry.?;
+            assert(entry.name.len > 0);
+            if (try self.descendOrEmit(top_index, entry)) |item| {
+                return item;
+            }
+        }
+        return null;
+    }
+
+    fn descendOrEmit(self: *TreeWalker, top_index: usize, entry: std.Io.Dir.Entry) !?WalkItem {
+        assert(top_index < self.stack.items.len);
+        assert(entry.name.len > 0);
+        const parent = &self.stack.items[top_index];
+        const entry_path = if (parent.prefix) |prefix|
+            try std.fs.path.join(self.allocator, &.{ prefix, entry.name })
+        else
+            try self.allocator.dupe(u8, entry.name);
+        errdefer self.allocator.free(entry_path);
+
+        if (shouldSkip(entry_path, self.ignored_patterns)) {
+            self.allocator.free(entry_path);
+            return null;
+        }
+
+        const stat = parent.directory.statFile(
+            self.input_output_handle,
+            entry.name,
+            .{ .follow_symlinks = true },
+        ) catch |err| {
+            self.allocator.free(entry_path);
+            if (err == error.FileNotFound) {
+                return null;
+            }
+            if (err == error.AccessDenied) {
+                return null;
+            }
+            input_output.warn("failed to stat {s}: {}\n", .{ entry.name, err });
+            return null;
+        };
+
+        if (entry.kind == .directory) {
+            if (parent.depth + 1 < self.max_depth) {
+                self.pushFrame(top_index, entry.name, entry_path) catch |err| {
+                    input_output.warn("failed to open directory {s}: {}\n", .{ entry.name, err });
+                };
+            }
+        }
+        assert(self.stack.items.len <= self.max_depth + 1);
+        assert(entry_path.len >= entry.name.len);
+        return .{
+            .path = entry_path,
+            .kind = entry.kind,
+            .stat_kind = stat.kind,
+            .mtime_ns = stat.mtime.nanoseconds,
+        };
+    }
+
+    fn pushFrame(
+        self: *TreeWalker,
+        parent_index: usize,
+        name: []const u8,
+        prefix_source: []const u8,
+    ) !void {
+        assert(parent_index < self.stack.items.len);
+        assert(name.len > 0);
+        const parent = &self.stack.items[parent_index];
+        assert(parent.depth + 1 < self.max_depth);
+        var sub_dir = try parent.directory.openDir(self.input_output_handle, name, .{
+            .access_sub_paths = true,
+            .iterate = true,
+            .follow_symlinks = true,
+        });
+        errdefer sub_dir.close(self.input_output_handle);
+        const owned_prefix = try self.allocator.dupe(u8, prefix_source);
+        errdefer self.allocator.free(owned_prefix);
+        try self.stack.append(self.allocator, .{
+            .directory = sub_dir,
+            .prefix = owned_prefix,
+            .depth = parent.depth + 1,
+            .iterator = sub_dir.iterate(),
+        });
+        assert(self.stack.items.len <= self.max_depth + 1);
+        const top_frame = &self.stack.items[self.stack.items.len - 1];
+        assert(top_frame.prefix != null);
+        if (top_frame.prefix) |prefix| {
+            assert(std.mem.eql(u8, prefix, prefix_source));
+        }
+    }
+};
+
+fn collectWalkEntries(
+    input_output_handle: std.Io,
+    allocator: std.mem.Allocator,
+    root: std.Io.Dir,
+    kind: std.Io.File.Kind,
+    ignored_patterns: []const []const u8,
+    max_depth: usize,
+    entries: *std.ArrayListUnmanaged(Entry),
+) !void {
+    assert(max_depth <= max_supported_depth);
+    assert(entries.items.len == 0);
+    var walker = try TreeWalker.init(
+        input_output_handle,
+        allocator,
+        root,
+        ignored_patterns,
+        max_depth,
+    );
+    defer walker.deinit();
+    while (try walker.next()) |item| {
+        defer allocator.free(item.path);
+        assert(item.path.len > 0);
+        if (item.stat_kind == null) {
+            continue;
+        }
+        if (item.kind == kind) {
+            try appendWalkEntry(allocator, entries, item);
+            continue;
+        }
+        if (item.kind == .sym_link) {
+            if (item.stat_kind == kind) {
+                try appendWalkEntry(allocator, entries, item);
+            }
+        }
+    }
+}
+
+fn appendWalkEntry(
+    allocator: std.mem.Allocator,
+    entries: *std.ArrayListUnmanaged(Entry),
+    item: WalkItem,
+) !void {
+    assert(item.path.len > 0);
+    assert(item.stat_kind != null);
+    const owned_path = try allocator.dupe(u8, item.path);
+    assert(owned_path.len == item.path.len);
+    try entries.append(allocator, .{
+        .path = owned_path,
+        .mtime_ns = item.mtime_ns,
+    });
+    assert(entries.items[entries.items.len - 1].path.len == item.path.len);
+}
+
+fn measureMaxDirectoryMtimeNs(
+    input_output_handle: std.Io,
+    allocator: std.mem.Allocator,
+    root: std.Io.Dir,
+    ignored_patterns: []const []const u8,
+    max_depth: usize,
+) !i128 {
+    assert(max_depth <= max_supported_depth);
+    var max_mtime_ns: i128 = 0;
+
+    if (root.stat(input_output_handle)) |root_stat| {
+        max_mtime_ns = root_stat.mtime.nanoseconds;
+    } else |err| {
+        input_output.warn("failed to stat walk root for cache key: {}\n", .{err});
+    }
+
+    var walker = try TreeWalker.init(
+        input_output_handle,
+        allocator,
+        root,
+        ignored_patterns,
+        max_depth,
+    );
+    defer walker.deinit();
+    while (try walker.next()) |item| {
+        defer allocator.free(item.path);
+        assert(item.path.len > 0);
+        if (item.stat_kind == null) {
+            continue;
+        }
+        if (item.stat_kind == .directory) {
+            if (item.mtime_ns > max_mtime_ns) {
+                max_mtime_ns = item.mtime_ns;
+            }
+        }
+    }
+    return max_mtime_ns;
+}
+
+fn isSafeCachedPath(path: []const u8) bool {
+    if (path.len == 0) {
+        return false;
+    }
+    if (std.mem.indexOfScalar(u8, path, 0) != null) {
+        return false;
+    }
+    if (std.fs.path.isAbsolute(path)) {
+        return false;
+    }
+    var components = std.mem.splitScalar(u8, path, std.fs.path.sep);
+    while (components.next()) |component| {
+        if (std.mem.eql(u8, component, "..")) {
+            return false;
+        }
+    }
+    return true;
+}
+
+const WalkCache = struct {
+    allocator: std.mem.Allocator,
+    client: ?cache.Memcached,
+    key: ?[]const u8 = null,
+
+    fn load(
+        self: *WalkCache,
+        app_config: *const config.Config,
+        searching_directories: bool,
+        max_depth: usize,
+        entries: *std.ArrayListUnmanaged(Entry),
+    ) !bool {
+        assert(max_depth <= max_supported_depth);
+        const client = if (self.client) |*cache_client| cache_client else return false;
+        self.key = try computeWalkCacheKey(
+            self.allocator,
+            app_config,
+            searching_directories,
+            max_depth,
+        );
+        const key = self.key orelse return false;
+        assert(key.len > 0);
+        return try readValidatedWalkEntries(self.allocator, client, key, entries);
+    }
+
+    fn computeWalkCacheKey(
+        allocator: std.mem.Allocator,
+        app_config: *const config.Config,
+        searching_directories: bool,
+        max_depth: usize,
+    ) !?[]const u8 {
+        assert(max_depth <= max_supported_depth);
+        const current_directory = std.process.currentPathAlloc(
+            input_output.runtime(),
+            allocator,
+        ) catch |err| {
+            input_output.warn("failed to get working directory for cache: {}\n", .{err});
+            return null;
+        };
+        defer allocator.free(current_directory);
+
+        const hash = std.hash.Crc32.hash(current_directory);
+        const walk_root = try openWalkRoot();
+        const max_directory_mtime_ns = measureMaxDirectoryMtimeNs(
+            input_output.runtime(),
+            allocator,
+            walk_root,
+            app_config.ignored_patterns,
+            max_depth,
+        ) catch |err| fallback: {
+            input_output.warn("failed to measure directory times for cache key: {}\n", .{err});
+            break :fallback 0;
+        };
+        const mtime_seconds = @divTrunc(max_directory_mtime_ns, std.time.ns_per_s);
+        const kind_name: []const u8 = if (searching_directories) "directory" else "file";
+        const key = try std.fmt.allocPrint(
+            allocator,
+            "magdalena:look:{s}:{x}:{d}:{d}",
+            .{ kind_name, hash, mtime_seconds, max_depth },
+        );
+        assert(key.len > 0);
+        return key;
+    }
+
+    fn readValidatedWalkEntries(
+        allocator: std.mem.Allocator,
+        client: *cache.Memcached,
+        key: []const u8,
+        entries: *std.ArrayListUnmanaged(Entry),
+    ) !bool {
+        assert(key.len > 0);
+        const maybe_data = client.get(key) catch |err| {
+            input_output.warn("ignoring cache read failure: {}\n", .{err});
+            return false;
+        };
+        const cached_data = maybe_data orelse return false;
+        defer allocator.free(cached_data);
+
+        const maybe_parsed: ?std.json.Parsed([]Entry) = std.json.parseFromSlice(
+            []Entry,
+            allocator,
+            cached_data,
+            .{
+                .allocate = .alloc_always,
+                .ignore_unknown_fields = false,
+                .duplicate_field_behavior = .@"error",
+                .max_value_len = cache.Memcached.max_value_bytes,
+            },
+        ) catch |err| fallback: {
+            input_output.warn("ignoring corrupt cache entry: {}\n", .{err});
+            break :fallback null;
+        };
+        const parsed = maybe_parsed orelse return false;
+        defer parsed.deinit();
+
+        var valid_count: usize = 0;
+        for (parsed.value) |cached_entry| {
+            if (!isSafeCachedPath(cached_entry.path)) {
+                continue;
+            }
+            try entries.append(allocator, .{
+                .path = try allocator.dupe(u8, cached_entry.path),
+                .mtime_ns = cached_entry.mtime_ns,
+            });
+            valid_count += 1;
+        }
+        assert(valid_count <= parsed.value.len);
+        if (valid_count == 0) {
+            if (parsed.value.len == 0) {
+                return true;
+            }
+            return false;
+        }
+        return true;
+    }
+
+    const walk_cache_ttl_seconds: u32 = 3600;
+
+    fn store(self: *WalkCache, entries: []const Entry) !void {
+        if (self.client == null) {
+            return;
+        }
+        const client = &self.client.?;
+        const key = self.key orelse return;
+        assert(key.len > 0);
+        const json_data = try std.json.Stringify.valueAlloc(self.allocator, entries, .{});
+        defer self.allocator.free(json_data);
+        assert(json_data.len > 0);
+        client.set(key, json_data, walk_cache_ttl_seconds) catch |err| {
+            input_output.warn("failed to write cache: {}\n", .{err});
+        };
+    }
+
+    fn deinit(self: *WalkCache) void {
+        if (self.client) |*client| {
+            client.deinit();
+        }
+        if (self.key) |key| {
+            self.allocator.free(key);
+        }
+    }
+};
+
+fn connectCache(allocator: std.mem.Allocator) ?cache.Memcached {
+    if (cache.Memcached.init(allocator, "127.0.0.1", 11211)) |client| {
+        return client;
+    } else |err| {
+        if (err != error.ConnectionRefused) {
+            input_output.warn("failed to initialize memcached: {}\n", .{err});
+        }
+        return null;
+    }
+}
+
+fn openWalkRoot() !std.Io.Dir {
+    return std.Io.Dir.cwd().openDir(input_output.runtime(), ".", .{
+        .access_sub_paths = true,
+        .iterate = true,
+        .follow_symlinks = true,
+    });
+}
+
+fn runFzfWithWalker(
+    allocator: std.mem.Allocator,
+    history: *database.Database,
+    kind: std.Io.File.Kind,
+    app_config: *const config.Config,
+    depth: ?usize,
+) !void {
+    const searching_directories = kind == .directory;
+    const max_depth = depth orelse app_config.max_depth;
+    if (max_depth > max_supported_depth) {
+        return error.DepthTooLarge;
+    }
+    assert(max_depth <= max_supported_depth);
+
+    var entries = std.ArrayListUnmanaged(Entry).empty;
+    assert(entries.items.len == 0);
+    defer {
+        for (entries.items) |entry| allocator.free(entry.path);
+        entries.deinit(allocator);
+    }
+
+    var walk_cache = WalkCache{
+        .allocator = allocator,
+        .client = connectCache(allocator),
+    };
+    defer walk_cache.deinit();
+
+    const cache_hit = try walk_cache.load(app_config, searching_directories, max_depth, &entries);
+    if (!cache_hit) {
+        const root = try openWalkRoot();
+        try collectWalkEntries(
+            input_output.runtime(),
+            allocator,
+            root,
+            kind,
+            app_config.ignored_patterns,
+            max_depth,
+            &entries,
+        );
+        try walk_cache.store(entries.items);
+    }
+
+    walk_cache.deinit();
+    walk_cache.client = null;
+    walk_cache.key = null;
+
+    try sortPickAndOpen(allocator, history, app_config, searching_directories, &entries);
+}
+
+fn compareEntryMtimeDesc(_: void, left: Entry, right: Entry) bool {
+    assert(left.path.len > 0);
+    assert(right.path.len > 0);
+    if (left.mtime_ns != right.mtime_ns) {
+        return left.mtime_ns > right.mtime_ns;
+    }
+    return std.mem.order(u8, left.path, right.path) == .lt;
+}
+
+fn sortPickAndOpen(
+    allocator: std.mem.Allocator,
+    history: *database.Database,
+    app_config: *const config.Config,
+    searching_directories: bool,
+    entries: *std.ArrayListUnmanaged(Entry),
+) !void {
+    std.mem.sortUnstable(Entry, entries.items, {}, compareEntryMtimeDesc);
+
+    var items = std.ArrayListUnmanaged([]const u8).empty;
+    defer items.deinit(allocator);
+    for (entries.items) |entry| {
+        assert(entry.path.len > 0);
+        try items.append(allocator, entry.path);
+    }
+    assert(items.items.len == entries.items.len);
+
+    const theme = pickerTheme(app_config);
+    const selected = try picker.pickFromList(allocator, items.items, theme);
+    defer allocator.free(selected);
+    assert(selected.len > 0);
+
+    var clean_path = selected;
+    if (std.mem.startsWith(u8, selected, "./")) {
+        clean_path = selected["./".len..];
+    }
+    if (clean_path.len == 0) {
+        return;
+    }
+    std.Io.Dir.cwd().access(input_output.runtime(), clean_path, .{
+        .follow_symlinks = true,
+        .read = false,
+        .write = false,
+        .execute = false,
+    }) catch |err| {
+        input_output.warn("Selected item no longer exists or is inaccessible:" ++
+            " {s} ({})\n", .{ clean_path, err });
+        return error.UserAbort;
+    };
+    if (searching_directories) {
+        try printPath(clean_path);
+        return;
+    }
+    try gotoFile(history, clean_path, null, app_config);
+}
+
+const testing = std.testing;
+
+test "shouldSkip: matches an exact path component" {
+    const ignored = [_][]const u8{ "node_modules", ".git" };
+    try testing.expect(shouldSkip("src/node_modules/pkg/x.js", &ignored));
+    try testing.expect(shouldSkip(".git/config", &ignored));
+    try testing.expect(shouldSkip("a/b/.git", &ignored));
+}
+
+test "shouldSkip: partial component is not a match" {
+    const ignored = [_][]const u8{"node_modules"};
+    try testing.expect(!shouldSkip("src/lib/main.zig", &ignored));
+    try testing.expect(!shouldSkip("src/node_modules_old/x", &ignored));
+}
+
+test "shouldSkip: empty pattern set never skips" {
+    try testing.expect(!shouldSkip("a/b/c", &.{}));
+}
+
+test "isTildeExpansion: start and post-separator tildes expand" {
+    try testing.expect(isTildeExpansion("~/x", 0));
+    try testing.expect(isTildeExpansion("a/~/x", 2));
+}
+
+test "isTildeExpansion: mid-component tildes stay literal" {
+    try testing.expect(!isTildeExpansion("a~b", 1));
+    try testing.expect(!isTildeExpansion("a/b~c", 3));
+}
+
+test "isSafeCachedPath: accepts plain relative paths" {
+    try testing.expect(isSafeCachedPath("src/main.zig"));
+    try testing.expect(isSafeCachedPath("./a/b"));
+}
+
+test "isSafeCachedPath: rejects escapes and junk" {
+    try testing.expect(!isSafeCachedPath(""));
+    try testing.expect(!isSafeCachedPath("/etc/passwd"));
+    try testing.expect(!isSafeCachedPath("a/../../etc/passwd"));
+    try testing.expect(!isSafeCachedPath(".."));
+    try testing.expect(!isSafeCachedPath("a\x00b"));
+}
+
+test "matchOpener: openers with empty command or action are skipped" {
+    const openers = [_]config.Config.Opener{
+        .{ .extensions = &.{"zig"}, .action = "", .command = "nvim" },
+        .{ .extensions = &.{"zig"}, .action = "text_editor", .command = "" },
+        .{ .extensions = &.{"zig"}, .action = "text_editor", .command = "nvim" },
+    };
+    const app_config = config.Config{ .openers = @constCast(&openers) };
+    try testing.expectEqualStrings("nvim", matchOpener(&app_config, "zig").command.?);
+}
+
+test "gotoFile: empty path is an error, not a crash" {
+    const app_config = config.Config{};
+    const result = gotoFile(null, "", null, &app_config);
+    try testing.expectError(error.EmptyPath, result);
+}
+
+test "matchOpener: first matching extension wins" {
+    const openers = [_]config.Config.Opener{
+        .{ .extensions = &.{ "mp4", "mkv" }, .action = "media_player", .command = "mpv" },
+        .{ .extensions = &.{"zig"}, .action = "text_editor", .command = "nvim" },
+    };
+    const app_config = config.Config{ .openers = @constCast(&openers) };
+    try testing.expectEqualStrings("mpv", matchOpener(&app_config, "mkv").command.?);
+    try testing.expectEqualStrings("text_editor", matchOpener(&app_config, "txt").action);
+    try testing.expect(matchOpener(&app_config, "txt").command == null);
+    try testing.expectEqualStrings("text_editor", matchOpener(&app_config, "").action);
+}
+
+test "splitGrepSelection: file with line and column" {
+    const target = splitGrepSelection("src/main.zig:12:4:needle");
+    try testing.expectEqualStrings("src/main.zig", target.file);
+    try testing.expectEqualStrings("12", target.line.?);
+}
+
+test "splitGrepSelection: bare filename has no line" {
+    const target = splitGrepSelection("README.md");
+    try testing.expectEqualStrings("README.md", target.file);
+    try testing.expect(target.line == null);
+}
+
+test "TreeWalker: collects nested entries without recursion" {
+    var temp_directory = testing.tmpDir(.{ .iterate = true });
+    defer temp_directory.cleanup();
+    const test_input_output = testing.io;
+
+    try temp_directory.dir.createDir(test_input_output, "sub", .fromMode(0o755));
+    try temp_directory.dir.createDir(test_input_output, "sub/deep", .fromMode(0o755));
+    try writeTestFile(&temp_directory.dir, test_input_output, "top.txt", "top");
+    try writeTestFile(&temp_directory.dir, test_input_output, "sub/mid.txt", "mid");
+    try writeTestFile(&temp_directory.dir, test_input_output, "sub/deep/bottom.txt", "bottom");
+
+    const root = try temp_directory.dir.openDir(test_input_output, ".", .{
+        .access_sub_paths = true,
+        .iterate = true,
+        .follow_symlinks = true,
+    });
+    var entries = std.ArrayListUnmanaged(Entry).empty;
+    defer {
+        for (entries.items) |entry| testing.allocator.free(entry.path);
+        entries.deinit(testing.allocator);
+    }
+    try collectWalkEntries(test_input_output, testing.allocator, root, .file, &.{}, 10, &entries);
+    try testing.expectEqual(@as(usize, 3), entries.items.len);
+
+    var found_top = false;
+    var found_mid = false;
+    var found_bottom = false;
+    for (entries.items) |entry| {
+        if (std.mem.eql(u8, entry.path, "top.txt")) found_top = true;
+        if (std.mem.eql(u8, entry.path, "sub/mid.txt")) found_mid = true;
+        if (std.mem.eql(u8, entry.path, "sub/deep/bottom.txt")) found_bottom = true;
+    }
+    try testing.expect(found_top);
+    try testing.expect(found_mid);
+    try testing.expect(found_bottom);
+}
+
+test "TreeWalker: ignored components are pruned, not descended" {
+    var temp_directory = testing.tmpDir(.{ .iterate = true });
+    defer temp_directory.cleanup();
+    const test_input_output = testing.io;
+
+    try temp_directory.dir.createDir(test_input_output, "node_modules", .fromMode(0o755));
+    try writeTestFile(&temp_directory.dir, test_input_output, "keep.txt", "keep");
+    try writeTestFile(&temp_directory.dir, test_input_output, "node_modules/hidden.txt", "hidden");
+
+    const ignored = [_][]const u8{"node_modules"};
+    const root = try temp_directory.dir.openDir(test_input_output, ".", .{
+        .access_sub_paths = true,
+        .iterate = true,
+        .follow_symlinks = true,
+    });
+    var entries = std.ArrayListUnmanaged(Entry).empty;
+    defer {
+        for (entries.items) |entry| testing.allocator.free(entry.path);
+        entries.deinit(testing.allocator);
+    }
+    try collectWalkEntries(
+        test_input_output,
+        testing.allocator,
+        root,
+        .file,
+        &ignored,
+        10,
+        &entries,
+    );
+    try testing.expectEqual(@as(usize, 1), entries.items.len);
+    try testing.expectEqualStrings("keep.txt", entries.items[0].path);
+}
+
+test "TreeWalker: max depth bounds descent" {
+    var temp_directory = testing.tmpDir(.{ .iterate = true });
+    defer temp_directory.cleanup();
+    const test_input_output = testing.io;
+
+    try temp_directory.dir.createDir(test_input_output, "sub", .fromMode(0o755));
+    try writeTestFile(&temp_directory.dir, test_input_output, "top.txt", "top");
+    try writeTestFile(&temp_directory.dir, test_input_output, "sub/mid.txt", "mid");
+
+    const root = try temp_directory.dir.openDir(test_input_output, ".", .{
+        .access_sub_paths = true,
+        .iterate = true,
+        .follow_symlinks = true,
+    });
+    var entries = std.ArrayListUnmanaged(Entry).empty;
+    defer {
+        for (entries.items) |entry| testing.allocator.free(entry.path);
+        entries.deinit(testing.allocator);
+    }
+    try collectWalkEntries(test_input_output, testing.allocator, root, .file, &.{}, 1, &entries);
+    try testing.expectEqual(@as(usize, 1), entries.items.len);
+    try testing.expectEqualStrings("top.txt", entries.items[0].path);
+}
+
+test "TreeWalker: empty directories yield no entries" {
+    var temp_directory = testing.tmpDir(.{ .iterate = true });
+    defer temp_directory.cleanup();
+    const test_input_output = testing.io;
+
+    const root = try temp_directory.dir.openDir(test_input_output, ".", .{
+        .access_sub_paths = true,
+        .iterate = true,
+        .follow_symlinks = true,
+    });
+    var entries = std.ArrayListUnmanaged(Entry).empty;
+    defer entries.deinit(testing.allocator);
+    try collectWalkEntries(test_input_output, testing.allocator, root, .file, &.{}, 10, &entries);
+    try testing.expectEqual(@as(usize, 0), entries.items.len);
+}
+
+test "measureMaxDirectoryMtimeNs: sees newly added files" {
+    var temp_directory = testing.tmpDir(.{ .iterate = true });
+    defer temp_directory.cleanup();
+    const test_input_output = testing.io;
+
+    const root_before = try temp_directory.dir.openDir(test_input_output, ".", .{
+        .access_sub_paths = true,
+        .iterate = true,
+        .follow_symlinks = true,
+    });
+    const before = try measureMaxDirectoryMtimeNs(
+        test_input_output,
+        testing.allocator,
+        root_before,
+        &.{},
+        10,
+    );
+
+    try writeTestFile(&temp_directory.dir, test_input_output, "new.txt", "new");
+
+    const root_after = try temp_directory.dir.openDir(test_input_output, ".", .{
+        .access_sub_paths = true,
+        .iterate = true,
+        .follow_symlinks = true,
+    });
+    const after = try measureMaxDirectoryMtimeNs(
+        test_input_output,
+        testing.allocator,
+        root_after,
+        &.{},
+        10,
+    );
+    try testing.expect(after >= before);
+}
+
+test "runFzfWithWalker: rejects depths above the supported maximum" {
+    var test_history = database.Database{
+        .allocator = testing.allocator,
+        .base_path = "",
+        .directories_path = "",
+        .files_path = "",
+    };
+    const app_config = config.Config{};
+    const too_deep = max_supported_depth + 1;
+    try testing.expectError(
+        error.DepthTooLarge,
+        runFzfWithWalker(testing.allocator, &test_history, .file, &app_config, too_deep),
+    );
+}
+
+fn writeTestFile(
+    directory: *std.Io.Dir,
+    input_output_handle: std.Io,
+    path: []const u8,
+    content: []const u8,
+) !void {
+    assert(path.len > 0);
+    assert(content.len > 0);
+    var file = try directory.createFile(input_output_handle, path, .{
+        .read = false,
+        .truncate = true,
+        .exclusive = false,
+        .lock = .none,
+        .lock_nonblocking = false,
+        .permissions = .fromMode(0o644),
+        .resolve_beneath = false,
+    });
+    defer file.close(input_output_handle);
+    var buffer: [4096]u8 = undefined;
+    var writer = file.writer(input_output_handle, &buffer);
+    try writer.interface.writeAll(content);
+    try writer.interface.flush();
+}
blob - /dev/null
blob + bc8a351215f71092aea30b412f2fd5fa2f31d6b7 (mode 644)
--- /dev/null
+++ src/picker.zig
@@ -0,0 +1,2395 @@
+const std = @import("std");
+const builtin = @import("builtin");
+const assert = std.debug.assert;
+const fuzzig = @import("fuzzig");
+const vaxis = @import("vaxis");
+const vxfw = vaxis.vxfw;
+const config = @import("config.zig");
+const input_output = @import("input_output.zig");
+
+pub const max_haystack_len: usize = 1024;
+pub const max_needle_len: usize = 256;
+pub const max_grep_lines: usize = 20000;
+pub const max_grep_stdout_bytes: usize = 32 * 1024 * 1024;
+pub const max_excerpt_bytes: usize = 64 * 1024;
+const min_terminal_columns: u16 = 20;
+const min_terminal_rows: u16 = 8;
+pub const excerpt_context_before: usize = 5;
+pub const excerpt_context_after: usize = 15;
+
+comptime {
+    assert(max_haystack_len > 0);
+    assert(max_needle_len > 0);
+    assert(max_grep_lines > 0);
+    assert(max_grep_stdout_bytes > 0);
+    assert(max_excerpt_bytes > 0);
+}
+
+const FuzzyScorer = struct {
+    sensitive: fuzzig.Ascii,
+    insensitive: fuzzig.Ascii,
+    haystack_max: usize,
+
+    fn initScorer(
+        allocator: std.mem.Allocator,
+        haystack_max: usize,
+        case_sensitive: bool,
+    ) !fuzzig.Ascii {
+        assert(haystack_max > 0);
+        assert(haystack_max <= max_haystack_len);
+        return try fuzzig.Ascii.init(allocator, haystack_max, max_needle_len, .{
+            .case_sensitive = case_sensitive,
+        });
+    }
+
+    fn init(allocator: std.mem.Allocator, longest_haystack: usize) !FuzzyScorer {
+        assert(longest_haystack > 0);
+        const haystack_max = @min(longest_haystack, max_haystack_len);
+        assert(haystack_max > 0);
+        var sensitive = try initScorer(allocator, haystack_max, true);
+        errdefer sensitive.deinit();
+        var insensitive = try initScorer(allocator, haystack_max, false);
+        errdefer insensitive.deinit();
+        return .{
+            .sensitive = sensitive,
+            .insensitive = insensitive,
+            .haystack_max = haystack_max,
+        };
+    }
+
+    fn deinit(self: *FuzzyScorer) void {
+        self.sensitive.deinit();
+        self.insensitive.deinit();
+    }
+
+    fn clampHaystack(self: *const FuzzyScorer, text: []const u8) struct {
+        slice: []const u8,
+        offset: usize,
+    } {
+        assert(text.len > 0);
+        if (text.len <= self.haystack_max) {
+            return .{ .slice = text, .offset = 0 };
+        }
+        const offset = text.len - self.haystack_max;
+        assert(offset < text.len);
+        return .{ .slice = text[offset..], .offset = offset };
+    }
+
+    fn clampNeedle(needle: []const u8) []const u8 {
+        if (needle.len <= max_needle_len) {
+            return needle;
+        }
+        const clamped = needle[0..max_needle_len];
+        assert(clamped.len == max_needle_len);
+        return clamped;
+    }
+
+    fn slashNeedle(arena: std.mem.Allocator, needle: []const u8) !?[]const u8 {
+        assert(needle.len > 0);
+        if (std.mem.indexOfScalar(u8, needle, ' ') == null) {
+            return null;
+        }
+        const copy = try arena.dupe(u8, needle);
+        assert(copy.len == needle.len);
+        std.mem.replaceScalar(u8, copy, ' ', '/');
+        return copy;
+    }
+
+    fn scorerFor(self: *FuzzyScorer, query: []const u8) *fuzzig.Ascii {
+        const scorer = if (hasUppercase(query)) &self.sensitive else &self.insensitive;
+        if (scorer == &self.sensitive) {
+            return scorer;
+        }
+        assert(scorer == &self.insensitive);
+        return scorer;
+    }
+};
+
+fn hasUppercase(text: []const u8) bool {
+    for (text) |byte| {
+        if (std.ascii.isUpper(byte)) {
+            return true;
+        }
+    }
+    return false;
+}
+
+fn checkTerminalSize(input_output_handle: std.Io) !void {
+    if (input_output.getenv("TERM")) |term| {
+        if (std.mem.eql(u8, term, "dumb")) {
+            input_output.warn("dumb terminal cannot run picker\n", .{});
+            return error.NoTerminal;
+        }
+    }
+    if (builtin.os.tag != .linux) {
+        return;
+    }
+    const tty = std.Io.Dir.openFileAbsolute(input_output_handle, "/dev/tty", .{
+        .mode = .read_only,
+        .allow_directory = false,
+        .path_only = false,
+    }) catch |err| {
+        input_output.warn("cannot open terminal for picker: {}\n", .{err});
+        return error.NoTerminal;
+    };
+    defer tty.close(input_output_handle);
+    var size: std.posix.winsize = .{ .row = 0, .col = 0, .xpixel = 0, .ypixel = 0 };
+    const ioctl_rc = std.os.linux.ioctl(tty.handle, std.os.linux.T.IOCGWINSZ, @intFromPtr(&size));
+    if (ioctl_rc != 0) {
+        input_output.warn("cannot query terminal size for picker: {d}\n", .{ioctl_rc});
+        return error.NoTerminal;
+    }
+    if (size.col < min_terminal_columns) {
+        input_output.warn(
+            "terminal too narrow for picker: {d} columns, need {d}\n",
+            .{ size.col, min_terminal_columns },
+        );
+        return error.TerminalTooSmall;
+    }
+    if (size.row < min_terminal_rows) {
+        input_output.warn(
+            "terminal too short for picker: {d} rows, need {d}\n",
+            .{ size.row, min_terminal_rows },
+        );
+        return error.TerminalTooSmall;
+    }
+}
+
+pub const ResolvedTheme = struct {
+    prompt: vaxis.Style,
+    match: vaxis.Style,
+    cursor_row: vaxis.Style,
+    status: vaxis.Style,
+    preview_current: vaxis.Style,
+};
+
+fn defaultTheme() ResolvedTheme {
+    return .{
+        .prompt = .{ .fg = .{ .index = 4 } },
+        .match = .{ .fg = .{ .index = 4 }, .reverse = true },
+        .cursor_row = .{ .bg = .{ .index = 8 } },
+        .status = .{ .fg = .{ .index = 8 } },
+        .preview_current = .{ .fg = .{ .index = 4 } },
+    };
+}
+
+comptime {
+    assert(defaultTheme().prompt.fg.index == 4);
+    assert(defaultTheme().match.reverse);
+    assert(defaultTheme().cursor_row.bg.index == 8);
+    assert(defaultTheme().status.fg.index == 8);
+    assert(defaultTheme().preview_current.fg.index == 4);
+}
+
+pub fn parseThemeColor(text: []const u8) !vaxis.Color {
+    if (std.mem.eql(u8, text, "default")) {
+        return .default;
+    }
+    if (text.len == 0) {
+        return error.InvalidColor;
+    }
+    if (text[0] == '#') {
+        return parseHexColor(text);
+    }
+    const index = std.fmt.parseInt(u16, text, 10) catch {
+        return error.InvalidColor;
+    };
+    if (index > 255) {
+        return error.InvalidColor;
+    }
+    assert(index <= 255);
+    return .{ .index = @intCast(index) };
+}
+
+fn parseHexColor(text: []const u8) !vaxis.Color {
+    assert(text.len > 0);
+    if (text[0] != '#') {
+        return error.InvalidColor;
+    }
+    if (text.len != 7) {
+        return error.InvalidColor;
+    }
+    var rgb: [3]u8 = undefined;
+    var channel: usize = 0;
+    while (channel < 3) {
+        assert(channel < 3);
+        const pair = text[1 + channel * 2 ..][0..2];
+        assert(pair.len == 2);
+        rgb[channel] = std.fmt.parseUnsigned(u8, pair, 16) catch {
+            return error.InvalidColor;
+        };
+        channel += 1;
+    }
+    assert(channel == 3);
+    return .{ .rgb = rgb };
+}
+
+fn noColorSet() bool {
+    const raw = std.c.getenv("NO_COLOR") orelse return false;
+    return std.mem.span(raw).len > 0;
+}
+
+fn resolveRoleColor(
+    input_output_handle: std.Io,
+    role: []const u8,
+    maybe_text: ?[]const u8,
+    fallback: vaxis.Color,
+) vaxis.Color {
+    assert(role.len > 0);
+    const text = maybe_text orelse return fallback;
+    const color = parseThemeColor(text) catch {
+        if (!builtin.is_test) {
+            warn(input_output_handle, "invalid theme color for '{s}': '{s}', using default\n", .{
+                role,
+                text,
+            });
+        }
+        return fallback;
+    };
+    return color;
+}
+
+pub fn resolveTheme(input_output_handle: std.Io, app_config: config.Config) ResolvedTheme {
+    if (noColorSet()) {
+        return .{
+            .prompt = .{},
+            .match = .{},
+            .cursor_row = .{},
+            .status = .{},
+            .preview_current = .{},
+        };
+    }
+    const defaults = defaultTheme();
+    const theme = app_config.theme;
+    const prompt_fg = resolveRoleColor(
+        input_output_handle,
+        "prompt",
+        theme.prompt,
+        defaults.prompt.fg,
+    );
+    const match_fg = resolveRoleColor(input_output_handle, "match", theme.match, defaults.match.fg);
+    const cursor_bg = resolveRoleColor(
+        input_output_handle,
+        "cursor_row",
+        theme.cursor_row,
+        defaults.cursor_row.bg,
+    );
+    const status_fg = resolveRoleColor(
+        input_output_handle,
+        "status",
+        theme.status,
+        defaults.status.fg,
+    );
+    const marker_fg = resolveRoleColor(
+        input_output_handle,
+        "preview_current",
+        theme.preview_current,
+        defaults.preview_current.fg,
+    );
+    return .{
+        .prompt = .{ .fg = prompt_fg },
+        .match = .{ .fg = match_fg, .reverse = true },
+        .cursor_row = .{ .bg = cursor_bg },
+        .status = .{ .fg = status_fg },
+        .preview_current = .{ .fg = marker_fg },
+    };
+}
+
+pub const RankedItem = struct {
+    index: usize,
+    score: i32,
+    tail_offset: usize,
+    matches: []const usize,
+};
+
+pub fn rankItems(
+    arena: std.mem.Allocator,
+    scorer: *FuzzyScorer,
+    items: []const []const u8,
+    query: []const u8,
+) ![]RankedItem {
+    var ranked = std.ArrayListUnmanaged(RankedItem).empty;
+    const needle = FuzzyScorer.clampNeedle(query);
+    const slash_needle = if (needle.len > 0)
+        try FuzzyScorer.slashNeedle(arena, needle)
+    else
+        null;
+    const searcher = scorer.scorerFor(needle);
+    for (items, 0..) |item, index| {
+        assert(index < items.len);
+        if (item.len == 0) {
+            continue;
+        }
+        if (needle.len == 0) {
+            try ranked.append(arena, .{
+                .index = index,
+                .score = 0,
+                .tail_offset = 0,
+                .matches = &.{},
+            });
+            continue;
+        }
+        const clamped = scorer.clampHaystack(item);
+        assert(clamped.slice.len <= scorer.haystack_max);
+        assert(clamped.slice.len > 0);
+        const direct = searcher.scoreMatches(clamped.slice, needle);
+        var best = direct;
+        if (slash_needle) |slash| {
+            const slashed = searcher.scoreMatches(clamped.slice, slash);
+            const use_slash = if (slashed.score) |slash_score| blk: {
+                const direct_score = direct.score orelse break :blk true;
+                break :blk slash_score >= direct_score;
+            } else false;
+            if (use_slash) {
+                best = slashed;
+            }
+        }
+        const score = best.score orelse continue;
+        const mapped = try arena.alloc(usize, best.matches.len);
+        for (best.matches, 0..) |position, match_index| {
+            assert(match_index < mapped.len);
+            assert(position < clamped.slice.len);
+            mapped[match_index] = position + clamped.offset;
+        }
+        try ranked.append(arena, .{
+            .index = index,
+            .score = score,
+            .tail_offset = clamped.offset,
+            .matches = mapped,
+        });
+    }
+    std.mem.sortUnstable(RankedItem, ranked.items, {}, compareRankedItem);
+    return ranked.toOwnedSlice(arena);
+}
+
+fn compareRankedItem(_: void, left: RankedItem, right: RankedItem) bool {
+    if (left.score != right.score) {
+        return left.score > right.score;
+    }
+    return left.index < right.index;
+}
+
+pub fn appendHighlightSpans(
+    spans: *std.ArrayListUnmanaged(vxfw.RichText.TextSpan),
+    arena: std.mem.Allocator,
+    text: []const u8,
+    matches: []const usize,
+    match_style: vaxis.Style,
+) !void {
+    assert(text.len > 0);
+    const start_len = spans.items.len;
+    var cursor: usize = 0;
+    var match_index: usize = 0;
+    while (match_index < matches.len) {
+        assert(match_index < matches.len);
+        const position = matches[match_index];
+        assert(position < text.len);
+        var run_end = position + 1;
+        while (match_index + 1 < matches.len) {
+            if (matches[match_index + 1] != run_end) {
+                break;
+            }
+            match_index += 1;
+            run_end += 1;
+        }
+        assert(run_end <= text.len);
+        if (position > cursor) {
+            try appendDisplaySafeText(spans, arena, text[cursor..position], .{});
+        }
+        try appendDisplaySafeText(spans, arena, text[position..run_end], match_style);
+        cursor = run_end;
+        match_index += 1;
+    }
+    if (cursor < text.len) {
+        try appendDisplaySafeText(spans, arena, text[cursor..], .{});
+    }
+    assert(spans.items.len >= start_len);
+}
+
+pub fn sanitizeDisplayText(allocator: std.mem.Allocator, text: []const u8) ![]u8 {
+    var out = std.ArrayListUnmanaged(u8).empty;
+    errdefer out.deinit(allocator);
+    const replacement = [_]u8{ 0xEF, 0xBF, 0xBD };
+    var i: usize = 0;
+    var col: usize = 0;
+    while (i < text.len) {
+        const b = text[i];
+        if (b == '\n') {
+            try out.append(allocator, '\n');
+            i += 1;
+            col = 0;
+            continue;
+        }
+        if (b == '\t') {
+            const width = 8 - (col % 8);
+            assert(width >= 1 and width <= 8);
+            try out.appendNTimes(allocator, ' ', width);
+            col += width;
+            i += 1;
+            continue;
+        }
+        if (b == 0x1B) {
+            i = skipAnsiEscape(text, i);
+            continue;
+        }
+        if (b < 0x20 or b == 0x7F) {
+            try out.append(allocator, ' ');
+            col += 1;
+            i += 1;
+            continue;
+        }
+        if (b < 0x80) {
+            try out.append(allocator, b);
+            col += 1;
+            i += 1;
+            continue;
+        }
+        const seq_len = std.unicode.utf8ByteSequenceLength(b) catch {
+            try out.appendSlice(allocator, &replacement);
+            col += 1;
+            i += 1;
+            continue;
+        };
+        if (i + seq_len > text.len) {
+            try out.appendSlice(allocator, &replacement);
+            col += 1;
+            i += 1;
+            continue;
+        }
+        var encoded: [4]u8 = undefined;
+        @memcpy(encoded[0..seq_len], text[i..][0..seq_len]);
+        const codepoint: u21 = switch (seq_len) {
+            2 => std.unicode.utf8Decode2(encoded[0..2].*) catch {
+                try out.appendSlice(allocator, &replacement);
+                col += 1;
+                i += 1;
+                continue;
+            },
+            3 => std.unicode.utf8Decode3(encoded[0..3].*) catch {
+                try out.appendSlice(allocator, &replacement);
+                col += 1;
+                i += 1;
+                continue;
+            },
+            4 => std.unicode.utf8Decode4(encoded[0..4].*) catch {
+                try out.appendSlice(allocator, &replacement);
+                col += 1;
+                i += 1;
+                continue;
+            },
+            else => unreachable,
+        };
+        if (codepoint < 0x20 or (codepoint >= 0x7F and codepoint <= 0x9F)) {
+            try out.append(allocator, ' ');
+        } else {
+            try out.appendSlice(allocator, text[i..][0..seq_len]);
+        }
+        col += 1;
+        i += seq_len;
+    }
+    return out.toOwnedSlice(allocator);
+}
+
+fn skipAnsiEscape(text: []const u8, index: usize) usize {
+    assert(text[index] == 0x1B);
+    var j = index + 1;
+    if (j >= text.len) {
+        return j;
+    }
+    const kind = text[j];
+    if (kind == '[') {
+        j += 1;
+        while (j < text.len) : (j += 1) {
+            if (text[j] >= 0x40 and text[j] <= 0x7E) {
+                return j + 1;
+            }
+        }
+        return j;
+    }
+    if (kind == ']') {
+        j += 1;
+        while (j < text.len) : (j += 1) {
+            if (text[j] == 0x07) {
+                return j + 1;
+            }
+            if (text[j] == 0x1B and j + 1 < text.len and text[j + 1] == '\\') {
+                return j + 2;
+            }
+        }
+        return j;
+    }
+    if (kind == 'P' or kind == 'X' or kind == '^' or kind == '_') {
+        j += 1;
+        while (j < text.len) : (j += 1) {
+            if (text[j] == 0x1B and j + 1 < text.len and text[j + 1] == '\\') {
+                return j + 2;
+            }
+        }
+        return j;
+    }
+    return j + 1;
+}
+
+pub fn appendDisplaySafeText(
+    spans: *std.ArrayListUnmanaged(vxfw.RichText.TextSpan),
+    arena: std.mem.Allocator,
+    text: []const u8,
+    style: vaxis.Style,
+) !void {
+    assert(text.len > 0);
+    const start_len = spans.items.len;
+    const clean = try sanitizeDisplayText(arena, text);
+    if (clean.len == 0) {
+        try spans.append(arena, .{ .text = " ", .style = style });
+    } else {
+        try spans.append(arena, .{ .text = clean, .style = style });
+    }
+    assert(spans.items.len > start_len);
+}
+
+pub fn appendCursorSpans(
+    spans: *std.ArrayListUnmanaged(vxfw.RichText.TextSpan),
+    arena: std.mem.Allocator,
+    source: []const vxfw.RichText.TextSpan,
+    cursor_row: vaxis.Style,
+) !void {
+    assert(source.len > 0);
+    const start_len = spans.items.len;
+    for (source) |span| {
+        assert(span.text.len > 0);
+        var styled = span;
+        styled.style.bg = cursor_row.bg;
+        try spans.append(arena, styled);
+    }
+    assert(spans.items.len == start_len + source.len);
+}
+
+pub fn appendPreviewSpans(
+    spans: *std.ArrayListUnmanaged(vxfw.RichText.TextSpan),
+    arena: std.mem.Allocator,
+    excerpt: []const u8,
+    column_one_based: usize,
+    query_len: usize,
+    theme: ResolvedTheme,
+) !void {
+    assert(excerpt.len > 0);
+    const start_len = spans.items.len;
+    var split = std.mem.splitScalar(u8, excerpt, '\n');
+    while (split.next()) |line| {
+        if (line.len == 0) {
+            continue;
+        }
+        if (isTargetLine(line)) {
+            try appendTargetLineSpans(spans, arena, line, column_one_based, query_len, theme);
+            continue;
+        }
+        try appendDisplaySafeText(spans, arena, line, .{});
+        try spans.append(arena, .{ .text = "\n" });
+    }
+    assert(spans.items.len > start_len);
+}
+
+fn isTargetLine(line: []const u8) bool {
+    assert(line.len > 0);
+    if (line.len < 2) {
+        return false;
+    }
+    if (line[0] != '>') {
+        return false;
+    }
+    if (line[1] != ' ') {
+        return false;
+    }
+    return true;
+}
+
+const PreviewRegion = struct {
+    start: usize,
+    end: usize,
+};
+
+fn appendTargetLineSpans(
+    spans: *std.ArrayListUnmanaged(vxfw.RichText.TextSpan),
+    arena: std.mem.Allocator,
+    line: []const u8,
+    column_one_based: usize,
+    query_len: usize,
+    theme: ResolvedTheme,
+) !void {
+    assert(line.len >= 2);
+    try spans.append(arena, .{ .text = line[0..2], .style = theme.preview_current });
+    const content = line[2..];
+    assert(content.len <= line.len);
+    const region = previewMatchRegion(content, column_one_based, query_len);
+    if (region) |match| {
+        assert(match.start <= content.len);
+        assert(match.end <= content.len);
+        if (match.start > 0) {
+            try appendDisplaySafeText(spans, arena, content[0..match.start], .{});
+        }
+        try appendDisplaySafeText(spans, arena, content[match.start..match.end], theme.match);
+        if (match.end < content.len) {
+            try appendDisplaySafeText(spans, arena, content[match.end..], .{});
+        }
+        try spans.append(arena, .{ .text = "\n" });
+        return;
+    }
+    if (content.len > 0) {
+        try appendDisplaySafeText(spans, arena, content, .{});
+    }
+    try spans.append(arena, .{ .text = "\n" });
+}
+
+fn previewMatchRegion(
+    content: []const u8,
+    column_one_based: usize,
+    query_len: usize,
+) ?PreviewRegion {
+    if (column_one_based == 0) {
+        return null;
+    }
+    if (query_len == 0) {
+        return null;
+    }
+    if (content.len == 0) {
+        return null;
+    }
+    const start = column_one_based - 1;
+    if (start >= content.len) {
+        return null;
+    }
+    assert(start < content.len);
+    var end = start + query_len;
+    if (end > content.len) {
+        end = content.len;
+    }
+    assert(end <= content.len);
+    if (end <= start) {
+        return null;
+    }
+    return .{ .start = start, .end = end };
+}
+
+fn rowConstraints(ctx: vxfw.DrawContext, width: u16, height: u16) vxfw.DrawContext {
+    const constrained = ctx.withConstraints(ctx.min, .{ .width = width, .height = height });
+    assert(constrained.max.width != null);
+    assert(constrained.max.height != null);
+    return constrained;
+}
+
+const ListModel = struct {
+    allocator: std.mem.Allocator,
+    items: []const []const u8,
+    scorer: *FuzzyScorer,
+    theme: ResolvedTheme,
+    arena: std.heap.ArenaAllocator,
+    ranked: []RankedItem = &.{},
+    filtered: std.ArrayListUnmanaged(vxfw.RichText) = .empty,
+    filtered_cursor: std.ArrayListUnmanaged(vxfw.RichText) = .empty,
+    list_view: vxfw.ListView,
+    text_field: vxfw.TextField,
+    selected: ?usize = null,
+    loaded: bool = false,
+
+    fn init(
+        allocator: std.mem.Allocator,
+        items: []const []const u8,
+        scorer: *FuzzyScorer,
+        theme: ResolvedTheme,
+    ) !*ListModel {
+        assert(items.len > 0);
+        assert(items.len <= std.math.maxInt(u32));
+        const self = try allocator.create(ListModel);
+        self.* = .{
+            .allocator = allocator,
+            .items = items,
+            .scorer = scorer,
+            .theme = theme,
+            .arena = std.heap.ArenaAllocator.init(allocator),
+            .list_view = .{
+                .children = .{ .builder = undefined },
+            },
+            .text_field = vxfw.TextField.init(allocator),
+        };
+        self.list_view.children = .{ .builder = .{
+            .userdata = self,
+            .buildFn = ListModel.widgetBuilder,
+        } };
+        self.text_field.userdata = self;
+        self.text_field.onChange = ListModel.onChange;
+        self.text_field.onSubmit = ListModel.onSubmit;
+        assert(self.text_field.userdata != null);
+        self.list_view.item_count = 0;
+        return self;
+    }
+
+    fn deinit(self: *ListModel) void {
+        const allocator = self.allocator;
+        self.arena.deinit();
+        self.text_field.deinit();
+        allocator.destroy(self);
+    }
+
+    fn widget(self: *ListModel) vxfw.Widget {
+        const result: vxfw.Widget = .{
+            .userdata = self,
+            .eventHandler = ListModel.typeErasedEventHandler,
+            .drawFn = ListModel.typeErasedDrawFn,
+        };
+        assert(result.userdata == @as(*anyopaque, @ptrCast(self)));
+        return result;
+    }
+
+    fn typeErasedEventHandler(
+        ptr: *anyopaque,
+        ctx: *vxfw.EventContext,
+        event: vxfw.Event,
+    ) anyerror!void {
+        const self: *ListModel = @ptrCast(@alignCast(ptr));
+        switch (event) {
+            .init => {
+                try ctx.tick(0, self.widget());
+                return ctx.requestFocus(self.text_field.widget());
+            },
+            .tick => {
+                if (!self.loaded) {
+                    self.loaded = true;
+                    const query = try self.text_field.buf.dupe();
+                    defer self.text_field.buf.allocator.free(query);
+                    try self.refilter(query);
+                    ctx.redraw = true;
+                }
+                return;
+            },
+            .key_press => |key| {
+                if (key.matches(vaxis.Key.escape, .{})) {
+                    ctx.quit = true;
+                    assert(ctx.quit);
+                    return;
+                }
+                if (key.matches('c', .{ .ctrl = true })) {
+                    ctx.quit = true;
+                    assert(ctx.quit);
+                    return;
+                }
+                if (key.matches('d', .{ .ctrl = true })) {
+                    ctx.quit = true;
+                    assert(ctx.quit);
+                    return;
+                }
+                if (key.matches(vaxis.Key.up, .{})) {
+                    self.list_view.prevItem(ctx);
+                    return;
+                }
+                if (key.matches('p', .{ .ctrl = true })) {
+                    self.list_view.prevItem(ctx);
+                    return;
+                }
+                if (key.matches(vaxis.Key.down, .{})) {
+                    self.list_view.nextItem(ctx);
+                    return;
+                }
+                if (key.matches('n', .{ .ctrl = true })) {
+                    self.list_view.nextItem(ctx);
+                    return;
+                }
+                return;
+            },
+            .focus_in => {
+                return ctx.requestFocus(self.text_field.widget());
+            },
+            else => {},
+        }
+    }
+
+    fn typeErasedDrawFn(
+        ptr: *anyopaque,
+        ctx: vxfw.DrawContext,
+    ) std.mem.Allocator.Error!vxfw.Surface {
+        const self: *ListModel = @ptrCast(@alignCast(ptr));
+        const max_size = ctx.max.size();
+        assert(max_size.width > 0);
+        assert(max_size.height > 0);
+
+        const match_count = self.filtered.items.len;
+        const total_count = self.items.len;
+        const count_text = try std.fmt.allocPrint(
+            ctx.arena,
+            "{d}/{d}",
+            .{ match_count, total_count },
+        );
+        const count: vxfw.Text = .{ .text = count_text, .style = self.theme.status };
+        const prompt: vxfw.Text = .{ .text = ">", .style = self.theme.prompt };
+
+        const list_height = max_size.height -| 2;
+        const children = try ctx.arena.alloc(vxfw.SubSurface, 4);
+        children[0] = .{
+            .origin = .{ .row = 0, .col = 0 },
+            .surface = try prompt.draw(rowConstraints(ctx, 2, 1)),
+        };
+        children[1] = .{
+            .origin = .{ .row = 0, .col = 2 },
+            .surface = try self.text_field.draw(rowConstraints(ctx, max_size.width -| 2, 1)),
+        };
+        children[2] = .{
+            .origin = .{ .row = 1, .col = 2 },
+            .surface = try count.draw(rowConstraints(ctx, max_size.width -| 2, 1)),
+        };
+        children[3] = .{
+            .origin = .{ .row = 2, .col = 0 },
+            .surface = try self.list_view.draw(rowConstraints(ctx, max_size.width, list_height)),
+        };
+
+        return .{
+            .size = max_size,
+            .widget = self.widget(),
+            .buffer = &.{},
+            .children = children,
+        };
+    }
+
+    fn widgetBuilder(ptr: *const anyopaque, index: usize, cursor: usize) ?vxfw.Widget {
+        const self: *const ListModel = @ptrCast(@alignCast(ptr));
+        if (index >= self.filtered.items.len) {
+            return null;
+        }
+        assert(index < self.filtered.items.len);
+        assert(self.filtered_cursor.items.len == self.filtered.items.len);
+        if (index == cursor) {
+            return self.filtered_cursor.items[index].widget();
+        }
+        return self.filtered.items[index].widget();
+    }
+
+    fn onChange(maybe_ptr: ?*anyopaque, _: *vxfw.EventContext, query: []const u8) anyerror!void {
+        const ptr = maybe_ptr orelse return;
+        const self: *ListModel = @ptrCast(@alignCast(ptr));
+        try self.refilter(query);
+    }
+
+    fn refilter(self: *ListModel, query: []const u8) !void {
+        _ = self.arena.reset(.free_all);
+        const arena = self.arena.allocator();
+        const display = try arena.alloc([]const u8, self.items.len);
+        for (self.items, 0..) |item, index| {
+            if (item.len == 0) {
+                display[index] = item;
+                continue;
+            }
+            const clean = try sanitizeDisplayText(arena, item);
+            display[index] = if (clean.len == 0) " " else clean;
+        }
+        self.ranked = try rankItems(arena, self.scorer, display, query);
+        self.filtered = .empty;
+        self.filtered_cursor = .empty;
+        for (self.ranked) |ranked_item| {
+            var spans = std.ArrayListUnmanaged(vxfw.RichText.TextSpan).empty;
+            try appendHighlightSpans(
+                &spans,
+                arena,
+                display[ranked_item.index],
+                ranked_item.matches,
+                self.theme.match,
+            );
+            const owned = try spans.toOwnedSlice(arena);
+            assert(owned.len > 0);
+            try self.filtered.append(arena, .{ .text = owned });
+            var cursor_spans = std.ArrayListUnmanaged(vxfw.RichText.TextSpan).empty;
+            try appendCursorSpans(&cursor_spans, arena, owned, self.theme.cursor_row);
+            const owned_cursor = try cursor_spans.toOwnedSlice(arena);
+            assert(owned_cursor.len == owned.len);
+            try self.filtered_cursor.append(arena, .{ .text = owned_cursor });
+        }
+        assert(self.filtered.items.len == self.ranked.len);
+        assert(self.filtered_cursor.items.len == self.ranked.len);
+        self.list_view.cursor = 0;
+        assert(self.list_view.cursor == 0);
+        self.list_view.scroll.top = 0;
+        self.list_view.scroll.offset = 0;
+        self.list_view.item_count = @intCast(self.filtered.items.len);
+    }
+
+    fn onSubmit(maybe_ptr: ?*anyopaque, ctx: *vxfw.EventContext, _: []const u8) anyerror!void {
+        const ptr = maybe_ptr orelse return;
+        const self: *ListModel = @ptrCast(@alignCast(ptr));
+        if (self.list_view.cursor < self.ranked.len) {
+            self.selected = self.ranked[self.list_view.cursor].index;
+        }
+        ctx.quit = true;
+        assert(ctx.quit);
+    }
+};
+
+pub fn pickFromList(
+    allocator: std.mem.Allocator,
+    items: []const []const u8,
+    theme: ResolvedTheme,
+) ![]u8 {
+    try checkTerminalSize(input_output.runtime());
+    var longest: usize = 0;
+    var usable_count: usize = 0;
+    for (items) |item| {
+        if (item.len == 0) {
+            continue;
+        }
+        usable_count += 1;
+        if (item.len > longest) {
+            longest = item.len;
+        }
+    }
+    if (usable_count == 0) {
+        return error.UserAbort;
+    }
+    assert(longest > 0);
+    var scorer = try FuzzyScorer.init(allocator, longest);
+    defer scorer.deinit();
+    const model = try ListModel.init(allocator, items, &scorer, theme);
+    defer model.deinit();
+    var app_buffer: [4096]u8 = undefined;
+    var app: vxfw.App = try .init(
+        input_output.runtime(),
+        allocator,
+        input_output.environMap(),
+        &app_buffer,
+    );
+    defer app.deinit();
+    try app.run(model.widget(), .{});
+    const selected = model.selected orelse return error.UserAbort;
+    assert(selected < items.len);
+    const result = try allocator.dupe(u8, items[selected]);
+    assert(result.len == items[selected].len);
+    return result;
+}
+
+pub fn buildUgrepArgv(
+    allocator: std.mem.Allocator,
+    ignored_patterns: []const []const u8,
+    query: []const u8,
+) !std.ArrayListUnmanaged([]const u8) {
+    var argv = std.ArrayListUnmanaged([]const u8).empty;
+    errdefer argv.deinit(allocator);
+    try argv.appendSlice(allocator, &.{
+        "ugrep",
+        "--recursive",
+        "--line-number",
+        "--column-number",
+        "--smart-case",
+        "--ignore-binary",
+        "--color=never",
+    });
+    for (ignored_patterns) |pattern| {
+        assert(pattern.len > 0);
+        try argv.append(allocator, "--exclude-dir");
+        try argv.append(allocator, pattern);
+        try argv.append(allocator, "--exclude");
+        try argv.append(allocator, pattern);
+    }
+    var words = std.mem.splitScalar(u8, query, ' ');
+    var first_word = true;
+    var have_word = false;
+    while (words.next()) |word| {
+        if (word.len == 0) {
+            continue;
+        }
+        if (!first_word) {
+            try argv.append(allocator, "--and");
+        }
+        try argv.append(allocator, "-e");
+        try argv.append(allocator, word);
+        first_word = false;
+        have_word = true;
+    }
+    if (!have_word) {
+        try argv.append(allocator, "--");
+        try argv.append(allocator, query);
+    }
+    assert(argv.items.len >= 9);
+    return argv;
+}
+
+pub fn parseUgrepLines(
+    allocator: std.mem.Allocator,
+    lines: *std.ArrayListUnmanaged([]const u8),
+    output: []const u8,
+) !void {
+    const start_len = lines.items.len;
+    var split = std.mem.splitScalar(u8, output, '\n');
+    while (split.next()) |line| {
+        if (line.len == 0) {
+            continue;
+        }
+        if (lines.items.len - start_len >= max_grep_lines) {
+            break;
+        }
+        try lines.append(allocator, line);
+    }
+    assert(lines.items.len - start_len <= max_grep_lines);
+    assert(lines.items.len >= start_len);
+}
+
+pub const GrepTarget = struct {
+    file: []const u8,
+    line: usize,
+    column: usize,
+};
+
+pub fn splitGrepLine(selected: []const u8) GrepTarget {
+    assert(selected.len > 0);
+    var fields = std.mem.splitScalar(u8, selected, ':');
+    if (fields.next()) |file| {
+        const line = if (fields.next()) |text| parseGrepNumber(text) else 0;
+        const column = if (fields.next()) |text| parseGrepNumber(text) else 0;
+        return .{ .file = file, .line = line, .column = column };
+    }
+    return .{ .file = selected, .line = 0, .column = 0 };
+}
+
+fn parseGrepNumber(text: []const u8) usize {
+    if (text.len == 0) {
+        return 0;
+    }
+    const number = std.fmt.parseInt(usize, text, 10) catch return 0;
+    return number;
+}
+
+pub fn readExcerpt(
+    input_output_handle: std.Io,
+    directory: std.Io.Dir,
+    allocator: std.mem.Allocator,
+    path: []const u8,
+    line_one_based: usize,
+) ![]u8 {
+    assert(path.len > 0);
+    assert(line_one_based > 0);
+    const content = try directory.readFileAlloc(
+        input_output_handle,
+        path,
+        allocator,
+        .limited(max_excerpt_bytes),
+    );
+    defer allocator.free(content);
+    const first = excerptFirstLine(line_one_based);
+    const last = first + excerpt_context_before + excerpt_context_after;
+    assert(first >= 1);
+    assert(last >= first);
+    var out = std.ArrayListUnmanaged(u8).empty;
+    errdefer out.deinit(allocator);
+    var number: usize = 0;
+    var split = std.mem.splitScalar(u8, content, '\n');
+    while (split.next()) |text| {
+        number += 1;
+        if (number < first) {
+            continue;
+        }
+        if (number > last) {
+            break;
+        }
+        if (number == line_one_based) {
+            try out.appendSlice(allocator, "> ");
+        } else {
+            try out.appendSlice(allocator, "  ");
+        }
+        try out.appendSlice(allocator, text);
+        try out.append(allocator, '\n');
+    }
+    return out.toOwnedSlice(allocator);
+}
+
+fn excerptFirstLine(line_one_based: usize) usize {
+    assert(line_one_based > 0);
+    if (line_one_based > excerpt_context_before) {
+        const first = line_one_based - excerpt_context_before;
+        assert(first < line_one_based);
+        assert(first >= 1);
+        return first;
+    }
+    return 1;
+}
+
+const no_preview_text = "[no preview]";
+
+const no_preview_spans = [_]vxfw.RichText.TextSpan{
+    .{ .text = no_preview_text },
+};
+
+fn warn(input_output_handle: std.Io, comptime fmt: []const u8, arguments: anytype) void {
+    assert(fmt.len > 0);
+    var buffer: [4096]u8 = undefined;
+    var writer = std.Io.File.stderr().writerStreaming(input_output_handle, &buffer);
+    writer.interface.print(fmt, arguments) catch |err| {
+        std.log.warn("failed to write picker warning: {}", .{err});
+    };
+    writer.interface.flush() catch |err| {
+        std.log.warn("failed to flush picker warning: {}", .{err});
+    };
+}
+
+const GrepModel = struct {
+    input_output_handle: std.Io,
+    allocator: std.mem.Allocator,
+    patterns: []const []const u8,
+    theme: ResolvedTheme,
+    started_empty: bool = false,
+    start_error: ?anyerror = null,
+    output: ?[]u8 = null,
+    lines: std.ArrayListUnmanaged([]const u8) = .empty,
+    texts: std.ArrayListUnmanaged(vxfw.RichText) = .empty,
+    texts_cursor: std.ArrayListUnmanaged(vxfw.RichText) = .empty,
+    query_len: usize = 0,
+    preview_text: []u8 = &.{},
+    preview_spans: []vxfw.RichText.TextSpan = &.{},
+    preview: vxfw.RichText = .{ .text = &no_preview_spans },
+    list_view: vxfw.ListView,
+    text_field: vxfw.TextField,
+    selected: ?usize = null,
+    pending_initial: bool = true,
+
+    fn init(
+        input_output_handle: std.Io,
+        allocator: std.mem.Allocator,
+        patterns: []const []const u8,
+        theme: ResolvedTheme,
+    ) !*GrepModel {
+        const self = try allocator.create(GrepModel);
+        self.* = .{
+            .input_output_handle = input_output_handle,
+            .allocator = allocator,
+            .patterns = patterns,
+            .theme = theme,
+            .list_view = .{
+                .children = .{ .builder = undefined },
+            },
+            .text_field = vxfw.TextField.init(allocator),
+        };
+        self.list_view.children = .{ .builder = .{
+            .userdata = self,
+            .buildFn = GrepModel.widgetBuilder,
+        } };
+        self.text_field.userdata = self;
+        self.text_field.onChange = GrepModel.onChange;
+        self.text_field.onSubmit = GrepModel.onSubmit;
+        assert(self.text_field.userdata != null);
+        self.list_view.item_count = 0;
+        return self;
+    }
+
+    fn initialReload(self: *GrepModel) void {
+        const truncated = self.reload("") catch |err| {
+            self.started_empty = true;
+            self.start_error = err;
+            if (!builtin.is_test) {
+                warn(
+                    self.input_output_handle,
+                    "initial grep reload failed, starting empty: {}\n",
+                    .{err},
+                );
+            }
+            return;
+        };
+        if (truncated) {
+            self.started_empty = true;
+            self.start_error = error.StreamTooLong;
+            if (!builtin.is_test) {
+                warn(
+                    self.input_output_handle,
+                    "initial grep found too many matches, starting empty\n",
+                    .{},
+                );
+            }
+        }
+    }
+
+    fn deinit(self: *GrepModel) void {
+        const allocator = self.allocator;
+        if (self.output) |output| {
+            allocator.free(output);
+        }
+        if (self.preview_text.len > 0) {
+            allocator.free(self.preview_text);
+        }
+        if (self.preview_spans.len > 0) {
+            allocator.free(self.preview_spans);
+        }
+        self.freeGrepRows();
+        self.texts.deinit(allocator);
+        self.texts_cursor.deinit(allocator);
+        self.lines.deinit(allocator);
+        self.text_field.deinit();
+        allocator.destroy(self);
+    }
+
+    fn widget(self: *GrepModel) vxfw.Widget {
+        const result: vxfw.Widget = .{
+            .userdata = self,
+            .eventHandler = GrepModel.typeErasedEventHandler,
+            .drawFn = GrepModel.typeErasedDrawFn,
+        };
+        assert(result.userdata == @as(*anyopaque, @ptrCast(self)));
+        return result;
+    }
+
+    fn typeErasedEventHandler(
+        ptr: *anyopaque,
+        ctx: *vxfw.EventContext,
+        event: vxfw.Event,
+    ) anyerror!void {
+        const self: *GrepModel = @ptrCast(@alignCast(ptr));
+        switch (event) {
+            .init => {
+                try ctx.tick(0, self.widget());
+                return ctx.requestFocus(self.text_field.widget());
+            },
+            .tick => {
+                if (self.pending_initial) {
+                    self.pending_initial = false;
+                    self.initialReload();
+                    ctx.redraw = true;
+                }
+                return;
+            },
+            .key_press => |key| {
+                if (key.matches(vaxis.Key.escape, .{})) {
+                    ctx.quit = true;
+                    assert(ctx.quit);
+                    return;
+                }
+                if (key.matches('c', .{ .ctrl = true })) {
+                    ctx.quit = true;
+                    assert(ctx.quit);
+                    return;
+                }
+                if (key.matches('d', .{ .ctrl = true })) {
+                    ctx.quit = true;
+                    assert(ctx.quit);
+                    return;
+                }
+                if (key.matches(vaxis.Key.up, .{})) {
+                    self.list_view.prevItem(ctx);
+                    self.refreshPreview();
+                    return;
+                }
+                if (key.matches('p', .{ .ctrl = true })) {
+                    self.list_view.prevItem(ctx);
+                    self.refreshPreview();
+                    return;
+                }
+                if (key.matches(vaxis.Key.down, .{})) {
+                    self.list_view.nextItem(ctx);
+                    self.refreshPreview();
+                    return;
+                }
+                if (key.matches('n', .{ .ctrl = true })) {
+                    self.list_view.nextItem(ctx);
+                    self.refreshPreview();
+                    return;
+                }
+                return;
+            },
+            .focus_in => {
+                return ctx.requestFocus(self.text_field.widget());
+            },
+            else => {},
+        }
+    }
+
+    fn typeErasedDrawFn(
+        ptr: *anyopaque,
+        ctx: vxfw.DrawContext,
+    ) std.mem.Allocator.Error!vxfw.Surface {
+        const self: *GrepModel = @ptrCast(@alignCast(ptr));
+        const max_size = ctx.max.size();
+        assert(max_size.width > 0);
+        assert(max_size.height > 0);
+
+        const match_count = self.lines.items.len;
+        const count_text = try std.fmt.allocPrint(
+            ctx.arena,
+            "{d} matches",
+            .{match_count},
+        );
+        const count: vxfw.Text = .{ .text = count_text, .style = self.theme.status };
+        const prompt: vxfw.Text = .{ .text = ">", .style = self.theme.prompt };
+
+        const total_width: u32 = max_size.width;
+        const preview_width: u16 = @intCast(total_width * 3 / 5);
+        const list_width: u16 = @intCast(total_width - total_width * 3 / 5);
+        assert(@as(u32, list_width) + preview_width == total_width);
+        const list_height = max_size.height -| 2;
+        const children = try ctx.arena.alloc(vxfw.SubSurface, 5);
+        children[0] = .{
+            .origin = .{ .row = 0, .col = 0 },
+            .surface = try prompt.draw(rowConstraints(ctx, 2, 1)),
+        };
+        children[1] = .{
+            .origin = .{ .row = 0, .col = 2 },
+            .surface = try self.text_field.draw(rowConstraints(ctx, max_size.width -| 2, 1)),
+        };
+        children[2] = .{
+            .origin = .{ .row = 1, .col = 2 },
+            .surface = try count.draw(rowConstraints(ctx, max_size.width -| 2, 1)),
+        };
+        children[3] = .{
+            .origin = .{ .row = 2, .col = 0 },
+            .surface = try self.list_view.draw(rowConstraints(ctx, list_width, list_height)),
+        };
+        children[4] = .{
+            .origin = .{ .row = 2, .col = list_width },
+            .surface = try self.preview.draw(rowConstraints(ctx, preview_width, list_height)),
+        };
+
+        return .{
+            .size = max_size,
+            .widget = self.widget(),
+            .buffer = &.{},
+            .children = children,
+        };
+    }
+
+    fn widgetBuilder(ptr: *const anyopaque, index: usize, cursor: usize) ?vxfw.Widget {
+        const self: *const GrepModel = @ptrCast(@alignCast(ptr));
+        if (index >= self.texts.items.len) {
+            return null;
+        }
+        assert(index < self.texts.items.len);
+        assert(self.texts_cursor.items.len == self.texts.items.len);
+        if (index == cursor) {
+            return self.texts_cursor.items[index].widget();
+        }
+        return self.texts.items[index].widget();
+    }
+
+    fn onChange(maybe_ptr: ?*anyopaque, _: *vxfw.EventContext, query: []const u8) anyerror!void {
+        const ptr = maybe_ptr orelse return;
+        const self: *GrepModel = @ptrCast(@alignCast(ptr));
+        _ = try self.reload(query);
+    }
+
+    fn reload(self: *GrepModel, query: []const u8) !bool {
+        self.clearResults();
+        self.query_len = query.len;
+        var argv = try buildUgrepArgv(self.allocator, self.patterns, query);
+        defer argv.deinit(self.allocator);
+        const run_result = std.process.run(self.allocator, self.input_output_handle, .{
+            .argv = argv.items,
+            .stdout_limit = .limited(max_grep_stdout_bytes),
+            .stderr_limit = .limited(65536),
+        }) catch |err| {
+            if (err == error.StreamTooLong) {
+                if (!builtin.is_test) {
+                    warn(
+                        self.input_output_handle,
+                        "too many matches, keep typing to narrow\n",
+                        .{},
+                    );
+                }
+                self.refreshPreview();
+                return true;
+            }
+            return err;
+        };
+        defer self.allocator.free(run_result.stderr);
+        switch (run_result.term) {
+            .exited => |code| {
+                if (code != 0) {
+                    self.allocator.free(run_result.stdout);
+                    if (code != 1) {
+                        warn(self.input_output_handle, "ugrep exited with status {d}\n", .{code});
+                    }
+                    self.refreshPreview();
+                    return false;
+                }
+            },
+            else => {
+                self.allocator.free(run_result.stdout);
+                warn(self.input_output_handle, "ugrep did not exit normally\n", .{});
+                self.refreshPreview();
+                return false;
+            },
+        }
+        self.output = run_result.stdout;
+        try parseUgrepLines(self.allocator, &self.lines, run_result.stdout);
+        for (self.lines.items) |line| {
+            assert(line.len > 0);
+            try self.appendGrepRow(line);
+        }
+        assert(self.texts.items.len == self.lines.items.len);
+        assert(self.texts_cursor.items.len == self.lines.items.len);
+        assert(self.lines.items.len <= std.math.maxInt(u32));
+        self.list_view.cursor = 0;
+        assert(self.list_view.cursor == 0);
+        self.list_view.scroll.top = 0;
+        self.list_view.scroll.offset = 0;
+        self.list_view.item_count = @intCast(self.lines.items.len);
+        self.refreshPreview();
+        return false;
+    }
+
+    fn appendGrepRow(self: *GrepModel, line: []const u8) !void {
+        assert(line.len > 0);
+        var normal = std.ArrayListUnmanaged(vxfw.RichText.TextSpan).empty;
+        defer normal.deinit(self.allocator);
+        try appendDisplaySafeText(&normal, self.allocator, line, .{});
+        assert(normal.items.len > 0);
+        const owned = try self.allocator.dupe(vxfw.RichText.TextSpan, normal.items);
+        errdefer self.allocator.free(owned);
+        assert(owned.len > 0);
+        var cursor_spans = std.ArrayListUnmanaged(vxfw.RichText.TextSpan).empty;
+        defer cursor_spans.deinit(self.allocator);
+        try appendCursorSpans(&cursor_spans, self.allocator, owned, self.theme.cursor_row);
+        const owned_cursor = try cursor_spans.toOwnedSlice(self.allocator);
+        assert(owned_cursor.len == owned.len);
+        try self.texts.append(self.allocator, .{ .text = owned });
+        try self.texts_cursor.append(self.allocator, .{ .text = owned_cursor });
+        assert(self.texts.items.len == self.texts_cursor.items.len);
+    }
+
+    fn freeGrepRows(self: *GrepModel) void {
+        assert(self.texts.items.len == self.texts_cursor.items.len);
+        for (self.texts.items) |row| {
+            assert(row.text.len > 0);
+            self.allocator.free(row.text);
+        }
+        for (self.texts_cursor.items) |row| {
+            assert(row.text.len > 0);
+            self.allocator.free(row.text);
+        }
+    }
+
+    fn clearResults(self: *GrepModel) void {
+        if (self.output) |output| {
+            self.allocator.free(output);
+            self.output = null;
+        }
+        self.lines.deinit(self.allocator);
+        self.lines = .empty;
+        self.freeGrepRows();
+        self.texts.deinit(self.allocator);
+        self.texts = .empty;
+        self.texts_cursor.deinit(self.allocator);
+        self.texts_cursor = .empty;
+        assert(self.output == null);
+        assert(self.lines.items.len == 0);
+        assert(self.texts.items.len == 0);
+        assert(self.texts_cursor.items.len == 0);
+    }
+
+    fn refreshPreview(self: *GrepModel) void {
+        if (self.preview_text.len > 0) {
+            self.allocator.free(self.preview_text);
+            self.preview_text = &.{};
+        }
+        if (self.preview_spans.len > 0) {
+            self.allocator.free(self.preview_spans);
+            self.preview_spans = &.{};
+        }
+        self.preview = .{ .text = &no_preview_spans };
+        const cursor = self.list_view.cursor;
+        if (cursor >= self.lines.items.len) {
+            return;
+        }
+        const target = splitGrepLine(self.lines.items[cursor]);
+        if (target.file.len == 0) {
+            return;
+        }
+        if (target.line == 0) {
+            return;
+        }
+        self.loadPreview(target) catch return;
+    }
+
+    fn loadPreview(self: *GrepModel, target: GrepTarget) !void {
+        assert(target.file.len > 0);
+        assert(target.line > 0);
+        const excerpt = try readExcerpt(
+            self.input_output_handle,
+            std.Io.Dir.cwd(),
+            self.allocator,
+            target.file,
+            target.line,
+        );
+        errdefer self.allocator.free(excerpt);
+        if (excerpt.len == 0) {
+            self.allocator.free(excerpt);
+            return;
+        }
+        assert(excerpt.len > 0);
+        var spans = std.ArrayListUnmanaged(vxfw.RichText.TextSpan).empty;
+        defer spans.deinit(self.allocator);
+        try appendPreviewSpans(
+            &spans,
+            self.allocator,
+            excerpt,
+            target.column,
+            self.query_len,
+            self.theme,
+        );
+        self.preview_text = excerpt;
+        self.preview_spans = try spans.toOwnedSlice(self.allocator);
+        assert(self.preview_spans.len > 0);
+        self.preview = .{ .text = self.preview_spans };
+        assert(self.preview.text.len > 0);
+    }
+
+    fn onSubmit(maybe_ptr: ?*anyopaque, ctx: *vxfw.EventContext, _: []const u8) anyerror!void {
+        const ptr = maybe_ptr orelse return;
+        const self: *GrepModel = @ptrCast(@alignCast(ptr));
+        if (self.list_view.cursor < self.lines.items.len) {
+            self.selected = self.list_view.cursor;
+        }
+        ctx.quit = true;
+        assert(ctx.quit);
+    }
+};
+
+pub fn pickGrepLine(
+    allocator: std.mem.Allocator,
+    ignored_patterns: []const []const u8,
+    theme: ResolvedTheme,
+) ![]u8 {
+    try checkTerminalSize(input_output.runtime());
+    {
+        const probe = std.process.run(allocator, input_output.runtime(), .{
+            .argv = &.{ "ugrep", "--version" },
+            .stdout_limit = .limited(4096),
+            .stderr_limit = .limited(4096),
+        }) catch |err| {
+            input_output.warn("ugrep is required for grep but could not start: {}\n", .{err});
+            return error.UgrepNotFound;
+        };
+        defer allocator.free(probe.stdout);
+        defer allocator.free(probe.stderr);
+        if (probe.term != .exited) {
+            return error.UgrepNotFound;
+        }
+        if (probe.term.exited != 0) {
+            return error.UgrepNotFound;
+        }
+    }
+    const model = try GrepModel.init(input_output.runtime(), allocator, ignored_patterns, theme);
+    defer model.deinit();
+    var app_buffer: [4096]u8 = undefined;
+    var app: vxfw.App = try .init(
+        input_output.runtime(),
+        allocator,
+        input_output.environMap(),
+        &app_buffer,
+    );
+    defer app.deinit();
+    try app.run(model.widget(), .{});
+    const selected = model.selected orelse return error.UserAbort;
+    assert(selected < model.lines.items.len);
+    const result = try allocator.dupe(u8, model.lines.items[selected]);
+    assert(result.len == model.lines.items[selected].len);
+    return result;
+}
+
+const testing = std.testing;
+
+fn testScorer(items: []const []const u8) !FuzzyScorer {
+    assert(items.len > 0);
+    var longest: usize = 1;
+    for (items) |item| {
+        if (item.len > longest) {
+            longest = item.len;
+        }
+    }
+    assert(longest >= 1);
+    return FuzzyScorer.init(testing.allocator, longest);
+}
+
+test "rankItems: filename match beats non-matches" {
+    const items = [_][]const u8{ "src/main.zig", "README.md" };
+    var scorer = try testScorer(&items);
+    defer scorer.deinit();
+    var arena = std.heap.ArenaAllocator.init(testing.allocator);
+    defer arena.deinit();
+    const ranked = try rankItems(arena.allocator(), &scorer, &items, "main");
+    try testing.expectEqual(@as(usize, 1), ranked.len);
+    try testing.expectEqual(@as(usize, 0), ranked[0].index);
+}
+
+test "rankItems: empty query keeps original order" {
+    const items = [_][]const u8{ "b.txt", "a.txt", "c.txt" };
+    var scorer = try testScorer(&items);
+    defer scorer.deinit();
+    var arena = std.heap.ArenaAllocator.init(testing.allocator);
+    defer arena.deinit();
+    const ranked = try rankItems(arena.allocator(), &scorer, &items, "");
+    try testing.expectEqual(@as(usize, 3), ranked.len);
+    try testing.expectEqual(@as(usize, 0), ranked[0].index);
+    try testing.expectEqual(@as(usize, 1), ranked[1].index);
+    try testing.expectEqual(@as(usize, 2), ranked[2].index);
+}
+
+test "rankItems: no match yields nothing" {
+    const items = [_][]const u8{"src/main.zig"};
+    var scorer = try testScorer(&items);
+    defer scorer.deinit();
+    var arena = std.heap.ArenaAllocator.init(testing.allocator);
+    defer arena.deinit();
+    const ranked = try rankItems(arena.allocator(), &scorer, &items, "zzz");
+    try testing.expectEqual(@as(usize, 0), ranked.len);
+}
+
+test "rankItems: uppercase query matches case-sensitively" {
+    const items = [_][]const u8{ "src/main.zig", "src/MAIN.zig" };
+    var scorer = try testScorer(&items);
+    defer scorer.deinit();
+    var arena = std.heap.ArenaAllocator.init(testing.allocator);
+    defer arena.deinit();
+    const lower = try rankItems(arena.allocator(), &scorer, &items, "main");
+    try testing.expectEqual(@as(usize, 2), lower.len);
+    const upper = try rankItems(arena.allocator(), &scorer, &items, "MAIN");
+    try testing.expectEqual(@as(usize, 1), upper.len);
+    try testing.expectEqual(@as(usize, 1), upper[0].index);
+}
+
+test "rankItems: results are deterministic across runs" {
+    const items = [_][]const u8{ "src/main.zig", "src/fzf.zig", "README.md" };
+    var scorer = try testScorer(&items);
+    defer scorer.deinit();
+    var arena = std.heap.ArenaAllocator.init(testing.allocator);
+    defer arena.deinit();
+    const first = try rankItems(arena.allocator(), &scorer, &items, "z");
+    const second = try rankItems(arena.allocator(), &scorer, &items, "z");
+    try testing.expectEqual(first.len, second.len);
+    for (first, second) |left, right| {
+        try testing.expectEqual(left.index, right.index);
+        try testing.expectEqual(left.score, right.score);
+    }
+}
+
+test "rankItems: space matches path separator like slash" {
+    const items = [_][]const u8{
+        "hosts/impish/overlays/config/magdalena",
+        "hosts/impish/overlays/config/MangoHud",
+    };
+    var scorer = try testScorer(&items);
+    defer scorer.deinit();
+    var arena = std.heap.ArenaAllocator.init(testing.allocator);
+    defer arena.deinit();
+    const slashed = try rankItems(arena.allocator(), &scorer, &items, "impish/mag");
+    const spaced = try rankItems(arena.allocator(), &scorer, &items, "impish mag");
+    try testing.expectEqual(slashed.len, spaced.len);
+    try testing.expect(spaced.len > 0);
+    for (slashed, spaced) |left, right| {
+        try testing.expectEqual(left.index, right.index);
+        try testing.expectEqual(left.score, right.score);
+    }
+}
+
+test "rankItems: literal space content still matches" {
+    const items = [_][]const u8{ "foo bar", "foo/bar" };
+    var scorer = try testScorer(&items);
+    defer scorer.deinit();
+    var arena = std.heap.ArenaAllocator.init(testing.allocator);
+    defer arena.deinit();
+    const ranked = try rankItems(arena.allocator(), &scorer, &items, "foo bar");
+    try testing.expectEqual(@as(usize, 2), ranked.len);
+    try testing.expectEqual(@as(usize, 0), ranked[0].index);
+}
+
+test "clampHaystack: short text passes through" {
+    const items = [_][]const u8{"short"};
+    var scorer = try testScorer(&items);
+    defer scorer.deinit();
+    const clamped = scorer.clampHaystack("short");
+    try testing.expectEqualStrings("short", clamped.slice);
+    try testing.expectEqual(@as(usize, 0), clamped.offset);
+}
+
+test "clampHaystack: long text keeps the tail" {
+    const items = [_][]const u8{"0123456789"};
+    var scorer = try testScorer(&items);
+    defer scorer.deinit();
+    var small = try FuzzyScorer.init(testing.allocator, 4);
+    defer small.deinit();
+    const clamped = small.clampHaystack("0123456789");
+    try testing.expectEqualStrings("6789", clamped.slice);
+    try testing.expectEqual(@as(usize, 6), clamped.offset);
+
+    var arena = std.heap.ArenaAllocator.init(testing.allocator);
+    defer arena.deinit();
+    const ranked = try rankItems(arena.allocator(), &small, &[_][]const u8{"0123456789"}, "789");
+    try testing.expectEqual(@as(usize, 1), ranked.len);
+    try testing.expectEqual(@as(usize, 6), ranked[0].tail_offset);
+}
+
+test "appendHighlightSpans: text rebuilds and matches glow" {
+    var arena = std.heap.ArenaAllocator.init(testing.allocator);
+    defer arena.deinit();
+    const text = "src/main.zig";
+    const matches = [_]usize{ 4, 5, 6, 7 };
+    var spans = std.ArrayListUnmanaged(vxfw.RichText.TextSpan).empty;
+    try appendHighlightSpans(&spans, arena.allocator(), text, &matches, defaultTheme().match);
+    var rebuilt = std.ArrayListUnmanaged(u8).empty;
+    defer rebuilt.deinit(testing.allocator);
+    var highlighted: ?[]const u8 = null;
+    for (spans.items) |span| {
+        try rebuilt.appendSlice(testing.allocator, span.text);
+        if (span.style.reverse) {
+            highlighted = span.text;
+        }
+    }
+    try testing.expectEqualStrings(text, rebuilt.items);
+    try testing.expectEqualStrings("main", highlighted.?);
+}
+
+test "appendHighlightSpans: no matches leaves plain text" {
+    var arena = std.heap.ArenaAllocator.init(testing.allocator);
+    defer arena.deinit();
+    var spans = std.ArrayListUnmanaged(vxfw.RichText.TextSpan).empty;
+    try appendHighlightSpans(&spans, arena.allocator(), "abc", &.{}, defaultTheme().match);
+    try testing.expectEqual(@as(usize, 1), spans.items.len);
+    try testing.expectEqualStrings("abc", spans.items[0].text);
+    try testing.expect(!spans.items[0].style.reverse);
+}
+
+test "buildUgrepArgv: exact sequence without shell quoting" {
+    const patterns = [_][]const u8{ "node_modules", "a'b" };
+    var argv = try buildUgrepArgv(testing.allocator, &patterns, "needle");
+    defer argv.deinit(testing.allocator);
+    const expected = [_][]const u8{
+        "ugrep",
+        "--recursive",
+        "--line-number",
+        "--column-number",
+        "--smart-case",
+        "--ignore-binary",
+        "--color=never",
+        "--exclude-dir",
+        "node_modules",
+        "--exclude",
+        "node_modules",
+        "--exclude-dir",
+        "a'b",
+        "--exclude",
+        "a'b",
+        "-e",
+        "needle",
+    };
+    try testing.expectEqual(expected.len, argv.items.len);
+    for (expected, argv.items) |want, got| {
+        try testing.expectEqualStrings(want, got);
+    }
+}
+
+test "buildUgrepArgv: spaces split into AND patterns" {
+    var argv = try buildUgrepArgv(testing.allocator, &.{}, "impish mag");
+    defer argv.deinit(testing.allocator);
+    const tail = argv.items[argv.items.len - 5 ..];
+    try testing.expectEqualStrings("-e", tail[0]);
+    try testing.expectEqualStrings("impish", tail[1]);
+    try testing.expectEqualStrings("--and", tail[2]);
+    try testing.expectEqualStrings("-e", tail[3]);
+    try testing.expectEqualStrings("mag", tail[4]);
+}
+
+test "buildUgrepArgv: doubled spaces skip empties" {
+    var argv = try buildUgrepArgv(testing.allocator, &.{}, "a  b");
+    defer argv.deinit(testing.allocator);
+    const tail = argv.items[argv.items.len - 5 ..];
+    try testing.expectEqualStrings("-e", tail[0]);
+    try testing.expectEqualStrings("a", tail[1]);
+    try testing.expectEqualStrings("--and", tail[2]);
+    try testing.expectEqualStrings("-e", tail[3]);
+    try testing.expectEqualStrings("b", tail[4]);
+}
+
+test "buildUgrepArgv: all-space query keeps literal form" {
+    var argv = try buildUgrepArgv(testing.allocator, &.{}, " ");
+    defer argv.deinit(testing.allocator);
+    const tail = argv.items[argv.items.len - 2 ..];
+    try testing.expectEqualStrings("--", tail[0]);
+    try testing.expectEqualStrings(" ", tail[1]);
+}
+
+test "grep end to end: space query finds both words like slash" {
+    const probe = std.process.run(testing.allocator, testing.io, .{
+        .argv = &.{ "ugrep", "--version" },
+    }) catch |err| {
+        if (err == error.FileNotFound) return error.SkipZigTest;
+        return err;
+    };
+    testing.allocator.free(probe.stdout);
+    testing.allocator.free(probe.stderr);
+
+    var temp_directory = testing.tmpDir(.{ .iterate = true });
+    defer temp_directory.cleanup();
+    const test_input_output = testing.io;
+    try writeGrepFixture(
+        &temp_directory.dir,
+        test_input_output,
+        "notes.txt",
+        "use hosts/impish/overlays/config/magdalena here\n",
+    );
+    try writeGrepFixture(
+        &temp_directory.dir,
+        test_input_output,
+        "path.txt",
+        "see impish/mag link\n",
+    );
+    try writeGrepFixture(
+        &temp_directory.dir,
+        test_input_output,
+        "other.txt",
+        "unrelated content here\n",
+    );
+
+    const spaced = try runGrepQuery(test_input_output, &temp_directory.dir, "impish mag");
+    defer testing.allocator.free(spaced);
+    try testing.expect(std.mem.indexOf(u8, spaced, "notes.txt") != null);
+    try testing.expect(std.mem.indexOf(u8, spaced, "path.txt") != null);
+    try testing.expect(std.mem.indexOf(u8, spaced, "other.txt") == null);
+
+    const slashed = try runGrepQuery(test_input_output, &temp_directory.dir, "impish/mag");
+    defer testing.allocator.free(slashed);
+    try testing.expect(std.mem.indexOf(u8, slashed, "path.txt") != null);
+    try testing.expect(std.mem.indexOf(u8, slashed, "notes.txt") == null);
+}
+
+fn writeGrepFixture(
+    directory: *std.Io.Dir,
+    input_output_handle: std.Io,
+    name: []const u8,
+    content: []const u8,
+) !void {
+    assert(name.len > 0);
+    assert(content.len > 0);
+    var file = try directory.createFile(input_output_handle, name, .{
+        .read = false,
+        .truncate = true,
+        .exclusive = false,
+        .lock = .none,
+        .lock_nonblocking = false,
+        .permissions = .fromMode(0o644),
+        .resolve_beneath = false,
+    });
+    var file_buffer: [4096]u8 = undefined;
+    var writer = file.writer(input_output_handle, &file_buffer);
+    try writer.interface.writeAll(content);
+    try writer.interface.flush();
+    file.close(input_output_handle);
+}
+
+fn runGrepQuery(
+    input_output_handle: std.Io,
+    directory: *std.Io.Dir,
+    query: []const u8,
+) ![]u8 {
+    assert(query.len > 0);
+    var argv = try buildUgrepArgv(testing.allocator, &.{}, query);
+    defer argv.deinit(testing.allocator);
+    const run_result = try std.process.run(testing.allocator, input_output_handle, .{
+        .argv = argv.items,
+        .cwd = .{ .dir = directory.* },
+        .stdout_limit = .limited(max_grep_stdout_bytes),
+        .stderr_limit = .limited(65536),
+    });
+    testing.allocator.free(run_result.stderr);
+    errdefer testing.allocator.free(run_result.stdout);
+    switch (run_result.term) {
+        .exited => |code| {
+            try testing.expectEqual(@as(u8, 0), code);
+            return run_result.stdout;
+        },
+        else => return error.UgrepDidNotExit,
+    }
+}
+
+test "parseUgrepLines: skips blanks" {
+    var lines = std.ArrayListUnmanaged([]const u8).empty;
+    defer lines.deinit(testing.allocator);
+    try parseUgrepLines(testing.allocator, &lines, "a:1:1:x\n\nb:2:3:y\n");
+    try testing.expectEqual(@as(usize, 2), lines.items.len);
+    try testing.expectEqualStrings("a:1:1:x", lines.items[0]);
+}
+
+test "parseUgrepLines: caps runaway output" {
+    var lines = std.ArrayListUnmanaged([]const u8).empty;
+    defer lines.deinit(testing.allocator);
+    var big = std.ArrayListUnmanaged(u8).empty;
+    defer big.deinit(testing.allocator);
+    var count: usize = 0;
+    while (count < max_grep_lines + 5) : (count += 1) {
+        try big.appendSlice(testing.allocator, "f:1:1:x\n");
+    }
+    try parseUgrepLines(testing.allocator, &lines, big.items);
+    try testing.expectEqual(max_grep_lines, lines.items.len);
+}
+
+test "splitGrepLine: file with line and column" {
+    const target = splitGrepLine("src/main.zig:12:4:needle");
+    try testing.expectEqualStrings("src/main.zig", target.file);
+    try testing.expectEqual(@as(usize, 12), target.line);
+}
+
+test "splitGrepLine: missing line yields zero" {
+    const target = splitGrepLine("README.md");
+    try testing.expectEqualStrings("README.md", target.file);
+    try testing.expectEqual(@as(usize, 0), target.line);
+}
+
+test "splitGrepLine: malformed line yields zero" {
+    const target = splitGrepLine("src/main.zig:deep:4:x");
+    try testing.expectEqualStrings("src/main.zig", target.file);
+    try testing.expectEqual(@as(usize, 0), target.line);
+}
+
+test "readExcerpt: window around the target line" {
+    var temp_directory = testing.tmpDir(.{ .iterate = true });
+    defer temp_directory.cleanup();
+    const test_input_output = testing.io;
+    var file = try temp_directory.dir.createFile(test_input_output, "sample.txt", .{
+        .read = false,
+        .truncate = true,
+        .exclusive = false,
+        .lock = .none,
+        .lock_nonblocking = false,
+        .permissions = .fromMode(0o644),
+        .resolve_beneath = false,
+    });
+    var write_buffer: [4096]u8 = undefined;
+    var writer = file.writer(test_input_output, &write_buffer);
+    var number: usize = 1;
+    while (number <= 30) : (number += 1) {
+        try writer.interface.print("line{d}\n", .{number});
+    }
+    try writer.interface.flush();
+    file.close(test_input_output);
+
+    const excerpt = try readExcerpt(
+        test_input_output,
+        temp_directory.dir,
+        testing.allocator,
+        "sample.txt",
+        10,
+    );
+    defer testing.allocator.free(excerpt);
+    try testing.expect(std.mem.indexOf(u8, excerpt, "> line10\n") != null);
+    try testing.expect(std.mem.indexOf(u8, excerpt, "  line5\n") != null);
+    try testing.expect(std.mem.indexOf(u8, excerpt, "  line25\n") != null);
+    try testing.expect(std.mem.indexOf(u8, excerpt, "line26\n") == null);
+}
+
+test "readExcerpt: missing file errors" {
+    var temp_directory = testing.tmpDir(.{ .iterate = true });
+    defer temp_directory.cleanup();
+    const missing = readExcerpt(
+        testing.io,
+        temp_directory.dir,
+        testing.allocator,
+        "does-not-exist.txt",
+        1,
+    );
+    try testing.expectError(error.FileNotFound, missing);
+}
+
+test "readExcerpt: line past the end yields empty" {
+    var temp_directory = testing.tmpDir(.{ .iterate = true });
+    defer temp_directory.cleanup();
+    const test_input_output = testing.io;
+    var file = try temp_directory.dir.createFile(test_input_output, "tiny.txt", .{
+        .read = false,
+        .truncate = true,
+        .exclusive = false,
+        .lock = .none,
+        .lock_nonblocking = false,
+        .permissions = .fromMode(0o644),
+        .resolve_beneath = false,
+    });
+    var tiny_buffer: [4096]u8 = undefined;
+    var tiny_writer = file.writer(test_input_output, &tiny_buffer);
+    try tiny_writer.interface.writeAll("only\n");
+    try tiny_writer.interface.flush();
+    file.close(test_input_output);
+    const excerpt = try readExcerpt(
+        test_input_output,
+        temp_directory.dir,
+        testing.allocator,
+        "tiny.txt",
+        99,
+    );
+    defer testing.allocator.free(excerpt);
+    try testing.expectEqual(@as(usize, 0), excerpt.len);
+}
+
+extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int;
+
+test "GrepModel.init survives reload failure without double free" {
+    var temp_directory = testing.tmpDir(.{ .iterate = true });
+    defer temp_directory.cleanup();
+    const test_input_output = testing.io;
+
+    var flood_script_buffer: [128]u8 = undefined;
+    const script = try std.fmt.bufPrint(
+        &flood_script_buffer,
+        "#!/bin/sh\n/usr/sbin/yes y | /usr/sbin/head -c {d}\n",
+        .{max_grep_stdout_bytes + 1024 * 1024},
+    );
+    var script_file = try temp_directory.dir.createFile(test_input_output, "ugrep", .{
+        .read = false,
+        .truncate = true,
+        .exclusive = false,
+        .lock = .none,
+        .lock_nonblocking = false,
+        .permissions = .fromMode(0o755),
+        .resolve_beneath = false,
+    });
+    var script_buffer: [4096]u8 = undefined;
+    var script_writer = script_file.writer(test_input_output, &script_buffer);
+    try script_writer.interface.writeAll(script);
+    try script_writer.interface.flush();
+    script_file.close(test_input_output);
+
+    const cwd = try std.process.currentPathAlloc(test_input_output, testing.allocator);
+    defer testing.allocator.free(cwd);
+    const fake_bin = try std.fs.path.join(testing.allocator, &.{
+        cwd,
+        ".zig-cache",
+        "tmp",
+        temp_directory.sub_path[0..],
+    });
+    defer testing.allocator.free(fake_bin);
+
+    const old_path = if (std.c.getenv("PATH")) |existing|
+        std.mem.span(existing)
+    else
+        "/usr/bin:/bin";
+    const new_path = try std.fmt.allocPrint(testing.allocator, "{s}:{s}", .{ fake_bin, old_path });
+    defer testing.allocator.free(new_path);
+    const new_path_z = try testing.allocator.dupeZ(u8, new_path);
+    defer testing.allocator.free(new_path_z);
+    assert(setenv("PATH", new_path_z.ptr, 1) == 0);
+    defer {
+        if (testing.allocator.dupeZ(u8, old_path)) |restore_z| {
+            defer testing.allocator.free(restore_z);
+            _ = setenv("PATH", restore_z.ptr, 1);
+        } else |_| {}
+    }
+
+    var model = try GrepModel.init(testing.io, testing.allocator, &.{}, defaultTheme());
+    defer model.deinit();
+    try testing.expect(model.pending_initial);
+    model.initialReload();
+    try testing.expect(model.started_empty);
+    try testing.expectEqual(error.StreamTooLong, model.start_error.?);
+    try testing.expectEqual(@as(usize, 0), model.lines.items.len);
+}
+
+test "parseThemeColor: default keyword" {
+    const color = try parseThemeColor("default");
+    try testing.expect(color == .default);
+}
+
+test "parseThemeColor: palette bounds" {
+    const zero = try parseThemeColor("0");
+    try testing.expectEqual(@as(u8, 0), zero.index);
+    const top = try parseThemeColor("255");
+    try testing.expectEqual(@as(u8, 255), top.index);
+    try testing.expectError(error.InvalidColor, parseThemeColor("256"));
+    try testing.expectError(error.InvalidColor, parseThemeColor("-1"));
+}
+
+test "parseThemeColor: hex triple" {
+    const color = try parseThemeColor("#ff0080");
+    try testing.expectEqual([3]u8{ 255, 0, 128 }, color.rgb);
+    const black = try parseThemeColor("#000000");
+    try testing.expectEqual([3]u8{ 0, 0, 0 }, black.rgb);
+}
+
+test "parseThemeColor: malformed input errors" {
+    try testing.expectError(error.InvalidColor, parseThemeColor(""));
+    try testing.expectError(error.InvalidColor, parseThemeColor("blue"));
+    try testing.expectError(error.InvalidColor, parseThemeColor("red"));
+    try testing.expectError(error.InvalidColor, parseThemeColor("#xyz"));
+    try testing.expectError(error.InvalidColor, parseThemeColor("#12345"));
+    try testing.expectError(error.InvalidColor, parseThemeColor("#1234567"));
+    try testing.expectError(error.InvalidColor, parseThemeColor("#zzzzzz"));
+}
+
+test "resolveTheme: empty config yields defaults" {
+    const theme = resolveTheme(testing.io, config.Config{});
+    try testing.expectEqual(@as(u8, 4), theme.prompt.fg.index);
+    try testing.expect(!theme.prompt.reverse);
+    try testing.expectEqual(@as(u8, 4), theme.match.fg.index);
+    try testing.expect(theme.match.reverse);
+    try testing.expectEqual(@as(u8, 8), theme.cursor_row.bg.index);
+    try testing.expectEqual(@as(u8, 8), theme.status.fg.index);
+    try testing.expectEqual(@as(u8, 4), theme.preview_current.fg.index);
+}
+
+test "resolveTheme: partial theme fills only set roles" {
+    const app_config = config.Config{ .theme = .{ .prompt = "9" } };
+    const theme = resolveTheme(testing.io, app_config);
+    try testing.expectEqual(@as(u8, 9), theme.prompt.fg.index);
+    try testing.expectEqual(@as(u8, 4), theme.match.fg.index);
+    try testing.expectEqual(@as(u8, 8), theme.status.fg.index);
+}
+
+test "resolveTheme: invalid role warns and defaults that role" {
+    const app_config = config.Config{ .theme = .{ .match = "not-a-color" } };
+    const theme = resolveTheme(testing.io, app_config);
+    try testing.expectEqual(@as(u8, 4), theme.match.fg.index);
+    try testing.expect(theme.match.reverse);
+    try testing.expectEqual(@as(u8, 4), theme.prompt.fg.index);
+}
+
+test "resolveTheme: NO_COLOR convention" {
+    const old_value = if (std.c.getenv("NO_COLOR")) |existing|
+        try testing.allocator.dupeZ(u8, std.mem.span(existing))
+    else
+        null;
+    defer {
+        if (old_value) |saved| {
+            defer testing.allocator.free(saved);
+            _ = setenv("NO_COLOR", saved.ptr, 1);
+        } else {
+            _ = setenv("NO_COLOR", "", 1);
+        }
+    }
+    assert(setenv("NO_COLOR", "1", 1) == 0);
+    const app_config = config.Config{ .theme = .{ .prompt = "9" } };
+    const blank = resolveTheme(testing.io, app_config);
+    try testing.expect(blank.prompt.fg == .default);
+    try testing.expect(blank.match.fg == .default);
+    try testing.expect(!blank.match.reverse);
+    try testing.expect(blank.cursor_row.bg == .default);
+    try testing.expect(blank.status.fg == .default);
+    try testing.expect(blank.preview_current.fg == .default);
+    assert(setenv("NO_COLOR", "", 1) == 0);
+    const kept = resolveTheme(testing.io, config.Config{});
+    try testing.expectEqual(@as(u8, 4), kept.prompt.fg.index);
+    try testing.expect(kept.match.reverse);
+}
+
+test "appendPreviewSpans: marker styled and match highlighted" {
+    var arena = std.heap.ArenaAllocator.init(testing.allocator);
+    defer arena.deinit();
+    const excerpt = "  line one\n> needle here\n  line three\n";
+    var spans = std.ArrayListUnmanaged(vxfw.RichText.TextSpan).empty;
+    try appendPreviewSpans(&spans, arena.allocator(), excerpt, 1, 6, defaultTheme());
+    var rebuilt = std.ArrayListUnmanaged(u8).empty;
+    defer rebuilt.deinit(testing.allocator);
+    var marker_style: ?vaxis.Style = null;
+    var match_text: ?[]const u8 = null;
+    for (spans.items) |span| {
+        try rebuilt.appendSlice(testing.allocator, span.text);
+        if (std.mem.eql(u8, span.text, "> ")) {
+            marker_style = span.style;
+        }
+        if (span.style.reverse) {
+            match_text = span.text;
+        }
+    }
+    try testing.expectEqualStrings(excerpt, rebuilt.items);
+    try testing.expectEqual(@as(u8, 4), marker_style.?.fg.index);
+    try testing.expectEqualStrings("needle", match_text.?);
+}
+
+test "appendPreviewSpans: other lines stay plain" {
+    var arena = std.heap.ArenaAllocator.init(testing.allocator);
+    defer arena.deinit();
+    const excerpt = "  plain one\n> needle here\n  plain two\n";
+    var spans = std.ArrayListUnmanaged(vxfw.RichText.TextSpan).empty;
+    try appendPreviewSpans(&spans, arena.allocator(), excerpt, 1, 6, defaultTheme());
+    for (spans.items) |span| {
+        if (std.mem.eql(u8, span.text, "> ")) {
+            continue;
+        }
+        if (std.mem.eql(u8, span.text, "needle")) {
+            continue;
+        }
+        try testing.expect(span.style.fg == .default);
+        try testing.expect(!span.style.reverse);
+    }
+}
+
+test "appendPreviewSpans: past-the-end column leaves plain text" {
+    var arena = std.heap.ArenaAllocator.init(testing.allocator);
+    defer arena.deinit();
+    const excerpt = "> short\n";
+    var spans = std.ArrayListUnmanaged(vxfw.RichText.TextSpan).empty;
+    try appendPreviewSpans(&spans, arena.allocator(), excerpt, 99, 3, defaultTheme());
+    var rebuilt = std.ArrayListUnmanaged(u8).empty;
+    defer rebuilt.deinit(testing.allocator);
+    for (spans.items) |span| {
+        try rebuilt.appendSlice(testing.allocator, span.text);
+        try testing.expect(!span.style.reverse);
+    }
+    try testing.expectEqualStrings(excerpt, rebuilt.items);
+}
+
+test "appendPreviewSpans: zero column yields no highlight" {
+    var arena = std.heap.ArenaAllocator.init(testing.allocator);
+    defer arena.deinit();
+    const excerpt = "> needle here\n";
+    var spans = std.ArrayListUnmanaged(vxfw.RichText.TextSpan).empty;
+    try appendPreviewSpans(&spans, arena.allocator(), excerpt, 0, 6, defaultTheme());
+    for (spans.items) |span| {
+        try testing.expect(!span.style.reverse);
+    }
+}
+
+test "appendPreviewSpans: match region clamps to the line" {
+    var arena = std.heap.ArenaAllocator.init(testing.allocator);
+    defer arena.deinit();
+    const excerpt = "> abcdef\n";
+    var spans = std.ArrayListUnmanaged(vxfw.RichText.TextSpan).empty;
+    try appendPreviewSpans(&spans, arena.allocator(), excerpt, 5, 99, defaultTheme());
+    var match_text: ?[]const u8 = null;
+    for (spans.items) |span| {
+        if (span.style.reverse) {
+            match_text = span.text;
+        }
+    }
+    try testing.expectEqualStrings("ef", match_text.?);
+}
+
+test "sanitizeDisplayText: CSI sequences stripped" {
+    const clean = try sanitizeDisplayText(testing.allocator, "a\x1b[31mred\x1b[0mb");
+    defer testing.allocator.free(clean);
+    try testing.expectEqualStrings("aredb", clean);
+}
+
+test "sanitizeDisplayText: OSC sequences stripped" {
+    const clean = try sanitizeDisplayText(testing.allocator, "a\x1b]8;;http://x\x07linkb");
+    defer testing.allocator.free(clean);
+    try testing.expectEqualStrings("alinkb", clean);
+}
+
+test "sanitizeDisplayText: controls become spaces, tabs expand" {
+    const clean = try sanitizeDisplayText(testing.allocator, "a\x00b\x1bc\x7fd\te");
+    defer testing.allocator.free(clean);
+    try testing.expectEqualStrings("a b d   e", clean);
+}
+
+test "sanitizeDisplayText: bad UTF-8 repaired, good UTF-8 kept" {
+    const clean = try sanitizeDisplayText(testing.allocator, "h\xff\xc3\xa9!\xe2\x82\xac");
+    defer testing.allocator.free(clean);
+    try testing.expectEqualStrings("h�é!€", clean);
+    try testing.expect(std.unicode.utf8ValidateSlice(clean));
+}
+
+test "sanitizeDisplayText: output never carries ESC" {
+    const inputs = [_][]const u8{
+        "\x1b[31m",
+        "\x1b]0;title\x07",
+        "plain",
+        "a\rb\n",
+        "\x9b31m",
+    };
+    for (inputs) |input| {
+        const clean = try sanitizeDisplayText(testing.allocator, input);
+        defer testing.allocator.free(clean);
+        try testing.expect(std.mem.indexOfScalar(u8, clean, 0x1B) == null);
+        try testing.expect(std.unicode.utf8ValidateSlice(clean));
+    }
+}
+
+test "sanitizeDisplayText: idempotent" {
+    const once = try sanitizeDisplayText(testing.allocator, "a\x1b[1mb\tc\xff");
+    defer testing.allocator.free(once);
+    const twice = try sanitizeDisplayText(testing.allocator, once);
+    defer testing.allocator.free(twice);
+    try testing.expectEqualStrings(once, twice);
+}
+
+test "ListModel.init paints empty before first tick" {
+    const items = [_][]const u8{ "src/main.zig", "README.md" };
+    var scorer = try testScorer(&items);
+    defer scorer.deinit();
+    const model = try ListModel.init(testing.allocator, &items, &scorer, defaultTheme());
+    defer model.deinit();
+    try testing.expect(!model.loaded);
+    try testing.expectEqual(@as(usize, 0), model.filtered.items.len);
+    try testing.expectEqual(@as(?u32, 0), model.list_view.item_count);
+    try model.refilter("");
+    try testing.expectEqual(@as(usize, 2), model.filtered.items.len);
+}
+
+test "ListModel.refilter scores sanitized text, selects raw" {
+    const items = [_][]const u8{ "a\x1b[31mmain.zig", "README.md" };
+    var scorer = try testScorer(&items);
+    defer scorer.deinit();
+    const model = try ListModel.init(testing.allocator, &items, &scorer, defaultTheme());
+    defer model.deinit();
+    try model.refilter("main");
+    try testing.expectEqual(@as(usize, 1), model.ranked.len);
+    try testing.expectEqual(@as(usize, 0), model.ranked[0].index);
+    for (model.filtered.items[0].text) |span| {
+        try testing.expect(std.mem.indexOfScalar(u8, span.text, 0x1B) == null);
+    }
+    try testing.expectEqualStrings("a\x1b[31mmain.zig", model.items[model.ranked[0].index]);
+}
+
+test "appendCursorSpans: differs only by background" {
+    var arena = std.heap.ArenaAllocator.init(testing.allocator);
+    defer arena.deinit();
+    const text = "src/main.zig";
+    const matches = [_]usize{ 4, 5, 6, 7 };
+    var normal = std.ArrayListUnmanaged(vxfw.RichText.TextSpan).empty;
+    try appendHighlightSpans(&normal, arena.allocator(), text, &matches, defaultTheme().match);
+    const theme = defaultTheme();
+    var cursor = std.ArrayListUnmanaged(vxfw.RichText.TextSpan).empty;
+    try appendCursorSpans(&cursor, arena.allocator(), normal.items, theme.cursor_row);
+    try testing.expectEqual(normal.items.len, cursor.items.len);
+    for (normal.items, cursor.items) |plain, highlighted| {
+        try testing.expectEqualStrings(plain.text, highlighted.text);
+        try testing.expect(vaxis.Color.eql(plain.style.fg, highlighted.style.fg));
+        try testing.expectEqual(plain.style.reverse, highlighted.style.reverse);
+        try testing.expectEqual(@as(u8, 8), highlighted.style.bg.index);
+    }
+    for (normal.items) |span| {
+        try testing.expect(span.style.bg == .default);
+    }
+}
+
+test "splitGrepLine: column parsed" {
+    const target = splitGrepLine("src/main.zig:12:4:needle");
+    try testing.expectEqualStrings("src/main.zig", target.file);
+    try testing.expectEqual(@as(usize, 12), target.line);
+    try testing.expectEqual(@as(usize, 4), target.column);
+}
+
+test "appendDisplaySafeText: carriage returns become spaces" {
+    var arena = std.heap.ArenaAllocator.init(testing.allocator);
+    defer arena.deinit();
+    var spans = std.ArrayListUnmanaged(vxfw.RichText.TextSpan).empty;
+    try appendDisplaySafeText(&spans, arena.allocator(), "a\rb\r", .{});
+    var rebuilt = std.ArrayListUnmanaged(u8).empty;
+    defer rebuilt.deinit(testing.allocator);
+    for (spans.items) |span| {
+        try testing.expect(std.mem.indexOfScalar(u8, span.text, '\r') == null);
+        try rebuilt.appendSlice(testing.allocator, span.text);
+    }
+    try testing.expectEqualStrings("a b ", rebuilt.items);
+}
+
+test "appendDisplaySafeText: clean text yields one span" {
+    var arena = std.heap.ArenaAllocator.init(testing.allocator);
+    defer arena.deinit();
+    var spans = std.ArrayListUnmanaged(vxfw.RichText.TextSpan).empty;
+    try appendDisplaySafeText(&spans, arena.allocator(), "abc", defaultTheme().match);
+    try testing.expectEqual(@as(usize, 1), spans.items.len);
+    try testing.expectEqualStrings("abc", spans.items[0].text);
+    try testing.expect(spans.items[0].style.reverse);
+}
+
+test "appendPreviewSpans: carriage returns never reach spans" {
+    var arena = std.heap.ArenaAllocator.init(testing.allocator);
+    defer arena.deinit();
+    const excerpt = "> ab\rcd\n";
+    var spans = std.ArrayListUnmanaged(vxfw.RichText.TextSpan).empty;
+    try appendPreviewSpans(&spans, arena.allocator(), excerpt, 1, 5, defaultTheme());
+    for (spans.items) |span| {
+        try testing.expect(std.mem.indexOfScalar(u8, span.text, '\r') == null);
+    }
+}
+
+test "splitGrepLine: missing column yields zero" {
+    const target = splitGrepLine("src/main.zig:12");
+    try testing.expectEqualStrings("src/main.zig", target.file);
+    try testing.expectEqual(@as(usize, 12), target.line);
+    try testing.expectEqual(@as(usize, 0), target.column);
+}