From 3f8943dcb36ab8c6fffeb40d88d628495aacbe01 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 29 Aug 2026 22:37:50 -0500 Subject: [PATCH] convert usage-example tests into doctests 26 tests move out of tests.zig to sit as decl-named tests next to the functions they exercise, so the generated API docs show them as usage examples. Error-path and multi-declaration tests stay in tests.zig. Two tests fully subsumed by the doctests are dropped, and the hasTag/countTags helpers move to testing.zig so both files share them. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017i7R7ReKJAHGQpitxGXqrb --- src/Database.zig | 443 +++++++++++++++++++++++++++ src/Message.zig | 182 +++++++++++ src/Query.zig | 62 ++++ src/helpers.zig | 56 ++++ src/testing.zig | 18 ++ src/tests.zig | 768 +---------------------------------------------- 6 files changed, 764 insertions(+), 765 deletions(-) diff --git a/src/Database.zig b/src/Database.zig index e830bea..31951fe 100644 --- a/src/Database.zig +++ b/src/Database.zig @@ -212,6 +212,35 @@ pub fn open(mode: Mode, options: OpenCreateOptions) OpenResult { }; } +test open { + const fixture = @import("testing.zig"); + + const alloc = std.testing.allocator; + var test_db = try fixture.corpusDatabase(); + defer test_db.deinit(); + + // Close the writer so its changes are committed and visible to readers. + const path = try alloc.dupeZ(u8, test_db.database.getPath() orelse return error.TestUnexpectedResult); + defer alloc.free(path); + try test_db.database.close(); + + const ro = switch (open(.read_only, .{ + .database_path = path, + .config_path = "", + })) { + .ok => |db| db, + .err => |e| { + defer e.deinit(); + return e.err; + }, + }; + defer ro.deinit() catch {}; + + const query = try ro.queryCreate("*"); + defer query.deinit(); + try std.testing.expectEqual(@as(u32, fixture.corpus.len), try query.countMessages()); +} + pub const CreateError = error{ /// A database already exists at `database_path`, so it was not /// (re-)created. @@ -453,6 +482,20 @@ pub fn getVersion(self: *const Database) error{FormatVersionError}!u32 { return version; } +test getVersion { + const fixture = @import("testing.zig"); + + var test_db = try fixture.corpusDatabase(); + defer test_db.deinit(); + + const version = try test_db.database.getVersion(); + try std.testing.expect(version > 0); + + // A closed database has no determinable version. + try test_db.database.close(); + try std.testing.expectError(error.FormatVersionError, test_db.database.getVersion()); +} + /// Can the database be upgraded to a newer database version? /// /// If this function returns `true`, then the caller may call `upgrade` @@ -505,6 +548,23 @@ pub fn beginAtomic(self: *const Database) error{ XapianException, Unexpected }!v } } +test beginAtomic { + const fixture = @import("testing.zig"); + + var test_db = try fixture.corpusDatabase(); + defer test_db.deinit(); + const db = test_db.database; + + try db.beginAtomic(); + const message = try db.findMessage("one@example.org") orelse return error.TestUnexpectedResult; + defer message.deinit(); + try message.addTag("atomic-test"); + try db.endAtomic(); + + var tags = message.getTags(); + try std.testing.expect(fixture.hasTag(&tags, "atomic-test")); +} + /// Indicate the end of an atomic database operation. If repeated (with matching /// `beginAtomic`) "database.autocommit" times, commit the transaction and /// all previous (non-cancelled) transactions to the database. @@ -560,6 +620,25 @@ pub fn getRevision(self: *const Database) Revision { }; } +test getRevision { + const fixture = @import("testing.zig"); + + var test_db = try fixture.corpusDatabase(); + defer test_db.deinit(); + const db = test_db.database; + + const message = try db.findMessage("one@example.org") orelse return error.TestUnexpectedResult; + defer message.deinit(); + + var previous = db.getRevision(); + for ([_][:0]const u8{ "mono-one", "mono-two", "mono-three" }) |tag| { + try message.addTag(tag); + const current = db.getRevision(); + try std.testing.expect(try current.compare(previous) == .gt); + previous = current; + } +} + /// Retrieve a directory object from the database for `path`. /// /// Here, 'path' should be a path relative to the path of `database` (see @@ -586,6 +665,36 @@ pub fn getDirectory(self: *const Database, path: [:0]const u8) Error!?Directory }; } +test getDirectory { + const fixture = @import("testing.zig"); + + const alloc = std.testing.allocator; + var test_db = try fixture.corpusDatabase(); + defer test_db.deinit(); + const db = test_db.database; + + const root = db.getPath() orelse return error.TestUnexpectedResult; + const cur_path = try std.fs.path.joinZ(alloc, &.{ root, "cur" }); + defer alloc.free(cur_path); + + const directory = try db.getDirectory(cur_path) orelse return error.TestUnexpectedResult; + defer directory.deinit(); + + try std.testing.expectEqual(@as(?i64, null), directory.getMtime()); + try directory.setMtime(12345); + try std.testing.expectEqual(@as(?i64, 12345), directory.getMtime()); + + var files = try directory.getChildFiles(); + defer files.deinit(); + var count: usize = 0; + while (files.next()) |_| count += 1; + try std.testing.expectEqual(fixture.corpus.len, count); + + var dirs = try directory.getChildDirectories(); + defer dirs.deinit(); + try std.testing.expectEqual(@as(?[:0]const u8, null), dirs.next()); +} + pub const IndexFileError = error{ /// An error occurred trying to open the file, (such as permission denied, or /// file not found, etc.). Nothing added to the database. @@ -702,6 +811,42 @@ pub fn indexFileGetMessage(self: *const Database, filename: [:0]const u8, indexo }; } +test indexFileGetMessage { + const fixture = @import("testing.zig"); + + const alloc = std.testing.allocator; + const io = std.testing.io; + var test_db = try fixture.corpusDatabase(); + defer test_db.deinit(); + + try test_db.tmp.dir.writeFile(io, .{ + .sub_path = "mail/cur/five.eml", + .data = + \\From: Erin + \\To: Alice + \\Subject: One more thing + \\Date: Thu, 04 Apr 2024 10:00:00 +0000 + \\Message-ID: + \\ + \\Just one more thing. + \\ + , + }); + + const root = test_db.database.getPath() orelse return error.TestUnexpectedResult; + const path = try std.fs.path.joinZ(alloc, &.{ root, "cur", "five.eml" }); + defer alloc.free(path); + + const message = try test_db.database.indexFileGetMessage(path, null); + defer message.deinit(); + try std.testing.expectEqualStrings("five@example.org", message.getMessageID() orelse ""); + + try test_db.database.reopen(.read_only); + const query = try test_db.database.queryCreate("*"); + defer query.deinit(); + try std.testing.expectEqual(@as(u32, fixture.corpus.len + 1), try query.countMessages()); +} + pub const RemoveMessageError = error{ /// This filename was removed but the message persists in the database with at /// least one other filename. @@ -741,6 +886,50 @@ pub fn removeMessage(self: *const Database, filename: [:0]const u8) RemoveMessag }; } +test removeMessage { + const fixture = @import("testing.zig"); + + const alloc = std.testing.allocator; + const io = std.testing.io; + var test_db = try fixture.corpusDatabase(); + defer test_db.deinit(); + const db = test_db.database; + + // Index a second file carrying the same Message-ID as one.eml. + try test_db.tmp.dir.writeFile(io, .{ + .sub_path = "mail/cur/one-copy.eml", + .data = fixture.corpus[0].data, + }); + + const root = db.getPath() orelse return error.TestUnexpectedResult; + const original = try std.fs.path.joinZ(alloc, &.{ root, "cur", "one.eml" }); + defer alloc.free(original); + const copy = try std.fs.path.joinZ(alloc, &.{ root, "cur", "one-copy.eml" }); + defer alloc.free(copy); + + try db.indexFile(copy, null); + + const message = try db.findMessage("one@example.org") orelse return error.TestUnexpectedResult; + defer message.deinit(); + try std.testing.expectEqual(@as(usize, 2), try message.countFiles()); + + // Removing one filename reports DuplicateMessageID: the filename is gone + // but the message persists under the remaining filename. + try std.testing.expectError(error.DuplicateMessageID, db.removeMessage(original)); + const remaining = try db.findMessage("one@example.org") orelse return error.TestUnexpectedResult; + defer remaining.deinit(); + try std.testing.expectEqual(@as(usize, 1), try remaining.countFiles()); + try std.testing.expect(std.mem.endsWith(u8, try remaining.getFilename(), "one-copy.eml")); + + // Removing the last filename removes the message content. Because + // two@example.org references this message ID, notmuch keeps it as a + // "ghost": findable, but with no files. + try db.removeMessage(copy); + const ghost = try db.findMessage("one@example.org") orelse return error.TestUnexpectedResult; + defer ghost.deinit(); + try std.testing.expect(try ghost.getFlag(.ghost)); +} + pub const FindMessageError = error{ /// Out of memory creating message object. OutOfMemory, @@ -799,6 +988,36 @@ pub fn findMessageByFilename(self: *const Database, filename: [:0]const u8) Find }; } +test findMessageByFilename { + const fixture = @import("testing.zig"); + + const alloc = std.testing.allocator; + var test_db = try fixture.corpusDatabase(); + defer test_db.deinit(); + const db = test_db.database; + + const root = db.getPath() orelse return error.TestUnexpectedResult; + + // Lookup by absolute filename. + const abs = try std.fs.path.joinZ(alloc, &.{ root, "cur", "two.eml" }); + defer alloc.free(abs); + const by_abs = try db.findMessageByFilename(abs) orelse return error.TestUnexpectedResult; + defer by_abs.deinit(); + try std.testing.expectEqualStrings("two@example.org", by_abs.getMessageID() orelse ""); + + // Lookup by filename relative to the database path. + const by_rel = try db.findMessageByFilename("cur/two.eml") orelse return error.TestUnexpectedResult; + defer by_rel.deinit(); + try std.testing.expectEqualStrings("two@example.org", by_rel.getMessageID() orelse ""); + + // Unknown filenames return null. + try std.testing.expectEqual(@as(?Message, null), try db.findMessageByFilename("cur/missing.eml")); + + // A closed database reports a Xapian exception. + try db.close(); + try std.testing.expectError(error.XapianException, db.findMessageByFilename(abs)); +} + /// Return a list of all tags found in the database. /// /// This function creates a list of all tags found in the database. The @@ -811,6 +1030,21 @@ pub fn getAllTags(self: *const Database) ?TagsIterator { }; } +test getAllTags { + const fixture = @import("testing.zig"); + + var test_db = try fixture.corpusDatabase(); + defer test_db.deinit(); + const db = test_db.database; + + const message = try db.findMessage("four@example.org") orelse return error.TestUnexpectedResult; + defer message.deinit(); + try message.addTag("zig"); + + var tags = db.getAllTags() orelse return error.TestUnexpectedResult; + try std.testing.expect(fixture.hasTag(&tags, "zig")); +} + pub const ReopenError = error{ /// The database was not open. IllegalArgument, @@ -834,6 +1068,48 @@ pub fn reopen(self: *Database, mode: Mode) ReopenError!void { }; } +test reopen { + const fixture = @import("testing.zig"); + + const alloc = std.testing.allocator; + var test_db = try fixture.corpusDatabase(); + defer test_db.deinit(); + + const path = try alloc.dupeZ(u8, test_db.database.getPath() orelse return error.TestUnexpectedResult); + defer alloc.free(path); + + // A second, read-only connection to the same database. + var reader = switch (open(.read_only, .{ + .database_path = path, + .config_path = "", + })) { + .ok => |db| db, + .err => |e| { + defer e.deinit(); + return e.err; + }, + }; + defer reader.deinit() catch {}; + + { + const query = try reader.queryCreate("tag:fresh"); + defer query.deinit(); + try std.testing.expectEqual(@as(u32, 0), try query.countMessages()); + } + + // The writer tags a message and commits by closing. + const message = try test_db.database.findMessage("one@example.org") orelse return error.TestUnexpectedResult; + defer message.deinit(); + try message.addTag("fresh"); + try test_db.database.close(); + + // The reader sees the change only after reopening. + try reader.reopen(.read_only); + const query = try reader.queryCreate("tag:fresh"); + defer query.deinit(); + try std.testing.expectEqual(@as(u32, 1), try query.countMessages()); +} + /// Create a new query. /// /// For the query string, we'll document the syntax here more completely in the @@ -899,6 +1175,47 @@ pub fn configSet(self: *const Database, key: Config, value: [:0]const u8) Error! try wrap(c.notmuch_config_set(self.database, @intFromEnum(key), value)); } +test configSet { + const fixture = @import("testing.zig"); + + var test_db = try fixture.corpusDatabase(); + defer test_db.deinit(); + const db = test_db.database; + + // A value from the configuration file. + try std.testing.expectEqualStrings( + "zig@example.org", + try db.configGet(.primary_email) orelse return error.TestUnexpectedResult, + ); + + // Round-trip through the database, including overwriting. + try db.configSet(.user_name, "Test User"); + try std.testing.expectEqualStrings( + "Test User", + try db.configGet(.user_name) orelse return error.TestUnexpectedResult, + ); + try db.configSet(.user_name, "Renamed User"); + try std.testing.expectEqualStrings( + "Renamed User", + try db.configGet(.user_name) orelse return error.TestUnexpectedResult, + ); + + // Values set through configSet persist across close and reopen. + try db.close(); + try test_db.database.reopen(.read_write); + try std.testing.expectEqualStrings( + "Renamed User", + try db.configGet(.user_name) orelse return error.TestUnexpectedResult, + ); + + // configGetBool rejects values that are not booleans. + try std.testing.expectError(error.IllegalArgument, db.configGetBool(.primary_email)); + + // configSet requires a read-write database. + try test_db.database.reopen(.read_only); + try std.testing.expectError(error.ReadOnlyDatabase, db.configSet(.user_name, "nope")); +} + /// Returns an iterator for a `;`-delimited list of configuration values. /// /// These values reflect all configuration information given at the @@ -915,6 +1232,26 @@ pub fn configGetValues( }; } +test configGetValues { + const fixture = @import("testing.zig"); + + var test_db = try fixture.corpusDatabase(); + defer test_db.deinit(); + const db = test_db.database; + + try db.configSet(.exclude_tags, "spam;deleted"); + + var values = db.configGetValues(.exclude_tags) orelse return error.TestUnexpectedResult; + defer values.deinit(); + try std.testing.expectEqualStrings("spam", values.next() orelse return error.TestUnexpectedResult); + try std.testing.expectEqualStrings("deleted", values.next() orelse return error.TestUnexpectedResult); + try std.testing.expectEqual(@as(?[:0]const u8, null), values.next()); + + // start() resets the iterator to the first value. + values.start(); + try std.testing.expectEqualStrings("spam", values.next() orelse return error.TestUnexpectedResult); +} + /// Returns an iterator for a ';'-delimited list of configuration values /// /// These values reflect all configuration information given at the @@ -973,6 +1310,51 @@ pub fn configGetPairs(self: *const Database, prefix: [:0]const u8) ?PairsIterato }; } +test configGetPairs { + const fixture = @import("testing.zig"); + + var test_db = try fixture.corpusDatabase(); + defer test_db.deinit(); + const db = test_db.database; + + try db.configSet(.user_name, "Test User"); + + // A prefix restricts iteration to matching keys. + var user_pairs = db.configGetPairs("user.") orelse return error.TestUnexpectedResult; + defer user_pairs.deinit(); + var user_count: usize = 0; + var saw_name = false; + while (user_pairs.next()) |pair| { + try std.testing.expect(std.mem.startsWith(u8, pair.key, "user.")); + if (std.mem.eql(u8, pair.key, "user.name")) { + saw_name = true; + try std.testing.expectEqualStrings("Test User", pair.value orelse ""); + } + user_count += 1; + } + try std.testing.expect(saw_name); + + // The empty prefix iterates over all pairs, a superset of any prefix. + var all_pairs = db.configGetPairs("") orelse return error.TestUnexpectedResult; + defer all_pairs.deinit(); + var all_count: usize = 0; + var saw_null_value = false; + while (all_pairs.next()) |pair| { + if (pair.value == null) saw_null_value = true; + all_count += 1; + } + try std.testing.expect(all_count > user_count); + + // Some keys carry no value; next() reports them as null rather than + // crashing (regression check for the PairsIterator NULL-value fix). + try std.testing.expect(saw_null_value); + + // An unknown prefix yields an empty iteration. + var unknown = db.configGetPairs("bogus.") orelse return error.TestUnexpectedResult; + defer unknown.deinit(); + try std.testing.expect(unknown.next() == null); +} + /// Get a configuration value from an open database as boolean. /// /// This value reflects all configuration information given at the time the @@ -998,6 +1380,43 @@ pub fn configPath(self: *const Database) ?[:0]const u8 { return std.mem.span(c.notmuch_config_path(self.database) orelse return null); } +test configPath { + const fixture = @import("testing.zig"); + + const alloc = std.testing.allocator; + var test_db = try fixture.corpusDatabase(); + defer test_db.deinit(); + const db = test_db.database; + + // The reported path is the config file the database was opened with, + // which the fixture places next to the mail root. + const root = db.getPath() orelse return error.TestUnexpectedResult; + const expected = try std.fs.path.join(alloc, &.{ + std.fs.path.dirname(root) orelse return error.TestUnexpectedResult, + "config", + }); + defer alloc.free(expected); + try std.testing.expectEqualStrings(expected, db.configPath() orelse return error.TestUnexpectedResult); + + // A database opened with config_path "" reports the empty string as its + // config path rather than null. + const path = try alloc.dupeZ(u8, root); + defer alloc.free(path); + try db.close(); + const no_config = switch (open(.read_only, .{ + .database_path = path, + .config_path = "", + })) { + .ok => |d| d, + .err => |e| { + defer e.deinit(); + return e.err; + }, + }; + defer no_config.deinit() catch {}; + try std.testing.expectEqualStrings("", no_config.configPath() orelse return error.TestUnexpectedResult); +} + /// Get the current default indexing options for a given database. /// /// This object will survive until the database itself is destroyed, but the @@ -1011,6 +1430,30 @@ pub fn getDefaultIndexOpts(self: *const Database) ?IndexOpts { }; } +test getDefaultIndexOpts { + const fixture = @import("testing.zig"); + + var test_db = try fixture.corpusDatabase(); + defer test_db.deinit(); + const db = test_db.database; + + // With no index.decrypt configuration, the default policy is auto. + const indexopts = db.getDefaultIndexOpts() orelse return error.TestUnexpectedResult; + defer indexopts.deinit(); + try std.testing.expectEqual(IndexOpts.Decrypt.auto, indexopts.getDecryptPolicy()); + + // Every policy value round-trips. + for ([_]IndexOpts.Decrypt{ .false, .true, .auto, .nostash }) |policy| { + try indexopts.setDecryptPolicy(policy); + try std.testing.expectEqual(policy, indexopts.getDecryptPolicy()); + } + + // Changing one options object does not change the database default. + const fresh = db.getDefaultIndexOpts() orelse return error.TestUnexpectedResult; + defer fresh.deinit(); + try std.testing.expectEqual(IndexOpts.Decrypt.auto, fresh.getDecryptPolicy()); +} + test { std.testing.refAllDecls(@This()); } diff --git a/src/Message.zig b/src/Message.zig index bbfd8eb..413a498 100644 --- a/src/Message.zig +++ b/src/Message.zig @@ -142,6 +142,25 @@ pub fn getFilenames(self: *const Message) GetFilenamesError!FilenamesIterator { }; } +test getFilenames { + const fixture = @import("testing.zig"); + + var test_db = try fixture.corpusDatabase(); + defer test_db.deinit(); + + const message = try test_db.database.findMessage("one@example.org") orelse return error.TestUnexpectedResult; + defer message.deinit(); + + try std.testing.expectEqual(@as(usize, 1), try message.countFiles()); + try std.testing.expect(std.mem.endsWith(u8, try message.getFilename(), "one.eml")); + + var filenames = try message.getFilenames(); + defer filenames.deinit(); + var count: usize = 0; + while (filenames.next()) |_| count += 1; + try std.testing.expectEqual(@as(usize, 1), count); +} + /// Re-index the e-mail corresponding to `Message` using the supplied index /// options (or the database defaults when `indexopts` is `null`). /// @@ -218,6 +237,23 @@ pub fn getHeader(self: *const Message, header: [:0]const u8) ?[:0]const u8 { return std.mem.span(c.notmuch_message_get_header(self.message, header) orelse return null); } +test getHeader { + const fixture = @import("testing.zig"); + + var test_db = try fixture.corpusDatabase(); + defer test_db.deinit(); + + const message = try test_db.database.findMessage("one@example.org") orelse return error.TestUnexpectedResult; + defer message.deinit(); + + try std.testing.expectEqualStrings("one@example.org", message.getMessageID() orelse ""); + try std.testing.expect(message.getThreadID() != null); + // Mon, 01 Apr 2024 09:00:00 +0000 + try std.testing.expectEqual(@as(?i64, 1711962000), message.getDate()); + try std.testing.expectEqualStrings("Alice ", message.getHeader("From") orelse ""); + try std.testing.expectEqualStrings("", message.getHeader("X-Nonexistent") orelse "missing"); +} + /// Get the tags for `Message`, returning a `TagsIterator` object which can be /// used to iterate over all tags. /// @@ -250,6 +286,31 @@ pub fn addTag(self: *const Message, tag: [:0]const u8) AddTagError!void { }; } +test addTag { + const fixture = @import("testing.zig"); + + var test_db = try fixture.corpusDatabase(); + defer test_db.deinit(); + + const message = try test_db.database.findMessage("one@example.org") orelse return error.TestUnexpectedResult; + defer message.deinit(); + + try message.addTag("foo"); + try message.addTag("bar"); + var tags = message.getTags(); + try std.testing.expectEqual(@as(usize, 2), fixture.countTags(&tags)); + + try message.removeTag("foo"); + tags = message.getTags(); + try std.testing.expect(!fixture.hasTag(&tags, "foo")); + tags = message.getTags(); + try std.testing.expect(fixture.hasTag(&tags, "bar")); + + try message.removeAllTags(); + tags = message.getTags(); + try std.testing.expectEqual(@as(usize, 0), fixture.countTags(&tags)); +} + pub const RemoveTagError = error{ /// The length of `tag` is too long (exceeds NOTMUCH_TAG_MAX). TagTooLong, @@ -323,6 +384,50 @@ pub fn maildirFlagsToTags(self: *const Message) Error!void { try wrap(c.notmuch_message_maildir_flags_to_tags(self.message)); } +test maildirFlagsToTags { + const fixture = @import("testing.zig"); + + const alloc = std.testing.allocator; + const io = std.testing.io; + var test_db = try fixture.corpusDatabase(); + defer test_db.deinit(); + const db = test_db.database; + + // A maildir file with the "flagged" flag set but not "seen". + try test_db.tmp.dir.writeFile(io, .{ + .sub_path = "mail/cur/five:2,F", + .data = + \\From: Erin + \\To: Alice + \\Subject: Flagged mail + \\Date: Thu, 04 Apr 2024 10:00:00 +0000 + \\Message-ID: + \\ + \\This one is flagged. + \\ + , + }); + + const root = db.getPath() orelse return error.TestUnexpectedResult; + const path = try std.fs.path.joinZ(alloc, &.{ root, "cur", "five:2,F" }); + defer alloc.free(path); + const message = try db.indexFileGetMessage(path, null); + defer message.deinit(); + + try std.testing.expect(try message.hasMaildirFlag(.F)); + try std.testing.expect(!try message.hasMaildirFlag(.S)); + try std.testing.expect(!try message.hasMaildirFlag(.D)); + + try message.maildirFlagsToTags(); + + // F present adds "flagged"; S absent performs the opposite of removing + // "unread", i.e. adds it. + var tags = message.getTags(); + try std.testing.expect(fixture.hasTag(&tags, "flagged")); + tags = message.getTags(); + try std.testing.expect(fixture.hasTag(&tags, "unread")); +} + pub const HasMaildirFlagError = error{ /// A Xapian exception occurred. XapianException, @@ -374,6 +479,33 @@ pub fn tagsToMaildirFlags(self: *const Message) Error!void { try wrap(c.notmuch_message_tags_to_maildir_flags(self.message)); } +test tagsToMaildirFlags { + const fixture = @import("testing.zig"); + + const io = std.testing.io; + var test_db = try fixture.corpusDatabase(); + defer test_db.deinit(); + const db = test_db.database; + + const message = try db.findMessage("one@example.org") orelse return error.TestUnexpectedResult; + defer message.deinit(); + + try message.addTag("flagged"); + try message.addTag("passed"); + // No "unread" tag, so the seen flag is set as well. + try message.tagsToMaildirFlags(); + + const filename = try message.getFilename(); + try std.testing.expect(std.mem.endsWith(u8, filename, "one.eml:2,FPS")); + try std.testing.expect(try message.hasMaildirFlag(.F)); + try std.testing.expect(try message.hasMaildirFlag(.P)); + try std.testing.expect(try message.hasMaildirFlag(.S)); + + // The file was renamed on disk, not copied. + try test_db.tmp.dir.access(io, "mail/cur/one.eml:2,FPS", .{}); + try std.testing.expectError(error.FileNotFound, test_db.tmp.dir.access(io, "mail/cur/one.eml", .{})); +} + /// Freeze the current state of `Message` within the database. /// /// This means that changes to the message state, (via `addTag`, `removeTag`, @@ -407,6 +539,23 @@ pub fn freeze(self: *const Message) Error!void { try wrap(c.notmuch_message_freeze(self.message)); } +test freeze { + const fixture = @import("testing.zig"); + + var test_db = try fixture.corpusDatabase(); + defer test_db.deinit(); + + const message = try test_db.database.findMessage("two@example.org") orelse return error.TestUnexpectedResult; + defer message.deinit(); + + try message.freeze(); + try message.addTag("frozen"); + try message.thaw(); + + var tags = message.getTags(); + try std.testing.expect(fixture.hasTag(&tags, "frozen")); +} + /// Thaw the current `Message`, synchronizing any changes that may have /// occurred while `Message` was frozen into the `notmuch` database. /// @@ -465,6 +614,39 @@ pub fn addProperty(self: *const Message, key: [:0]const u8, value: [:0]const u8) }; } +test addProperty { + const fixture = @import("testing.zig"); + + var test_db = try fixture.corpusDatabase(); + defer test_db.deinit(); + + const message = try test_db.database.findMessage("one@example.org") orelse return error.TestUnexpectedResult; + defer message.deinit(); + + try message.addProperty("test.key", "value1"); + try message.addProperty("test.key", "value2"); + try std.testing.expectEqualStrings( + "value1", + message.getProperty("test.key") orelse return error.TestUnexpectedResult, + ); + try std.testing.expectEqual(@as(usize, 2), try message.countProperties("test.key")); + + var properties = message.getProperties("test.key", true); + defer properties.deinit(); + var count: usize = 0; + while (properties.next()) |kv| { + try std.testing.expectEqualStrings("test.key", kv.key); + count += 1; + } + try std.testing.expectEqual(@as(usize, 2), count); + + try message.removeProperty("test.key", "value1"); + try std.testing.expectEqual(@as(usize, 1), try message.countProperties("test.key")); + + try message.removeAllProperties(null); + try std.testing.expectEqual(@as(usize, 0), try message.countProperties("test.key")); +} + pub const RemovePropertyError = error{ /// `key` may not contain an '=' character. IllegalArgument, diff --git a/src/Query.zig b/src/Query.zig index 04baa87..f79844c 100644 --- a/src/Query.zig +++ b/src/Query.zig @@ -99,6 +99,32 @@ pub fn addTagExclude(self: *const Query, tag: [:0]const u8) AddTagExcludeError!v }; } +test addTagExclude { + const fixture = @import("testing.zig"); + + var test_db = try fixture.corpusDatabase(); + defer test_db.deinit(); + const db = test_db.database; + + const spam = try db.findMessage("three@example.org") orelse return error.TestUnexpectedResult; + defer spam.deinit(); + try spam.addTag("spam"); + + const query = try db.queryCreate("*"); + defer query.deinit(); + try query.addTagExclude("spam"); + query.setOmitExcluded(.true); + + var messages = try query.searchMessages(); + defer messages.deinit(); + var count: usize = 0; + while (try messages.next()) |message| { + defer message.deinit(); + count += 1; + } + try std.testing.expectEqual(fixture.corpus.len - 1, count); +} + /// Execute a query for threads, returning a `ThreadsIterator` object which can /// be used to iterate over the results. The returned threads object is owned by /// the query and as such, will only be valid until `Query.deinit`. @@ -165,6 +191,30 @@ pub fn searchMessages(self: *const Query) Error!MessagesIterator { }; } +test searchMessages { + const fixture = @import("testing.zig"); + + var test_db = try fixture.corpusDatabase(); + defer test_db.deinit(); + + const query = try test_db.database.queryCreate("*"); + defer query.deinit(); + + query.setSort(.oldest_first); + var oldest = try query.searchMessages(); + defer oldest.deinit(); + const first = try oldest.next() orelse return error.TestUnexpectedResult; + defer first.deinit(); + try std.testing.expectEqualStrings("Lunch plans", first.getHeader("Subject") orelse ""); + + query.setSort(.newest_first); + var newest = try query.searchMessages(); + defer newest.deinit(); + const last = try newest.next() orelse return error.TestUnexpectedResult; + defer last.deinit(); + try std.testing.expectEqualStrings("Zig bindings", last.getHeader("Subject") orelse ""); +} + pub const CountMessagesError = error{ /// Syntax error in the query (returned for s-expression queries, whose /// parsing is deferred until the query runs). @@ -189,6 +239,18 @@ pub fn countMessages(self: *const Query) CountMessagesError!u32 { }; } +test countMessages { + const fixture = @import("testing.zig"); + + var test_db = try fixture.corpusDatabase(); + defer test_db.deinit(); + + const query = try test_db.database.queryCreate("from:carol@example.org"); + defer query.deinit(); + try std.testing.expectEqual(@as(u32, 1), try query.countMessages()); + try std.testing.expectEqual(@as(u32, 1), try query.countThreads()); +} + pub const CountThreadsError = error{ /// Syntax error in the query (returned for s-expression queries, whose /// parsing is deferred until the query runs). diff --git a/src/helpers.zig b/src/helpers.zig index b427b96..0bb03a1 100644 --- a/src/helpers.zig +++ b/src/helpers.zig @@ -131,3 +131,59 @@ pub const MessageWriter = struct { self.* = undefined; } }; + +test MessageWriter { + const fixture = @import("testing.zig"); + + const io = std.testing.io; + var test_db = try fixture.corpusDatabase(); + defer test_db.deinit(); + const db = test_db.database; + var buffer: [256]u8 = undefined; + + // A cancelled message leaves nothing behind at its destination. + var cancelled = try MessageWriter.init(io, db, &buffer, .{ .subdir = .new }); + try cancelled.writer().writeAll("From: partial \n"); + var cancelled_path_buf: [128]u8 = undefined; + const cancelled_path = try std.fmt.bufPrint(&cancelled_path_buf, "mail/new/{s}", .{cancelled.filename()}); + cancelled.cancel(); + try std.testing.expectError(error.FileNotFound, test_db.tmp.dir.access(io, cancelled_path, .{})); + + // A finished message is materialized in new/ and indexed. The generated + // name leads with the delivery timestamp so filenames sort by arrival. + var finished = try MessageWriter.init(io, db, &buffer, .{ .subdir = .new }); + try std.testing.expect(std.ascii.isDigit(finished.filename()[0])); + try std.testing.expect(std.mem.indexOf(u8, finished.filename(), ".M") != null); + try finished.writer().writeAll( + \\From: Grace + \\To: Alice + \\Subject: Atomic delivery + \\Date: Sat, 06 Apr 2024 09:00:00 +0000 + \\Message-ID: + \\ + \\Materialized atomically. + \\ + ); + const message = try finished.finish(null); + defer message.deinit(); + try std.testing.expectEqualStrings("seven@example.org", message.getMessageID() orelse ""); + try std.testing.expect(std.mem.indexOf(u8, try message.getFilename(), "/new/") != null); + + // Delivery into cur/ carries an empty maildir info suffix. + var seen = try MessageWriter.init(io, db, &buffer, .{ .subdir = .cur }); + try seen.writer().writeAll( + \\From: Heidi + \\To: Alice + \\Subject: Already seen + \\Date: Sat, 06 Apr 2024 10:00:00 +0000 + \\Message-ID: + \\ + \\Delivered as seen mail. + \\ + ); + const seen_message = try seen.finish(null); + defer seen_message.deinit(); + const seen_filename = try seen_message.getFilename(); + try std.testing.expect(std.mem.indexOf(u8, seen_filename, "/cur/") != null); + try std.testing.expect(std.mem.endsWith(u8, seen_filename, ":2,")); +} diff --git a/src/testing.zig b/src/testing.zig index a0f3fc5..3fcffd7 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -11,6 +11,24 @@ const std = @import("std"); const Database = @import("Database.zig"); const helpers = @import("helpers.zig"); const Message = @import("Message.zig"); +const TagsIterator = @import("TagsIterator.zig"); + +/// Consume a tags iterator and report whether it yields `name`. +pub fn hasTag(tags: *TagsIterator, name: []const u8) bool { + defer tags.deinit(); + while (tags.next()) |tag| { + if (std.mem.eql(u8, tag, name)) return true; + } + return false; +} + +/// Consume a tags iterator and count its entries. +pub fn countTags(tags: *TagsIterator) usize { + defer tags.deinit(); + var n: usize = 0; + while (tags.next()) |_| n += 1; + return n; +} pub const CorpusMessage = struct { filename: []const u8, diff --git a/src/tests.zig b/src/tests.zig index 749d20b..8c5bb86 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -9,52 +9,6 @@ const std = @import("std"); const notmuch = @import("notmuch.zig"); const fixture = @import("testing.zig"); -const TagsIterator = @import("TagsIterator.zig"); - -/// Consume a tags iterator and report whether it yields `name`. -fn hasTag(tags: *TagsIterator, name: []const u8) bool { - defer tags.deinit(); - while (tags.next()) |tag| { - if (std.mem.eql(u8, tag, name)) return true; - } - return false; -} - -/// Consume a tags iterator and count its entries. -fn countTags(tags: *TagsIterator) usize { - defer tags.deinit(); - var n: usize = 0; - while (tags.next()) |_| n += 1; - return n; -} - -test "Database.open on an existing database" { - const alloc = std.testing.allocator; - var test_db = try fixture.corpusDatabase(); - defer test_db.deinit(); - - // Close the writer so its changes are committed and visible to readers. - const path = try alloc.dupeZ(u8, test_db.database.getPath() orelse return error.TestUnexpectedResult); - defer alloc.free(path); - try test_db.database.close(); - - const ro = switch (notmuch.Database.open(.read_only, .{ - .database_path = path, - .config_path = "", - })) { - .ok => |db| db, - .err => |e| { - defer e.deinit(); - return e.err; - }, - }; - defer ro.deinit() catch {}; - - const query = try ro.queryCreate("*"); - defer query.deinit(); - try std.testing.expectEqual(@as(u32, fixture.corpus.len), try query.countMessages()); -} - test "Database path, version, and upgrade status" { var test_db = try fixture.corpusDatabase(); defer test_db.deinit(); @@ -67,50 +21,6 @@ test "Database path, version, and upgrade status" { try db.upgrade(null, null); } -test "Database.getVersion" { - var test_db = try fixture.corpusDatabase(); - defer test_db.deinit(); - - const version = try test_db.database.getVersion(); - try std.testing.expect(version > 0); - - // A closed database has no determinable version. - try test_db.database.close(); - try std.testing.expectError(error.FormatVersionError, test_db.database.getVersion()); -} - -test "Database revisions increase with commits" { - var test_db = try fixture.corpusDatabase(); - defer test_db.deinit(); - const db = test_db.database; - - const before = db.getRevision(); - const message = try db.findMessage("one@example.org") orelse return error.TestUnexpectedResult; - defer message.deinit(); - try message.addTag("revision-test"); - const after = db.getRevision(); - - try std.testing.expectEqualStrings(before.uuid, after.uuid); - try std.testing.expect(try after.compare(before) == .gt); -} - -test "Database revisions are monotonic across commits" { - var test_db = try fixture.corpusDatabase(); - defer test_db.deinit(); - const db = test_db.database; - - const message = try db.findMessage("one@example.org") orelse return error.TestUnexpectedResult; - defer message.deinit(); - - var previous = db.getRevision(); - for ([_][:0]const u8{ "mono-one", "mono-two", "mono-three" }) |tag| { - try message.addTag(tag); - const current = db.getRevision(); - try std.testing.expect(try current.compare(previous) == .gt); - previous = current; - } -} - test "Database UUIDs are unique per database" { var test_db1 = try fixture.corpusDatabase(); defer test_db1.deinit(); @@ -124,21 +34,6 @@ test "Database UUIDs are unique per database" { try std.testing.expectError(error.DatabaseMismatch, rev1.compare(rev2)); } -test "Database atomic sections" { - var test_db = try fixture.corpusDatabase(); - defer test_db.deinit(); - const db = test_db.database; - - try db.beginAtomic(); - const message = try db.findMessage("one@example.org") orelse return error.TestUnexpectedResult; - defer message.deinit(); - try message.addTag("atomic-test"); - try db.endAtomic(); - - var tags = message.getTags(); - try std.testing.expect(hasTag(&tags, "atomic-test")); -} - test "Database atomic section error paths" { var test_db = try fixture.corpusDatabase(); defer test_db.deinit(); @@ -161,34 +56,6 @@ test "Database atomic section error paths" { try db.endAtomic(); } -test "Database.getDirectory and Directory" { - const alloc = std.testing.allocator; - var test_db = try fixture.corpusDatabase(); - defer test_db.deinit(); - const db = test_db.database; - - const root = db.getPath() orelse return error.TestUnexpectedResult; - const cur_path = try std.fs.path.joinZ(alloc, &.{ root, "cur" }); - defer alloc.free(cur_path); - - const directory = try db.getDirectory(cur_path) orelse return error.TestUnexpectedResult; - defer directory.deinit(); - - try std.testing.expectEqual(@as(?i64, null), directory.getMtime()); - try directory.setMtime(12345); - try std.testing.expectEqual(@as(?i64, 12345), directory.getMtime()); - - var files = try directory.getChildFiles(); - defer files.deinit(); - var count: usize = 0; - while (files.next()) |_| count += 1; - try std.testing.expectEqual(fixture.corpus.len, count); - - var dirs = try directory.getChildDirectories(); - defer dirs.deinit(); - try std.testing.expectEqual(@as(?[:0]const u8, null), dirs.next()); -} - test "Database message lookup and removal" { const alloc = std.testing.allocator; var test_db = try fixture.corpusDatabase(); @@ -213,295 +80,6 @@ test "Database message lookup and removal" { try std.testing.expectEqual(@as(u32, fixture.corpus.len - 1), try query.countMessages()); } -test "Database.removeMessage with a message stored under multiple filenames" { - const alloc = std.testing.allocator; - const io = std.testing.io; - var test_db = try fixture.corpusDatabase(); - defer test_db.deinit(); - const db = test_db.database; - - // Index a second file carrying the same Message-ID as one.eml. - try test_db.tmp.dir.writeFile(io, .{ - .sub_path = "mail/cur/one-copy.eml", - .data = fixture.corpus[0].data, - }); - - const root = db.getPath() orelse return error.TestUnexpectedResult; - const original = try std.fs.path.joinZ(alloc, &.{ root, "cur", "one.eml" }); - defer alloc.free(original); - const copy = try std.fs.path.joinZ(alloc, &.{ root, "cur", "one-copy.eml" }); - defer alloc.free(copy); - - try db.indexFile(copy, null); - - const message = try db.findMessage("one@example.org") orelse return error.TestUnexpectedResult; - defer message.deinit(); - try std.testing.expectEqual(@as(usize, 2), try message.countFiles()); - - // Removing one filename reports DuplicateMessageID: the filename is gone - // but the message persists under the remaining filename. - try std.testing.expectError(error.DuplicateMessageID, db.removeMessage(original)); - const remaining = try db.findMessage("one@example.org") orelse return error.TestUnexpectedResult; - defer remaining.deinit(); - try std.testing.expectEqual(@as(usize, 1), try remaining.countFiles()); - try std.testing.expect(std.mem.endsWith(u8, try remaining.getFilename(), "one-copy.eml")); - - // Removing the last filename removes the message content. Because - // two@example.org references this message ID, notmuch keeps it as a - // "ghost": findable, but with no files. - try db.removeMessage(copy); - const ghost = try db.findMessage("one@example.org") orelse return error.TestUnexpectedResult; - defer ghost.deinit(); - try std.testing.expect(try ghost.getFlag(.ghost)); -} - -test "Database.findMessageByFilename" { - const alloc = std.testing.allocator; - var test_db = try fixture.corpusDatabase(); - defer test_db.deinit(); - const db = test_db.database; - - const root = db.getPath() orelse return error.TestUnexpectedResult; - - // Lookup by absolute filename. - const abs = try std.fs.path.joinZ(alloc, &.{ root, "cur", "two.eml" }); - defer alloc.free(abs); - const by_abs = try db.findMessageByFilename(abs) orelse return error.TestUnexpectedResult; - defer by_abs.deinit(); - try std.testing.expectEqualStrings("two@example.org", by_abs.getMessageID() orelse ""); - - // Lookup by filename relative to the database path. - const by_rel = try db.findMessageByFilename("cur/two.eml") orelse return error.TestUnexpectedResult; - defer by_rel.deinit(); - try std.testing.expectEqualStrings("two@example.org", by_rel.getMessageID() orelse ""); - - // Unknown filenames return null. - try std.testing.expectEqual(@as(?notmuch.Message, null), try db.findMessageByFilename("cur/missing.eml")); - - // A closed database reports a Xapian exception. - try db.close(); - try std.testing.expectError(error.XapianException, db.findMessageByFilename(abs)); -} - -test "Database configuration access" { - var test_db = try fixture.corpusDatabase(); - defer test_db.deinit(); - const db = test_db.database; - - try std.testing.expectEqualStrings( - "zig@example.org", - try db.configGet(.primary_email) orelse return error.TestUnexpectedResult, - ); - - try db.configSet(.user_name, "Test User"); - try std.testing.expectEqualStrings( - "Test User", - try db.configGet(.user_name) orelse return error.TestUnexpectedResult, - ); - - try std.testing.expect(try db.configGetBool(.sync_maildir_flags)); - - const config_path = db.configPath() orelse return error.TestUnexpectedResult; - try std.testing.expect(std.mem.endsWith(u8, config_path, "config")); - - var values = db.configGetValues(.new_tags) orelse return error.TestUnexpectedResult; - defer values.deinit(); - var saw_unread = false; - while (values.next()) |value| { - if (std.mem.eql(u8, value, "unread")) saw_unread = true; - } - try std.testing.expect(saw_unread); - - var pairs = db.configGetPairs("") orelse return error.TestUnexpectedResult; - defer pairs.deinit(); - var saw_pair = false; - while (pairs.next()) |_| saw_pair = true; - try std.testing.expect(saw_pair); -} - -test "Database configGet and configSet" { - var test_db = try fixture.corpusDatabase(); - defer test_db.deinit(); - const db = test_db.database; - - // A value from the configuration file. - try std.testing.expectEqualStrings( - "zig@example.org", - try db.configGet(.primary_email) orelse return error.TestUnexpectedResult, - ); - - // Round-trip through the database, including overwriting. - try db.configSet(.user_name, "Test User"); - try std.testing.expectEqualStrings( - "Test User", - try db.configGet(.user_name) orelse return error.TestUnexpectedResult, - ); - try db.configSet(.user_name, "Renamed User"); - try std.testing.expectEqualStrings( - "Renamed User", - try db.configGet(.user_name) orelse return error.TestUnexpectedResult, - ); - - // Values set through configSet persist across close and reopen. - try db.close(); - try test_db.database.reopen(.read_write); - try std.testing.expectEqualStrings( - "Renamed User", - try db.configGet(.user_name) orelse return error.TestUnexpectedResult, - ); - - // configGetBool rejects values that are not booleans. - try std.testing.expectError(error.IllegalArgument, db.configGetBool(.primary_email)); - - // configSet requires a read-write database. - try test_db.database.reopen(.read_only); - try std.testing.expectError(error.ReadOnlyDatabase, db.configSet(.user_name, "nope")); -} - -test "Database.configGetValues" { - var test_db = try fixture.corpusDatabase(); - defer test_db.deinit(); - const db = test_db.database; - - try db.configSet(.exclude_tags, "spam;deleted"); - - var values = db.configGetValues(.exclude_tags) orelse return error.TestUnexpectedResult; - defer values.deinit(); - try std.testing.expectEqualStrings("spam", values.next() orelse return error.TestUnexpectedResult); - try std.testing.expectEqualStrings("deleted", values.next() orelse return error.TestUnexpectedResult); - try std.testing.expectEqual(@as(?[:0]const u8, null), values.next()); - - // start() resets the iterator to the first value. - values.start(); - try std.testing.expectEqualStrings("spam", values.next() orelse return error.TestUnexpectedResult); - -} - -test "Database.configGetPairs" { - var test_db = try fixture.corpusDatabase(); - defer test_db.deinit(); - const db = test_db.database; - - try db.configSet(.user_name, "Test User"); - - // A prefix restricts iteration to matching keys. - var user_pairs = db.configGetPairs("user.") orelse return error.TestUnexpectedResult; - defer user_pairs.deinit(); - var user_count: usize = 0; - var saw_name = false; - while (user_pairs.next()) |pair| { - try std.testing.expect(std.mem.startsWith(u8, pair.key, "user.")); - if (std.mem.eql(u8, pair.key, "user.name")) { - saw_name = true; - try std.testing.expectEqualStrings("Test User", pair.value orelse ""); - } - user_count += 1; - } - try std.testing.expect(saw_name); - - // The empty prefix iterates over all pairs, a superset of any prefix. - var all_pairs = db.configGetPairs("") orelse return error.TestUnexpectedResult; - defer all_pairs.deinit(); - var all_count: usize = 0; - var saw_null_value = false; - while (all_pairs.next()) |pair| { - if (pair.value == null) saw_null_value = true; - all_count += 1; - } - try std.testing.expect(all_count > user_count); - - // Some keys carry no value; next() reports them as null rather than - // crashing (regression check for the PairsIterator NULL-value fix). - try std.testing.expect(saw_null_value); - - // An unknown prefix yields an empty iteration. - var unknown = db.configGetPairs("bogus.") orelse return error.TestUnexpectedResult; - defer unknown.deinit(); - try std.testing.expect(unknown.next() == null); -} - -test "Database.configPath" { - const alloc = std.testing.allocator; - var test_db = try fixture.corpusDatabase(); - defer test_db.deinit(); - const db = test_db.database; - - // The reported path is the config file the database was opened with, - // which the fixture places next to the mail root. - const root = db.getPath() orelse return error.TestUnexpectedResult; - const expected = try std.fs.path.join(alloc, &.{ - std.fs.path.dirname(root) orelse return error.TestUnexpectedResult, - "config", - }); - defer alloc.free(expected); - try std.testing.expectEqualStrings(expected, db.configPath() orelse return error.TestUnexpectedResult); - - // A database opened with config_path "" reports the empty string as its - // config path rather than null. - const path = try alloc.dupeZ(u8, root); - defer alloc.free(path); - try db.close(); - const no_config = switch (notmuch.Database.open(.read_only, .{ - .database_path = path, - .config_path = "", - })) { - .ok => |d| d, - .err => |e| { - defer e.deinit(); - return e.err; - }, - }; - defer no_config.deinit() catch {}; - try std.testing.expectEqualStrings("", no_config.configPath() orelse return error.TestUnexpectedResult); -} - -test "Database.getAllTags" { - var test_db = try fixture.corpusDatabase(); - defer test_db.deinit(); - const db = test_db.database; - - const message = try db.findMessage("four@example.org") orelse return error.TestUnexpectedResult; - defer message.deinit(); - try message.addTag("zig"); - - var tags = db.getAllTags() orelse return error.TestUnexpectedResult; - try std.testing.expect(hasTag(&tags, "zig")); -} - -test "Database.indexFileGetMessage and reopen" { - const alloc = std.testing.allocator; - const io = std.testing.io; - var test_db = try fixture.corpusDatabase(); - defer test_db.deinit(); - - try test_db.tmp.dir.writeFile(io, .{ - .sub_path = "mail/cur/five.eml", - .data = - \\From: Erin - \\To: Alice - \\Subject: One more thing - \\Date: Thu, 04 Apr 2024 10:00:00 +0000 - \\Message-ID: - \\ - \\Just one more thing. - \\ - , - }); - - const root = test_db.database.getPath() orelse return error.TestUnexpectedResult; - const path = try std.fs.path.joinZ(alloc, &.{ root, "cur", "five.eml" }); - defer alloc.free(path); - - const message = try test_db.database.indexFileGetMessage(path, null); - defer message.deinit(); - try std.testing.expectEqualStrings("five@example.org", message.getMessageID() orelse ""); - - try test_db.database.reopen(.read_only); - const query = try test_db.database.queryCreate("*"); - defer query.deinit(); - try std.testing.expectEqual(@as(u32, fixture.corpus.len + 1), try query.countMessages()); -} - test "Database.reopen switches modes" { var test_db = try fixture.corpusDatabase(); defer test_db.deinit(); @@ -518,47 +96,7 @@ test "Database.reopen switches modes" { defer rw_message.deinit(); try rw_message.addTag("reopened"); var tags = rw_message.getTags(); - try std.testing.expect(hasTag(&tags, "reopened")); -} - -test "Database.reopen picks up committed changes" { - const alloc = std.testing.allocator; - var test_db = try fixture.corpusDatabase(); - defer test_db.deinit(); - - const path = try alloc.dupeZ(u8, test_db.database.getPath() orelse return error.TestUnexpectedResult); - defer alloc.free(path); - - // A second, read-only connection to the same database. - var reader = switch (notmuch.Database.open(.read_only, .{ - .database_path = path, - .config_path = "", - })) { - .ok => |db| db, - .err => |e| { - defer e.deinit(); - return e.err; - }, - }; - defer reader.deinit() catch {}; - - { - const query = try reader.queryCreate("tag:fresh"); - defer query.deinit(); - try std.testing.expectEqual(@as(u32, 0), try query.countMessages()); - } - - // The writer tags a message and commits by closing. - const message = try test_db.database.findMessage("one@example.org") orelse return error.TestUnexpectedResult; - defer message.deinit(); - try message.addTag("fresh"); - try test_db.database.close(); - - // The reader sees the change only after reopening. - try reader.reopen(.read_only); - const query = try reader.queryCreate("tag:fresh"); - defer query.deinit(); - try std.testing.expectEqual(@as(u32, 1), try query.countMessages()); + try std.testing.expect(fixture.hasTag(&tags, "reopened")); } test "Database.reopen revives a closed database" { @@ -575,28 +113,6 @@ test "Database.reopen revives a closed database" { try std.testing.expectEqualStrings("one@example.org", message.getMessageID() orelse ""); } -test "Database.getDefaultIndexOpts and the decrypt policy" { - var test_db = try fixture.corpusDatabase(); - defer test_db.deinit(); - const db = test_db.database; - - // With no index.decrypt configuration, the default policy is auto. - const indexopts = db.getDefaultIndexOpts() orelse return error.TestUnexpectedResult; - defer indexopts.deinit(); - try std.testing.expectEqual(notmuch.IndexOpts.Decrypt.auto, indexopts.getDecryptPolicy()); - - // Every policy value round-trips. - for ([_]notmuch.IndexOpts.Decrypt{ .false, .true, .auto, .nostash }) |policy| { - try indexopts.setDecryptPolicy(policy); - try std.testing.expectEqual(policy, indexopts.getDecryptPolicy()); - } - - // Changing one options object does not change the database default. - const fresh = db.getDefaultIndexOpts() orelse return error.TestUnexpectedResult; - defer fresh.deinit(); - try std.testing.expectEqual(notmuch.IndexOpts.Decrypt.auto, fresh.getDecryptPolicy()); -} - test "Query string, sort, and database accessors" { var test_db = try fixture.corpusDatabase(); defer test_db.deinit(); @@ -610,62 +126,6 @@ test "Query string, sort, and database accessors" { try std.testing.expect(query.getDatabase() != null); } -test "Query search respects sort order" { - var test_db = try fixture.corpusDatabase(); - defer test_db.deinit(); - - const query = try test_db.database.queryCreate("*"); - defer query.deinit(); - - query.setSort(.oldest_first); - var oldest = try query.searchMessages(); - defer oldest.deinit(); - const first = try oldest.next() orelse return error.TestUnexpectedResult; - defer first.deinit(); - try std.testing.expectEqualStrings("Lunch plans", first.getHeader("Subject") orelse ""); - - query.setSort(.newest_first); - var newest = try query.searchMessages(); - defer newest.deinit(); - const last = try newest.next() orelse return error.TestUnexpectedResult; - defer last.deinit(); - try std.testing.expectEqualStrings("Zig bindings", last.getHeader("Subject") orelse ""); -} - -test "Query search terms" { - var test_db = try fixture.corpusDatabase(); - defer test_db.deinit(); - - const query = try test_db.database.queryCreate("from:carol@example.org"); - defer query.deinit(); - try std.testing.expectEqual(@as(u32, 1), try query.countMessages()); - try std.testing.expectEqual(@as(u32, 1), try query.countThreads()); -} - -test "Query tag exclusion" { - var test_db = try fixture.corpusDatabase(); - defer test_db.deinit(); - const db = test_db.database; - - const spam = try db.findMessage("three@example.org") orelse return error.TestUnexpectedResult; - defer spam.deinit(); - try spam.addTag("spam"); - - const query = try db.queryCreate("*"); - defer query.deinit(); - try query.addTagExclude("spam"); - query.setOmitExcluded(.true); - - var messages = try query.searchMessages(); - defer messages.deinit(); - var count: usize = 0; - while (try messages.next()) |message| { - defer message.deinit(); - count += 1; - } - try std.testing.expectEqual(fixture.corpus.len - 1, count); -} - test "Query tag exclusion error paths" { var test_db = try fixture.corpusDatabase(); defer test_db.deinit(); @@ -714,76 +174,6 @@ test "Query searches on a closed database" { try std.testing.expectError(error.XapianException, query.searchThreads()); } -test "Message identity, headers, and dates" { - var test_db = try fixture.corpusDatabase(); - defer test_db.deinit(); - - const message = try test_db.database.findMessage("one@example.org") orelse return error.TestUnexpectedResult; - defer message.deinit(); - - try std.testing.expectEqualStrings("one@example.org", message.getMessageID() orelse ""); - try std.testing.expect(message.getThreadID() != null); - // Mon, 01 Apr 2024 09:00:00 +0000 - try std.testing.expectEqual(@as(?i64, 1711962000), message.getDate()); - try std.testing.expectEqualStrings("Alice ", message.getHeader("From") orelse ""); - try std.testing.expectEqualStrings("", message.getHeader("X-Nonexistent") orelse "missing"); -} - -test "Message filenames" { - var test_db = try fixture.corpusDatabase(); - defer test_db.deinit(); - - const message = try test_db.database.findMessage("one@example.org") orelse return error.TestUnexpectedResult; - defer message.deinit(); - - try std.testing.expectEqual(@as(usize, 1), try message.countFiles()); - try std.testing.expect(std.mem.endsWith(u8, try message.getFilename(), "one.eml")); - - var filenames = try message.getFilenames(); - defer filenames.deinit(); - var count: usize = 0; - while (filenames.next()) |_| count += 1; - try std.testing.expectEqual(@as(usize, 1), count); -} - -test "Message tag manipulation" { - var test_db = try fixture.corpusDatabase(); - defer test_db.deinit(); - - const message = try test_db.database.findMessage("one@example.org") orelse return error.TestUnexpectedResult; - defer message.deinit(); - - try message.addTag("foo"); - try message.addTag("bar"); - var tags = message.getTags(); - try std.testing.expectEqual(@as(usize, 2), countTags(&tags)); - - try message.removeTag("foo"); - tags = message.getTags(); - try std.testing.expect(!hasTag(&tags, "foo")); - tags = message.getTags(); - try std.testing.expect(hasTag(&tags, "bar")); - - try message.removeAllTags(); - tags = message.getTags(); - try std.testing.expectEqual(@as(usize, 0), countTags(&tags)); -} - -test "Message freeze and thaw" { - var test_db = try fixture.corpusDatabase(); - defer test_db.deinit(); - - const message = try test_db.database.findMessage("two@example.org") orelse return error.TestUnexpectedResult; - defer message.deinit(); - - try message.freeze(); - try message.addTag("frozen"); - try message.thaw(); - - var tags = message.getTags(); - try std.testing.expect(hasTag(&tags, "frozen")); -} - test "Message freeze and thaw error paths" { var test_db = try fixture.corpusDatabase(); defer test_db.deinit(); @@ -808,37 +198,6 @@ test "Message freeze and thaw error paths" { try std.testing.expectError(error.ReadOnlyDatabase, ro_message.freeze()); } -test "Message properties" { - var test_db = try fixture.corpusDatabase(); - defer test_db.deinit(); - - const message = try test_db.database.findMessage("one@example.org") orelse return error.TestUnexpectedResult; - defer message.deinit(); - - try message.addProperty("test.key", "value1"); - try message.addProperty("test.key", "value2"); - try std.testing.expectEqualStrings( - "value1", - message.getProperty("test.key") orelse return error.TestUnexpectedResult, - ); - try std.testing.expectEqual(@as(usize, 2), try message.countProperties("test.key")); - - var properties = message.getProperties("test.key", true); - defer properties.deinit(); - var count: usize = 0; - while (properties.next()) |kv| { - try std.testing.expectEqualStrings("test.key", kv.key); - count += 1; - } - try std.testing.expectEqual(@as(usize, 2), count); - - try message.removeProperty("test.key", "value1"); - try std.testing.expectEqual(@as(usize, 1), try message.countProperties("test.key")); - - try message.removeAllProperties(null); - try std.testing.expectEqual(@as(usize, 0), try message.countProperties("test.key")); -} - test "Message property error paths" { var test_db = try fixture.corpusDatabase(); defer test_db.deinit(); @@ -883,73 +242,6 @@ test "Message maildir flags" { try message.maildirFlagsToTags(); } -test "Message.maildirFlagsToTags" { - const alloc = std.testing.allocator; - const io = std.testing.io; - var test_db = try fixture.corpusDatabase(); - defer test_db.deinit(); - const db = test_db.database; - - // A maildir file with the "flagged" flag set but not "seen". - try test_db.tmp.dir.writeFile(io, .{ - .sub_path = "mail/cur/five:2,F", - .data = - \\From: Erin - \\To: Alice - \\Subject: Flagged mail - \\Date: Thu, 04 Apr 2024 10:00:00 +0000 - \\Message-ID: - \\ - \\This one is flagged. - \\ - , - }); - - const root = db.getPath() orelse return error.TestUnexpectedResult; - const path = try std.fs.path.joinZ(alloc, &.{ root, "cur", "five:2,F" }); - defer alloc.free(path); - const message = try db.indexFileGetMessage(path, null); - defer message.deinit(); - - try std.testing.expect(try message.hasMaildirFlag(.F)); - try std.testing.expect(!try message.hasMaildirFlag(.S)); - try std.testing.expect(!try message.hasMaildirFlag(.D)); - - try message.maildirFlagsToTags(); - - // F present adds "flagged"; S absent performs the opposite of removing - // "unread", i.e. adds it. - var tags = message.getTags(); - try std.testing.expect(hasTag(&tags, "flagged")); - tags = message.getTags(); - try std.testing.expect(hasTag(&tags, "unread")); -} - -test "Message.tagsToMaildirFlags renames the file" { - const io = std.testing.io; - var test_db = try fixture.corpusDatabase(); - defer test_db.deinit(); - const db = test_db.database; - - const message = try db.findMessage("one@example.org") orelse return error.TestUnexpectedResult; - defer message.deinit(); - - try message.addTag("flagged"); - try message.addTag("passed"); - // No "unread" tag, so the seen flag is set as well. - try message.tagsToMaildirFlags(); - - const filename = try message.getFilename(); - try std.testing.expect(std.mem.endsWith(u8, filename, "one.eml:2,FPS")); - try std.testing.expect(try message.hasMaildirFlag(.F)); - try std.testing.expect(try message.hasMaildirFlag(.P)); - try std.testing.expect(try message.hasMaildirFlag(.S)); - - // The file was renamed on disk, not copied. - try test_db.tmp.dir.access(io, "mail/cur/one.eml:2,FPS", .{}); - try std.testing.expectError(error.FileNotFound, test_db.tmp.dir.access(io, "mail/cur/one.eml", .{})); -} - test "Thread accessors" { var test_db = try fixture.corpusDatabase(); defer test_db.deinit(); @@ -1026,12 +318,12 @@ test "Thread and MessagesIterator tag collection" { defer thread.deinit(); var thread_tags = thread.getTags(); - try std.testing.expect(hasTag(&thread_tags, "red")); + try std.testing.expect(fixture.hasTag(&thread_tags, "red")); var messages = try query.searchMessages(); defer messages.deinit(); var collected = messages.collectTags() orelse return error.TestUnexpectedResult; - try std.testing.expect(hasTag(&collected, "blue")); + try std.testing.expect(fixture.hasTag(&collected, "blue")); } test "Database.open error reporting" { @@ -1080,60 +372,6 @@ test "writes to a read-only database return errors" { try std.testing.expectError(error.ReadOnlyDatabase, message.removeAllProperties(null)); } -test "helpers.MessageWriter atomicity and delivery" { - const io = std.testing.io; - var test_db = try fixture.corpusDatabase(); - defer test_db.deinit(); - const db = test_db.database; - var buffer: [256]u8 = undefined; - - // A cancelled message leaves nothing behind at its destination. - var cancelled = try notmuch.helpers.MessageWriter.init(io, db, &buffer, .{ .subdir = .new }); - try cancelled.writer().writeAll("From: partial \n"); - var cancelled_path_buf: [128]u8 = undefined; - const cancelled_path = try std.fmt.bufPrint(&cancelled_path_buf, "mail/new/{s}", .{cancelled.filename()}); - cancelled.cancel(); - try std.testing.expectError(error.FileNotFound, test_db.tmp.dir.access(io, cancelled_path, .{})); - - // A finished message is materialized in new/ and indexed. The generated - // name leads with the delivery timestamp so filenames sort by arrival. - var finished = try notmuch.helpers.MessageWriter.init(io, db, &buffer, .{ .subdir = .new }); - try std.testing.expect(std.ascii.isDigit(finished.filename()[0])); - try std.testing.expect(std.mem.indexOf(u8, finished.filename(), ".M") != null); - try finished.writer().writeAll( - \\From: Grace - \\To: Alice - \\Subject: Atomic delivery - \\Date: Sat, 06 Apr 2024 09:00:00 +0000 - \\Message-ID: - \\ - \\Materialized atomically. - \\ - ); - const message = try finished.finish(null); - defer message.deinit(); - try std.testing.expectEqualStrings("seven@example.org", message.getMessageID() orelse ""); - try std.testing.expect(std.mem.indexOf(u8, try message.getFilename(), "/new/") != null); - - // Delivery into cur/ carries an empty maildir info suffix. - var seen = try notmuch.helpers.MessageWriter.init(io, db, &buffer, .{ .subdir = .cur }); - try seen.writer().writeAll( - \\From: Heidi - \\To: Alice - \\Subject: Already seen - \\Date: Sat, 06 Apr 2024 10:00:00 +0000 - \\Message-ID: - \\ - \\Delivered as seen mail. - \\ - ); - const seen_message = try seen.finish(null); - defer seen_message.deinit(); - const seen_filename = try seen_message.getFilename(); - try std.testing.expect(std.mem.indexOf(u8, seen_filename, "/cur/") != null); - try std.testing.expect(std.mem.endsWith(u8, seen_filename, ":2,")); -} - test "module helpers" { try std.testing.expect(notmuch.builtWith("compact")); try std.testing.expect(!notmuch.builtWith("time-travel")); -- 2.51.2