commit 5491de12b5939e99398b02a35fd80c7fb2706d86 Author: Jeffrey C. Ollie Date: Fri Sep 13 18:05:43 2024 -0500 first diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bc6a357 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/.zig-cache +/zig-out + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4865ed5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright © 2024 Jeffrey C. Ollie + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b257cc4 --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +# notmuch.zig + +Zig wrapper for the notmuchmail C API. + diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..acb165d --- /dev/null +++ b/build.zig @@ -0,0 +1,29 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const module = b.addModule("notmuch", .{ + .root_source_file = b.path("src/notmuch.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }); + + module.linkSystemLibrary("notmuch", .{}); + + const tests = b.addTest(.{ + .root_source_file = b.path("src/notmuch.zig"), + .target = target, + .optimize = optimize, + }); + + tests.linkSystemLibrary2("notmuch", .{}); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..7607522 --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,72 @@ +.{ + // This is the default name used by packages depending on this one. For + // example, when a user runs `zig fetch --save `, this field is used + // as the key in the `dependencies` table. Although the user can choose a + // different name, most users will stick with this provided value. + // + // It is redundant to include "zig" in this name because it is already + // within the Zig package namespace. + .name = "notmuch.zig", + + // This is a [Semantic Version](https://semver.org/). + // In a future version of Zig it will be used for package deduplication. + .version = "0.0.0", + + // This field is optional. + // This is currently advisory only; Zig does not yet do anything + // with this value. + //.minimum_zig_version = "0.11.0", + + // This field is optional. + // Each dependency must either provide a `url` and `hash`, or a `path`. + // `zig build --fetch` can be used to fetch all dependencies of a package, recursively. + // Once all dependencies are fetched, `zig build` no longer requires + // internet connectivity. + .dependencies = .{ + // See `zig fetch --save ` for a command-line interface for adding dependencies. + //.example = .{ + // // When updating this field to a new URL, be sure to delete the corresponding + // // `hash`, otherwise you are communicating that you expect to find the old hash at + // // the new URL. + // .url = "https://example.com/foo.tar.gz", + // + // // This is computed from the file contents of the directory of files that is + // // obtained after fetching `url` and applying the inclusion rules given by + // // `paths`. + // // + // // This field is the source of truth; packages do not come from a `url`; they + // // come from a `hash`. `url` is just one of many possible mirrors for how to + // // obtain a package matching this `hash`. + // // + // // Uses the [multihash](https://multiformats.io/multihash/) format. + // .hash = "...", + // + // // When this is provided, the package is found in a directory relative to the + // // build root. In this case the package's hash is irrelevant and therefore not + // // computed. This field and `url` are mutually exclusive. + // .path = "foo", + + // // When this is set to `true`, a package is declared to be lazily + // // fetched. This makes the dependency only get fetched if it is + // // actually used. + // .lazy = false, + //}, + }, + + // Specifies the set of files and directories that are included in this package. + // Only files and directories listed here are included in the `hash` that + // is computed for this package. Only files listed here will remain on disk + // when using the zig package manager. As a rule of thumb, one should list + // files required for compilation plus any license(s). + // Paths are relative to the build root. Use the empty string (`""`) to refer to + // the build root itself. + // A directory listed here means that all files within, recursively, are included. + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + // For example... + //"LICENSE", + //"README.md", + }, +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..86db06d --- /dev/null +++ b/flake.lock @@ -0,0 +1,60 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1710146030, + "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1726062873, + "narHash": "sha256-IiA3jfbR7K/B5+9byVi9BZGWTD4VSbWe8VLpp9B/iYk=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "4f807e8940284ad7925ebd0a0993d2a1791acb2f", + "type": "github" + }, + "original": { + "id": "nixpkgs", + "ref": "nixos-unstable", + "type": "indirect" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..4cf1648 --- /dev/null +++ b/flake.nix @@ -0,0 +1,30 @@ +{ + inputs = { + nixpkgs = { + url = "nixpkgs/nixos-unstable"; + }; + flake-utils = { + url = "github:numtide/flake-utils"; + }; + }; + + outputs = { + nixpkgs, + flake-utils, + ... + }: + flake-utils.lib.eachDefaultSystem ( + system: let + pkgs = import nixpkgs { + inherit system; + }; + in { + devShells.default = pkgs.mkShell { + nativeBuildInputs = [ + pkgs.zig_0_13 + pkgs.notmuch + ]; + }; + } + ); +} diff --git a/src/notmuch.zig b/src/notmuch.zig new file mode 100644 index 0000000..bdfc4b5 --- /dev/null +++ b/src/notmuch.zig @@ -0,0 +1,170 @@ +const std = @import("std"); + +const c = @cImport({ + @cInclude("notmuch.h"); +}); + +const log = std.log.scoped(.notmuch); + +fn generateEnum(comptime prefix: []const u8) type { + @setEvalBranchQuota(9000); + const info = @typeInfo(c); + var count: usize = 0; + for (info.Struct.decls) |d| { + if (std.mem.eql(u8, "NOTMUCH_STATUS_LAST_STATUS", d.name)) continue; + if (std.mem.startsWith(u8, d.name, prefix)) { + count += 1; + } + } + var fields: [count]std.builtin.Type.EnumField = undefined; + var index: usize = 0; + var max: c.notmuch_status_t = 0; + for (info.Struct.decls) |d| { + if (std.mem.eql(u8, "NOTMUCH_STATUS_LAST_STATUS", d.name)) continue; + if (std.mem.startsWith(u8, d.name, prefix)) { + max = @max(max, @field(c, d.name)); + fields[index] = .{ + .name = d.name[prefix.len..], + .value = @field(c, d.name), + }; + index += 1; + } + } + return @Type(.{ .Enum = .{ + .tag_type = std.meta.Int(.unsigned, std.math.ceilPowerOfTwoAssert(u16, max)), + .fields = &fields, + .decls = &.{}, + .is_exhaustive = true, + } }); +} + +pub const STATUS = generateEnum("NOTMUCH_STATUS_"); +pub const DATABASE_MODE = generateEnum("NOTMUCH_DATABASE_MODE_"); + +const Error = error{ + BadQuerySyntax, + ClosedDatabase, + DatabaseExists, + DuplicateMessageID, + FailedCryptoContextCreation, + FileError, + FileNotEmail, + Ignored, + IllegalArgument, + MaformedCryptoProtocol, + NoConfig, + NoDatabase, + NoMailRoot, + NotmuchVersion, + NullPointer, + OutOfMemory, + PathError, + ReadOnlyDatabase, + TagTooLong, + UnbalancedAtomic, + UnbalancedFreezeThaw, + UnknownCryptoProtocol, + UnsupportedOperation, + UpgradeRequired, + XapianException, +}; + +fn statusToError(comptime T: type, rc: c.notmuch_status_t, value: T) Error!T { + return switch (@as(STATUS, @enumFromInt(rc))) { + .SUCCESS => value, + .BAD_QUERY_SYNTAX => error.BadQuerySyntax, + .CLOSED_DATABASE => error.ClosedDatabase, + .DATABASE_EXISTS => error.DatabaseExists, + .DUPLICATE_MESSAGE_ID => error.DuplicateMessageID, + .FAILED_CRYPTO_CONTEXT_CREATION => error.FailedCryptoContextCreation, + .FILE_ERROR => error.FileError, + .FILE_NOT_EMAIL => error.FileNotEmail, + .IGNORED => error.Ignored, + .ILLEGAL_ARGUMENT => error.IllegalArgument, + .MALFORMED_CRYPTO_PROTOCOL => error.MaformedCryptoProtocol, + .NO_CONFIG => error.NoConfig, + .NO_DATABASE => error.NoDatabase, + .NO_MAIL_ROOT => error.NoMailRoot, + .NULL_POINTER => error.NullPointer, + .OUT_OF_MEMORY => error.OutOfMemory, + .PATH_ERROR => error.PathError, + .READ_ONLY_DATABASE => error.ReadOnlyDatabase, + .TAG_TOO_LONG => error.TagTooLong, + .UNBALANCED_ATOMIC => error.UnbalancedAtomic, + .UNBALANCED_FREEZE_THAW => error.UnbalancedFreezeThaw, + .UNKNOWN_CRYPTO_PROTOCOL => error.UnknownCryptoProtocol, + .UNSUPPORTED_OPERATION => error.UnsupportedOperation, + .UPGRADE_REQUIRED => error.UpgradeRequired, + .XAPIAN_EXCEPTION => error.XapianException, + }; +} + +pub const Database = struct { + database: ?*c.notmuch_database_t = null, + + pub fn open_with_config( + database_path: ?[*:0]const u8, + mode: DATABASE_MODE, + config_path: ?[:0]const u8, + profile: ?[:0]const u8, + ) Error!Database { + if (!c.LIBNOTMUCH_CHECK_VERSION(5, 6, 0)) { + log.err("need newer notmuch", .{}); + return error.NotmuchVersion; + } + + var database: ?*c.notmuch_database_t = null; + const rc = c.notmuch_database_open_with_config( + if (database_path) |p| p else null, + @intFromEnum(mode), + if (config_path) |p| p else null, + if (profile) |p| p else null, + &database, + null, + ); + return try statusToError(Database, rc, .{ .database = database }); + } + + pub fn close(self: *const Database) void { + _ = c.notmuch_database_close(self.database); + } + + pub fn index_file(self: *const Database, filename: [:0]const u8, indexopts: ?*c.notmuch_indexopts_t) Error!void { + const rc = c.notmuch_database_index_file(self.database, filename, indexopts, null); + return try statusToError(void, rc, {}); + } + + pub fn index_file_get_message(self: *const Database, filename: [:0]const u8, indexopts: ?*c.notmuch_indexopts_t) Error!Message { + var message: ?*c.notmuch_message_t = null; + const rc = c.notmuch_database_index_file(self.database, filename, indexopts, &message); + return statusToError(Message, rc, .{ .duplicate = false, .message = message }) catch |err| switch (err) { + error.DuplicateMessageID => return .{ .duplicate = true, .message = message }, + else => |e| return e, + }; + } + + pub fn find_message_by_filename(self: *const Database, filename: [:0]const u8) Error!Message { + var message: ?*c.notmuch_message_t = null; + const rc = c.notmuch_database_find_message_by_filename(self.database, filename, &message); + return try statusToError(Message, rc, .{ .message = message }); + } + + pub fn remove_message(self: *const Database, filename: [:0]const u8) Error!void { + const rc = c.notmuch_database_remove_message(self.database, filename); + return try statusToError(void, rc, {}); + } +}; + +pub const Message = struct { + duplicate: ?bool = null, + message: ?*c.notmuch_message_t = null, + + pub fn add_tag(self: *const Message, tag: [:0]const u8) Error!void { + const rc = c.notmuch_message_add_tag(self.message, tag); + return try statusToError(void, rc, {}); + } + + pub fn deinit(self: *const Message) void { + _ = c.notmuch_message_destroy(self.message); + } +};