magdalena

Log | Files | Refs | README | LICENSE

commit 746932d8a6954fd27d99593b7d4b51774cf95a1b
parent 6d4e9c975b027c7846a7133974c2ad8e8ccb4c00
Author: mtmn <miro@haravara.org>
Date:   Wed, 10 Jun 2026 00:50:39 +0200

feat: test: add `testing` and fix findings

Diffstat:
Mbuild.zig | 15+++++++++++++++
Mjustfile | 7+++++--
Msrc/cfg.zig | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/cli.zig | 180++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/db.zig | 186++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Msrc/fzf.zig | 44++++++++++++++++++++++++++++----------------
Msrc/main.zig | 7+++++++
7 files changed, 410 insertions(+), 79 deletions(-)

diff --git a/build.zig b/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); } diff --git a/justfile b/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 diff --git a/src/cfg.zig b/src/cfg.zig @@ -62,3 +62,53 @@ pub fn loadConfig(allocator: std.mem.Allocator) ?std.json.Parsed(Config) { 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); +} diff --git a/src/cli.zig b/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.?); +} diff --git a/src/db.zig b/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) ![]const u8 { } 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); +} diff --git a/src/fzf.zig b/src/fzf.zig @@ -114,22 +114,15 @@ fn runFzfPicker(allocator: std.mem.Allocator, fzf_opts: []const []const u8, item 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: *hist.Db, kind: std.Io.Fil } } } + +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", &.{})); +} diff --git a/src/main.zig b/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;