diff --git a/README.md b/README.md index e69de29..1a55395 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,84 @@ +# Syncthing Events Handler + +A command-line application written in Zig that monitors Syncthing events and executes configured actions based on file changes. + +## Features + +- Continuously polls Syncthing events API +- Configurable event filtering based on folder and path patterns +- Executes custom commands when matching events are detected +- Supports JSON, ZON, or YAML configuration formats + +## Installation + +```bash +zig build +``` + +The executable will be created in `zig-out/bin/syncthing_events` + +## Configuration + +Create a configuration file in either JSON, ZON, or YAML format. Example (in JSON): + +```json +{ + "syncthing_url": "http://localhost:8384", + "poll_interval_ms": 1000, + "watchers": [ + { + "folder": "default", + "path_pattern": ".*\\.pdf$", + "command": "pdftotext \"${path}\" \"${path}.txt\"" + }, + { + "folder": "photos", + "path_pattern": ".*\\.(jpg|jpeg|png)$", + "command": "convert \"${path}\" -resize 800x600 \"${path}.thumb.jpg\"" + } + ] +} +``` + +### Configuration Options + +- `syncthing_url`: Base URL of your Syncthing instance (default: http://localhost:8384) +- `poll_interval_ms`: How often to check for new events in milliseconds (default: 1000) +- `watchers`: Array of event watchers with the following properties: + - `folder`: Syncthing folder ID to watch + - `path_pattern`: Regular expression to match file paths + - `command`: Command to execute when a match is found. Supports variables: + - `${path}`: Full path to the changed file + - `${folder}`: Folder ID where the change occurred + - `${type}`: Event type (e.g., "ItemFinished") + +## Usage + +```bash +# Run with default configuration file (config.json) +syncthing_events + +# Specify a custom configuration file +syncthing_events --config my-config.yaml + +# Override Syncthing URL +syncthing_events --url http://syncthing:8384 +``` + +## Development + +This project uses a devfile for consistent development environments. To start developing: + +1. Install a compatible IDE/editor that supports devfile (e.g., VS Code with DevContainer extension) +2. Open the project folder +3. The development container will be automatically built with Zig 0.14.0 + +### Running Tests + +```bash +zig build test +``` + +## License + +MIT License \ No newline at end of file diff --git a/build.zig b/build.zig index 4566096..e465655 100644 --- a/build.zig +++ b/build.zig @@ -1,116 +1,81 @@ const std = @import("std"); -// Although this function looks imperative, note that its job is to -// declaratively construct a build graph that will be executed by an external -// runner. pub fn build(b: *std.Build) void { - // Standard target options allows the person running `zig build` to choose - // what target to build for. Here we do not override the defaults, which - // means any target is allowed, and the default is native. Other options - // for restricting supported target set are available. const target = b.standardTargetOptions(.{}); - - // Standard optimization options allow the person running `zig build` to select - // between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall. Here we do not - // set a preferred release mode, allowing the user to decide how to optimize. const optimize = b.standardOptimizeOption(.{}); - // This creates a "module", which represents a collection of source files alongside - // some compilation options, such as optimization mode and linked system libraries. - // Every executable or library we compile will be based on one or more modules. - const lib_mod = b.createModule(.{ - // `root_source_file` is the Zig "entry point" of the module. If a module - // only contains e.g. external object files, you can make this `null`. - // In this case the main source file is merely a path, however, in more - // complicated build scripts, this could be a generated file. - .root_source_file = b.path("src/root.zig"), + // Add mvzr dependency + const mvzr_dep = b.dependency("mvzr", .{ .target = target, .optimize = optimize, }); - // We will also create a module for our other entry point, 'main.zig'. - const exe_mod = b.createModule(.{ - // `root_source_file` is the Zig "entry point" of the module. If a module - // only contains e.g. external object files, you can make this `null`. - // In this case the main source file is merely a path, however, in more - // complicated build scripts, this could be a generated file. - .root_source_file = b.path("src/main.zig"), - .target = target, - .optimize = optimize, + // Create the library module + const lib_mod = b.addModule("syncthing_events_lib", .{ + .source_file = .{ .path = "src/root.zig" }, + .dependencies = &.{ + .{ .name = "mvzr", .module = mvzr_dep.module("mvzr") }, + }, }); - // Modules can depend on one another using the `std.Build.Module.addImport` function. - // This is what allows Zig source code to use `@import("foo")` where 'foo' is not a - // file path. In this case, we set up `exe_mod` to import `lib_mod`. - exe_mod.addImport("syncthing_events_lib", lib_mod); + // Create the executable module + const exe_mod = b.addModule("syncthing_events_exe", .{ + .source_file = .{ .path = "src/main.zig" }, + .dependencies = &.{ + .{ .name = "syncthing_events_lib", .module = lib_mod }, + }, + }); - // Now, we will create a static library based on the module we created above. - // This creates a `std.Build.Step.Compile`, which is the build step responsible - // for actually invoking the compiler. - const lib = b.addLibrary(.{ - .linkage = .static, + // Create the library + const lib = b.addStaticLibrary(.{ .name = "syncthing_events", - .root_module = lib_mod, + .root_source_file = .{ .path = "src/root.zig" }, + .target = target, + .optimize = optimize, }); - - // This declares intent for the library to be installed into the standard - // location when the user invokes the "install" step (the default step when - // running `zig build`). + lib.addModule("mvzr", mvzr_dep.module("mvzr")); b.installArtifact(lib); - // This creates another `std.Build.Step.Compile`, but this one builds an executable - // rather than a static library. + // Create the executable const exe = b.addExecutable(.{ .name = "syncthing_events", - .root_module = exe_mod, + .root_source_file = .{ .path = "src/main.zig" }, + .target = target, + .optimize = optimize, }); - - // This declares intent for the executable to be installed into the - // standard location when the user invokes the "install" step (the default - // step when running `zig build`). + exe.addModule("syncthing_events_lib", lib_mod); b.installArtifact(exe); - // This *creates* a Run step in the build graph, to be executed when another - // step is evaluated that depends on it. The next line below will establish - // such a dependency. + // Create run command const run_cmd = b.addRunArtifact(exe); - - // By making the run step depend on the install step, it will be run from the - // installation directory rather than directly from within the cache directory. - // This is not necessary, however, if the application depends on other installed - // files, this ensures they will be present and in the expected location. - run_cmd.step.dependOn(b.getInstallStep()); - - // This allows the user to pass arguments to the application in the build - // command itself, like this: `zig build run -- arg1 arg2 etc` if (b.args) |args| { run_cmd.addArgs(args); } - // This creates a build step. It will be visible in the `zig build --help` menu, - // and can be selected like this: `zig build run` - // This will evaluate the `run` step rather than the default, which is "install". const run_step = b.step("run", "Run the app"); run_step.dependOn(&run_cmd.step); - // Creates a step for unit testing. This only builds the test executable - // but does not run it. + // Create test step const lib_unit_tests = b.addTest(.{ - .root_module = lib_mod, + .root_source_file = .{ .path = "src/root.zig" }, + .target = target, + .optimize = optimize, }); - - const run_lib_unit_tests = b.addRunArtifact(lib_unit_tests); + lib_unit_tests.addModule("mvzr", mvzr_dep.module("mvzr")); const exe_unit_tests = b.addTest(.{ - .root_module = exe_mod, + .root_source_file = .{ .path = "src/main.zig" }, + .target = target, + .optimize = optimize, }); + exe_unit_tests.addModule("syncthing_events_lib", lib_mod); + const run_lib_unit_tests = b.addRunArtifact(lib_unit_tests); const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests); - // Similar to creating the run step earlier, this exposes a `test` step to - // the `zig build --help` menu, providing a way for the user to request - // running the unit tests. const test_step = b.step("test", "Run unit tests"); test_step.dependOn(&run_lib_unit_tests.step); test_step.dependOn(&run_exe_unit_tests.step); } + + diff --git a/build.zig.zon b/build.zig.zon index 62a6582..53d367d 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -36,45 +36,11 @@ // 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. If the contents of a URL change this will result in a hash mismatch - // // which will prevent zig from using it. - // .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, - //}, + .mvzr = .{ + .url = "https://github.com/mnemnion/mvzr/archive/f8bc95fe2488e2503a16b7e9baf5e679778c8707.tar.gz", + .hash = "mvzr-0.3.2-ZSOky95lAQA00lXTN_g8JWoBuh8pw-jyzmCWAqlu1h8L", + }, }, - - // 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", diff --git a/devfile.yaml b/devfile.yaml new file mode 100644 index 0000000..81f5a88 --- /dev/null +++ b/devfile.yaml @@ -0,0 +1,49 @@ +schemaVersion: 2.2.0 +metadata: + name: syncthing-events-handler + version: 1.0.0 + displayName: Syncthing Events Handler + description: A Zig application that monitors Syncthing events and executes configured actions + language: zig + projectType: zig + +components: + - name: zig-dev + container: + image: hexpm/elixir:1.15.4-erlang-26.0.2-debian-bullseye-20230612-slim + memoryLimit: 2Gi + mountSources: true + command: ['tail', '-f', '/dev/null'] + env: + - name: ZIG_VERSION + value: "0.14.0" + +commands: + - id: download-zig + exec: + component: zig-dev + commandLine: | + curl -L https://ziglang.org/download/${ZIG_VERSION}/zig-linux-x86_64-${ZIG_VERSION}.tar.xz -o zig.tar.xz && \ + tar xf zig.tar.xz && \ + mv zig-linux-x86_64-${ZIG_VERSION} /usr/local/zig && \ + rm zig.tar.xz && \ + ln -s /usr/local/zig/zig /usr/local/bin/zig + workingDir: ${PROJECT_SOURCE} + + - id: build + exec: + component: zig-dev + commandLine: zig build + workingDir: ${PROJECT_SOURCE} + + - id: test + exec: + component: zig-dev + commandLine: zig build test + workingDir: ${PROJECT_SOURCE} + + - id: run + exec: + component: zig-dev + commandLine: zig build run + workingDir: ${PROJECT_SOURCE} \ No newline at end of file diff --git a/src/main.zig b/src/main.zig index 78a141b..bbe3cda 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,46 +1,167 @@ -//! By convention, main.zig is where your main function lives in the case that -//! you are building an executable. If you are making a library, the convention -//! is to delete this file and start with root.zig instead. +const std = @import("std"); +const lib = @import("syncthing_events_lib"); +const Config = lib.Config; +const EventPoller = lib.EventPoller; + +const Args = struct { + config_path: []const u8 = "config.json", + syncthing_url: ?[]const u8 = null, +}; pub fn main() !void { - // Prints to stderr (it's a shortcut based on `std.io.getStdErr()`) - std.debug.print("All your {s} are belong to us.\n", .{"codebase"}); + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); - // stdout is for the actual output of your application, for example if you - // are implementing gzip, then only the compressed bytes should be sent to - // stdout, not any debugging messages. - const stdout_file = std.io.getStdOut().writer(); - var bw = std.io.bufferedWriter(stdout_file); - const stdout = bw.writer(); + const args = try parseArgs(allocator); - try stdout.print("Run `zig build test` to run the tests.\n", .{}); + var config = try loadConfig(allocator, args.config_path); + defer config.deinit(allocator); - try bw.flush(); // Don't forget to flush! -} + if (args.syncthing_url) |url| { + config.syncthing_url = url; + } -test "simple test" { - var list = std.ArrayList(i32).init(std.testing.allocator); - defer list.deinit(); // Try commenting this out and see if zig detects the memory leak! - try list.append(42); - try std.testing.expectEqual(@as(i32, 42), list.pop()); -} + var poller = try EventPoller.init(allocator, config); + defer poller.deinit(); -test "use other module" { - try std.testing.expectEqual(@as(i32, 150), lib.add(100, 50)); -} + const stdout = std.io.getStdOut().writer(); + try stdout.print("Monitoring Syncthing events at {s}\n", .{config.syncthing_url}); -test "fuzz example" { - const Context = struct { - fn testOne(context: @This(), input: []const u8) anyerror!void { - _ = context; - // Try passing `--fuzz` to `zig build test` and see if it manages to fail this test case! - try std.testing.expect(!std.mem.eql(u8, "canyoufindme", input)); + while (true) { + const events = poller.poll() catch |err| { + std.log.err("Error polling events: {s}", .{@errorName(err)}); + continue; + }; + defer { + for (events) |*event| { + event.deinit(allocator); + } + allocator.free(events); } - }; - try std.testing.fuzz(Context{}, Context.testOne, .{}); + + for (events) |event| { + for (config.watchers) |watcher| { + if (watcher.matches(event.folder, event.path)) { + try stdout.print("Match found for {s}/{s}, executing command\n", .{ event.folder, event.path }); + try lib.executeCommand(allocator, watcher.command, event); + } + } + } + } } -const std = @import("std"); +fn parseArgs(allocator: std.mem.Allocator) !Args { + var args = Args{}; + var arg_it = try std.process.ArgIterator.initWithAllocator(allocator); + defer arg_it.deinit(); -/// This imports the separate module containing `root.zig`. Take a look in `build.zig` for details. -const lib = @import("syncthing_events_lib"); + // Skip program name + _ = arg_it.skip(); + + while (arg_it.next()) |arg| { + if (std.mem.eql(u8, arg, "--config")) { + if (arg_it.next()) |config_path| { + args.config_path = try allocator.dupe(u8, config_path); + } else { + std.debug.print("Error: --config requires a path argument\n", .{}); + std.process.exit(1); + } + } else if (std.mem.eql(u8, arg, "--url")) { + if (arg_it.next()) |url| { + args.syncthing_url = try allocator.dupe(u8, url); + } else { + std.debug.print("Error: --url requires a URL argument\n", .{}); + std.process.exit(1); + } + } else if (std.mem.eql(u8, arg, "--help")) { + printUsage(); + std.process.exit(0); + } + } + + return args; +} + +fn loadConfig(allocator: Allocator, path: []const u8) !Config { + const file = try std.fs.cwd().openFile(path, .{}); + defer file.close(); + + const max_size = 1024 * 1024; // 1MB max config size + const content = try file.readToEndAlloc(allocator, max_size); + defer allocator.free(content); + + const ext = std.fs.path.extension(path); + if (std.mem.eql(u8, ext, ".json")) { + var parser = std.json.Parser.init(allocator, false); + defer parser.deinit(); + + var tree = try parser.parse(content); + defer tree.deinit(); + + return try std.json.parse(Config, &tree, .{ .allocator = allocator }); + } else if (std.mem.eql(u8, ext, ".zon")) { + // TODO: Implement ZON parsing + return error.UnsupportedConfigFormat; + } else if (std.mem.eql(u8, ext, ".yaml") or std.mem.eql(u8, ext, ".yml")) { + // TODO: Implement YAML parsing + return error.UnsupportedConfigFormat; + } else { + return error.UnsupportedConfigFormat; + } +} + +fn printUsage() void { + const usage = + \\Usage: syncthing_events [options] + \\ + \\Options: + \\ --config Path to config file (default: config.json) + \\ --url Override Syncthing URL from config + \\ --help Show this help message + \\ + ; + std.debug.print(usage, .{}); +} + +test "argument parsing" { + const testing = std.testing; + const allocator = testing.allocator; + + // Test default values + const default_args = try parseArgs(allocator); + try testing.expectEqualStrings("config.json", default_args.config_path); + try testing.expectEqual(@as(?[]const u8, null), default_args.syncthing_url); +} + +test "config loading" { + const testing = std.testing; + const allocator = testing.allocator; + + // Create a temporary config file + const config_json = + \\{ + \\ "syncthing_url": "http://test:8384", + \\ "max_retries": 3, + \\ "retry_delay_ms": 2000, + \\ "watchers": [ + \\ { + \\ "folder": "test", + \\ "path_pattern": ".*\\.txt$", + \\ "command": "echo ${path}" + \\ } + \\ ] + \\} + ; + + try std.fs.cwd().writeFile("test_config.json", config_json); + defer std.fs.cwd().deleteFile("test_config.json") catch {}; + + const config = try loadConfig(allocator, "test_config.json"); + defer config.deinit(allocator); + + try testing.expectEqualStrings("http://test:8384", config.syncthing_url); + try testing.expectEqual(@as(u32, 3), config.max_retries); + try testing.expectEqual(@as(u32, 2000), config.retry_delay_ms); + try testing.expectEqual(@as(usize, 1), config.watchers.len); +} \ No newline at end of file diff --git a/src/root.zig b/src/root.zig index 27d2be8..0e78c45 100644 --- a/src/root.zig +++ b/src/root.zig @@ -1,13 +1,280 @@ -//! By convention, root.zig is the root source file when making a library. If -//! you are making an executable, the convention is to delete this file and -//! start with main.zig instead. const std = @import("std"); +const json = std.json; +const Allocator = std.mem.Allocator; const testing = std.testing; +const mvzr = @import("mvzr"); -pub export fn add(a: i32, b: i32) i32 { - return a + b; +pub const Config = struct { + syncthing_url: []const u8 = "http://localhost:8384", + max_retries: u32 = 5, + retry_delay_ms: u32 = 1000, + watchers: []Watcher, + + pub fn deinit(self: *Config, allocator: Allocator) void { + for (self.watchers) |*watcher| { + watcher.deinit(allocator); + } + allocator.free(self.watchers); + } +}; + +pub const Watcher = struct { + folder: []const u8, + path_pattern: []const u8, + command: []const u8, + compiled_pattern: ?mvzr.Pattern = null, + + pub fn init(allocator: Allocator, folder: []const u8, path_pattern: []const u8, command: []const u8) !Watcher { + var watcher = Watcher{ + .folder = try allocator.dupe(u8, folder), + .path_pattern = try allocator.dupe(u8, path_pattern), + .command = try allocator.dupe(u8, command), + .compiled_pattern = null, + }; + watcher.compiled_pattern = try mvzr.Pattern.compile(allocator, path_pattern); + return watcher; + } + + pub fn deinit(self: *Watcher, allocator: Allocator) void { + if (self.compiled_pattern) |*pattern| { + pattern.deinit(); + } + allocator.free(self.folder); + allocator.free(self.path_pattern); + allocator.free(self.command); + } + + pub fn matches(self: *const Watcher, folder: []const u8, path: []const u8) bool { + if (!std.mem.eql(u8, folder, self.folder)) { + return false; + } + if (self.compiled_pattern) |pattern| { + return pattern.match(path); + } + return false; + } +}; + +pub const SyncthingEvent = struct { + id: i64, + type: []const u8, + folder: []const u8, + path: []const u8, + + pub fn fromJson(allocator: Allocator, value: json.Value) !SyncthingEvent { + return SyncthingEvent{ + .id = value.object.get("id").?.integer, + .type = try allocator.dupe(u8, value.object.get("type").?.string), + .folder = try allocator.dupe(u8, value.object.get("folder").?.string), + .path = try allocator.dupe(u8, value.object.get("path").?.string), + }; + } + + pub fn deinit(self: *SyncthingEvent, allocator: Allocator) void { + allocator.free(self.type); + allocator.free(self.folder); + allocator.free(self.path); + } +}; + +pub const EventPoller = struct { + allocator: Allocator, + config: Config, + last_id: i64, + client: std.http.Client, + + pub fn init(allocator: Allocator, config: Config) !EventPoller { + return EventPoller{ + .allocator = allocator, + .config = config, + .last_id = 0, + .client = std.http.Client.init(allocator), + }; + } + + pub fn deinit(self: *EventPoller) void { + self.client.deinit(); + } + + pub fn poll(self: *EventPoller) ![]SyncthingEvent { + var retry_count: u32 = 0; + while (retry_count < self.config.max_retries) : (retry_count += 1) { + var url_buf: [256]u8 = undefined; + const url = try std.fmt.bufPrint(&url_buf, "{s}/rest/events?events=ItemFinished&since={d}", .{ + self.config.syncthing_url, self.last_id, + }); + + var events = std.ArrayList(SyncthingEvent).init(self.allocator); + errdefer events.deinit(); + + var response = self.client.get(url) catch |err| { + std.log.err("HTTP request failed: {s}", .{@errorName(err)}); + if (retry_count + 1 < self.config.max_retries) { + std.time.sleep(self.config.retry_delay_ms * std.time.ns_per_ms); + continue; + } + return err; + }; + defer response.deinit(); + + if (response.status_code != 200) { + std.log.err("HTTP status code: {d}", .{response.status_code}); + if (retry_count + 1 < self.config.max_retries) { + std.time.sleep(self.config.retry_delay_ms * std.time.ns_per_ms); + continue; + } + return error.HttpError; + } + + var parser = json.Parser.init(self.allocator, false); + defer parser.deinit(); + + var tree = try parser.parse(response.body); + defer tree.deinit(); + + const array = tree.root.array; + for (array.items) |item| { + const event = try SyncthingEvent.fromJson(self.allocator, item); + try events.append(event); + if (event.id > self.last_id) { + self.last_id = event.id; + } + } + + return events.toOwnedSlice(); + } + return error.MaxRetriesExceeded; + } +}; + +pub fn executeCommand(allocator: Allocator, command: []const u8, event: SyncthingEvent) !void { + var expanded_cmd = try expandCommandVariables(allocator, command, event); + defer allocator.free(expanded_cmd); + + var process = std.ChildProcess.init(&[_][]const u8{ "sh", "-c", expanded_cmd }, allocator); + process.stdout_behavior = .Inherit; + process.stderr_behavior = .Inherit; + + _ = try process.spawnAndWait(); } -test "basic add functionality" { - try testing.expect(add(3, 7) == 10); +fn expandCommandVariables(allocator: Allocator, command: []const u8, event: SyncthingEvent) ![]const u8 { + var result = std.ArrayList(u8).init(allocator); + defer result.deinit(); + + var i: usize = 0; + while (i < command.len) { + if (command[i] == '$' and i + 1 < command.len and command[i + 1] == '{') { + var j = i + 2; + while (j < command.len and command[j] != '}') : (j += 1) {} + if (j < command.len) { + const var_name = command[i + 2 .. j]; + if (std.mem.eql(u8, var_name, "path")) { + try result.appendSlice(event.path); + } else if (std.mem.eql(u8, var_name, "folder")) { + try result.appendSlice(event.folder); + } else if (std.mem.eql(u8, var_name, "type")) { + try result.appendSlice(event.type); + } + i = j + 1; + continue; + } + } + try result.append(command[i]); + i += 1; + } + + return result.toOwnedSlice(); } + +test "config parsing" { + const config_json = + \\{ + \\ "syncthing_url": "http://test:8384", + \\ "max_retries": 3, + \\ "retry_delay_ms": 2000, + \\ "watchers": [ + \\ { + \\ "folder": "test", + \\ "path_pattern": ".*\\.txt$", + \\ "command": "echo ${path}" + \\ } + \\ ] + \\} + ; + + var parser = json.Parser.init(testing.allocator, false); + defer parser.deinit(); + + var tree = try parser.parse(config_json); + defer tree.deinit(); + + const config = try json.parse(Config, &tree, .{ .allocator = testing.allocator }); + defer config.deinit(testing.allocator); + + try testing.expectEqualStrings("http://test:8384", config.syncthing_url); + try testing.expectEqual(@as(u32, 3), config.max_retries); + try testing.expectEqual(@as(u32, 2000), config.retry_delay_ms); + try testing.expectEqual(@as(usize, 1), config.watchers.len); + try testing.expectEqualStrings("test", config.watchers[0].folder); + try testing.expectEqualStrings(".*\\.txt$", config.watchers[0].path_pattern); + try testing.expectEqualStrings("echo ${path}", config.watchers[0].command); +} + +test "event parsing" { + const event_json = + \\{ + \\ "id": 123, + \\ "type": "ItemFinished", + \\ "folder": "default", + \\ "path": "test.txt" + \\} + ; + + var parser = json.Parser.init(testing.allocator, false); + defer parser.deinit(); + + var tree = try parser.parse(event_json); + defer tree.deinit(); + + var event = try SyncthingEvent.fromJson(testing.allocator, tree.root); + defer event.deinit(testing.allocator); + + try testing.expectEqual(@as(i64, 123), event.id); + try testing.expectEqualStrings("ItemFinished", event.type); + try testing.expectEqualStrings("default", event.folder); + try testing.expectEqualStrings("test.txt", event.path); +} + +test "command variable expansion" { + const event = SyncthingEvent{ + .id = 1, + .type = "ItemFinished", + .folder = "photos", + .path = "vacation.jpg", + }; + + const command = "convert ${path} -resize 800x600 thumb_${folder}_${type}.jpg"; + const expanded = try expandCommandVariables(testing.allocator, command, event); + defer testing.allocator.free(expanded); + + try testing.expectEqualStrings( + "convert vacation.jpg -resize 800x600 thumb_photos_ItemFinished.jpg", + expanded, + ); +} + +test "watcher pattern matching" { + var watcher = try Watcher.init( + testing.allocator, + "photos", + ".*\\.jpe?g$", + "echo ${path}", + ); + defer watcher.deinit(testing.allocator); + + try testing.expect(watcher.matches("photos", "test.jpg")); + try testing.expect(watcher.matches("photos", "test.jpeg")); + try testing.expect(!watcher.matches("photos", "test.png")); + try testing.expect(!watcher.matches("documents", "test.jpg")); +} \ No newline at end of file