Commit Diff


commit - 6d4e9c975b027c7846a7133974c2ad8e8ccb4c00
commit + 746932d8a6954fd27d99593b7d4b51774cf95a1b
blob - 25708efc37795d0858a880b92fa8f52505d9f952
blob + 74868a0de07d2ee2d5c9b477726259bfd2ffdd33
--- build.zig
+++ build.zig
@@ -31,4 +31,19 @@ pub fn build(b: *std.Build) void {
     }
     const run_step = b.step("run", "Run the app");
     run_step.dependOn(&run_cmd.step);
+
+    const tests = b.addTest(.{
+        .root_module = b.createModule(.{
+            .root_source_file = b.path("src/main.zig"),
+            .target = target,
+            .optimize = optimize,
+            .link_libc = true,
+            .imports = &.{
+                .{ .name = "zul", .module = zul.module("zul") },
+            },
+        }),
+    });
+    const run_tests = b.addRunArtifact(tests);
+    const test_step = b.step("test", "Run unit tests");
+    test_step.dependOn(&run_tests.step);
 }
blob - fe23c682d753e301bd1d51e52f4b646263ce5a46
blob + 1d0c6cdd9236d8735bab603fb6c8ce2ca7efee23
--- justfile
+++ justfile
@@ -1,8 +1,11 @@
 run opt="Debug":
     zig build run -Doptimize={{ opt }}
 
+test:
+    zig build test --summary all
+
 build opt="Debug":
     zig build -Doptimize={{ opt }} -p build/{{ opt }}
 
-install opt="ReleaseSafe" prefix="~/.local":
-    zig build -Doptimize={{ opt }} --prefix {{ prefix }} install
+install opt="ReleaseSafe" path="~/.local":
+    zig build -Doptimize={{ opt }} --prefix {{ path }} install
blob - e8e0b66cb6663ed8ebb8e7f2a1c4668ca3b967d2
blob + 9835bd6d90b8c248671f30b6f1f10cc11ff51a26
--- src/cfg.zig
+++ src/cfg.zig
@@ -62,3 +62,53 @@ pub fn loadConfig(allocator: std.mem.Allocator) ?std.j
         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 - ffe497d71bcd539209a995586a5ecf98c62e9148
blob + ac9739c7a9d68808433abc92662fde17ff3a2d45
--- src/cli.zig
+++ src/cli.zig
@@ -50,8 +50,14 @@ pub const CommandLineArgs = struct {
         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();
-        const items = try source.toSlice(allocator);
 
         var lookup: std.StringHashMapUnmanaged([]const u8) = .{};
 
@@ -61,7 +67,6 @@ pub const CommandLineArgs = struct {
 
         const exe = items[0];
 
-        // 1, skip the exe
         var i: usize = 1;
         var tail_start: usize = 1;
 
@@ -140,7 +145,14 @@ const KeyValue = struct {
 
 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,
@@ -247,3 +259,167 @@ pub fn printUsage() !void {
     , .{});
     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 - a236d0c6ffa055b336fe82629143f4fef8bae250
blob + e141be3f513d25791527ee842ca5ff8b98bfb363
--- src/db.zig
+++ src/db.zig
@@ -42,12 +42,10 @@ pub const Db = struct {
             .files_path = files_path,
         };
 
-        // Ensure directory exists
         std.Io.Dir.cwd().createDirPath(io.rt, base_path) catch |err| {
             if (err != error.PathAlreadyExists) return err;
         };
 
-        // Ensure files exist
         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) {
@@ -74,7 +72,7 @@ pub const Db = struct {
             if (err == error.FileNotFound) return zul.Managed([]DirectoryEntry).fromJson(try std.json.parseFromSlice([]DirectoryEntry, self.allocator, "[]", .{}));
             return err;
         };
-        errdefer self.allocator.free(content);
+        defer self.allocator.free(content);
 
         var arena = try self.allocator.create(std.heap.ArenaAllocator);
         errdefer {
@@ -82,35 +80,10 @@ pub const Db = struct {
             self.allocator.destroy(arena);
         }
         arena.* = std.heap.ArenaAllocator.init(self.allocator);
-        const allocator = arena.allocator();
 
-        var list = std.ArrayListUnmanaged(DirectoryEntry).empty;
-
-        var seen = std.StringHashMap(void).init(allocator);
-
-        // Use splitBackwardsScalar to read the newest entries first
-        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),
-            });
-        }
-
-        self.allocator.free(content);
         return .{
             .arena = arena,
-            .value = try list.toOwnedSlice(allocator),
+            .value = try parseDirLog(arena.allocator(), content),
         };
     }
 
@@ -119,7 +92,7 @@ pub const Db = struct {
             if (err == error.FileNotFound) return zul.Managed([]FileEntry).fromJson(try std.json.parseFromSlice([]FileEntry, self.allocator, "[]", .{}));
             return err;
         };
-        errdefer self.allocator.free(content);
+        defer self.allocator.free(content);
 
         var arena = try self.allocator.create(std.heap.ArenaAllocator);
         errdefer {
@@ -127,38 +100,10 @@ pub const Db = struct {
             self.allocator.destroy(arena);
         }
         arena.* = std.heap.ArenaAllocator.init(self.allocator);
-        const allocator = arena.allocator();
 
-        var list = std.ArrayListUnmanaged(FileEntry).empty;
-
-        var seen = std.StringHashMap(void).init(allocator);
-
-        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),
-            });
-        }
-
-        self.allocator.free(content);
         return .{
             .arena = arena,
-            .value = try list.toOwnedSlice(allocator),
+            .value = try parseFileLog(arena.allocator(), content),
         };
     }
 
@@ -334,3 +279,126 @@ pub fn getDefaultDbPath(allocator: std.mem.Allocator) 
     }
     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 - 6b959d9d20c88693c39eae40f829b7c39842c5e7
blob + 127706ad4c3cb551cb5ef20cb1b65fc4445e7ec6
--- src/fzf.zig
+++ src/fzf.zig
@@ -114,22 +114,15 @@ fn runFzfPicker(allocator: std.mem.Allocator, fzf_opts
     const child = &fzf.child;
 
     if (child.stdin) |stdin| {
-        // Write items in batches to prevent excessive buffer usage
-        const batch_size = 1000;
-        for (0..((items.len + batch_size - 1) / batch_size)) |batch_idx| {
-            const start = batch_idx * batch_size;
-            const end = @min(start + batch_size, items.len);
-
-            for (start..end) |i| {
-                stdin.writeStreamingAll(io.rt, items[i]) catch |err| {
-                    if (err == error.BrokenPipe) break;
-                    return err;
-                };
-                stdin.writeStreamingAll(io.rt, "\n") catch |err| {
-                    if (err == error.BrokenPipe) break;
-                    return err;
-                };
-            }
+        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;
@@ -540,3 +533,22 @@ fn runFzfWithWalker(allocator: std.mem.Allocator, db: 
         }
     }
 }
+
+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", &.{}));
+}
blob - f580ffd7adca82805ed440c0a28c241af68676c6
blob + c80f3a526bf0097fc1a42aaeff6962b341e4f133
--- src/main.zig
+++ src/main.zig
@@ -5,6 +5,13 @@ const fzf = @import("fzf.zig");
 const hist = @import("db.zig");
 const io = @import("io.zig");
 
+test {
+    _ = cli;
+    _ = cfg;
+    _ = fzf;
+    _ = hist;
+}
+
 pub fn main(init: std.process.Init) void {
     io.rt = init.io;
     io.env = init.environ_map;