add AI generated whatever
I want a cli application written in zig 0.14.0 that continuously polls a syncthing events url (http://localhost:8384/rest/events?events=ItemFinished) by default. Syncthing uses the id in the json output to track the last event, so it will need to handle "since=<id>" in the query string after the first request. For all items returned, it should check all the events to see if they match anything configured by the user (the config file can be json, zon, or yaml format). The configuration must allow specification of the folder and path regular expression. If an event matches one of the configured items, the configuration will specify a system command that will be run synchronously, using stdout and stderr of the process. Tests should be written to verify accuracy of the parsing logic for both configuration and events, and the README must document the application. Also, please create a devfile.yaml for future use.
This commit is contained in:
parent
c2a99972be
commit
a584d3ed46
6 changed files with 605 additions and 153 deletions
84
README.md
84
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
|
113
build.zig
113
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);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -36,45 +36,11 @@
|
|||
// Once all dependencies are fetched, `zig build` no longer requires
|
||||
// internet connectivity.
|
||||
.dependencies = .{
|
||||
// See `zig fetch --save <url>` 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",
|
||||
|
|
49
devfile.yaml
Normal file
49
devfile.yaml
Normal file
|
@ -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}
|
187
src/main.zig
187
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;
|
||||
};
|
||||
try std.testing.fuzz(Context{}, Context.testOne, .{});
|
||||
defer {
|
||||
for (events) |*event| {
|
||||
event.deinit(allocator);
|
||||
}
|
||||
allocator.free(events);
|
||||
}
|
||||
|
||||
const std = @import("std");
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This imports the separate module containing `root.zig`. Take a look in `build.zig` for details.
|
||||
const lib = @import("syncthing_events_lib");
|
||||
fn parseArgs(allocator: std.mem.Allocator) !Args {
|
||||
var args = Args{};
|
||||
var arg_it = try std.process.ArgIterator.initWithAllocator(allocator);
|
||||
defer arg_it.deinit();
|
||||
|
||||
// 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> Path to config file (default: config.json)
|
||||
\\ --url <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);
|
||||
}
|
281
src/root.zig
281
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;
|
||||
}
|
||||
|
||||
test "basic add functionality" {
|
||||
try testing.expect(add(3, 7) == 10);
|
||||
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();
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
Loading…
Add table
Reference in a new issue