Changes
17 changed files (+2516/-4)
-
-
@@ -9,6 +9,7 @@# Zig build system writes cache and build output to these directories. .zig-cache zig-out zig-pkg # Nix writes build output to this too generic named directory. result
-
-
apps/cli/.gitignore (new)
-
@@ -0,0 +1,10 @@# Copyright 2026 Shota FUJI # # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at https://mozilla.org/MPL/2.0/. # # SPDX-License-Identifier: MPL-2.0 # Launching CLI without "-Ddir-env" set, the program creates this directory at cwd. .timetracker
-
-
apps/cli/build.zig (new)
-
@@ -0,0 +1,74 @@// Copyright 2026 Shota FUJI // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. // // SPDX-License-Identifier: MPL-2.0 const std = @import("std"); pub fn build(b: *std.Build) !void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); const dir_env_name = b.option([]const u8, "dir-env", "Environment variable name to use for data directory lookup (e.g., XDG_DATA_HOME)"); const main = main: { const config = b.addOptions(); config.addOption(?[]const u8, "dir_env", dir_env_name); const uuid = b.dependency("uuid", .{ .target = target, .optimize = optimize, }); const main = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("src/main.zig"), .link_libc = true, }); main.addImport("uuid", uuid.module("uuid")); main.addOptions("config", config); break :main main; }; const exe = b.addExecutable(.{ .name = "timetracker", .root_module = main, .use_llvm = false, }); // Default install step ("zig build") { b.installArtifact(exe); } // "zig build run" { const run = b.addRunArtifact(exe); for (b.args orelse &.{}) |arg| { run.addArg(arg); } const step = b.step("run", "Run the application"); step.dependOn(&run.step); } // "zig build test" { const t = b.addTest(.{ .root_module = main, }); const run = b.addRunArtifact(t); const step = b.step("test", "Run tests"); step.dependOn(&run.step); } }
-
-
apps/cli/build.zig.zon (new)
-
@@ -0,0 +1,25 @@// Copyright 2025 Shota FUJI // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. // // SPDX-License-Identifier: MPL-2.0 .{ .name = .timetracker_cli, .version = "0.1.0", .fingerprint = 0x5f2e899175cd699b, .minimum_zig_version = "0.16.0", .dependencies = .{ .uuid = .{ .url = "https://codeberg.org/r4gus/uuid-zig/archive/0.5.0.tar.gz", .hash = "uuid-0.5.0-oOieIQx-AABtc9U3ihv_bf9pZAYoKp4UMsNFHBr-cn-w", }, }, .paths = .{ "build.zig", "build.zig.zon", "src/", }, }
-
-
apps/cli/default.nix (new)
-
@@ -0,0 +1,123 @@# Copyright 2026 Shota FUJI # # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at https://mozilla.org/MPL/2.0/. # # SPDX-License-Identifier: MPL-2.0 { stdenvNoCC, lib, zig_0_16, installShellFiles, }: let zig = zig_0_16; in stdenvNoCC.mkDerivation (finalAttrs: { pname = "timetracker"; version = "0.1.0"; src = with lib.fileset; toSource { root = ./.; fileset = unions [ ./src ./build.zig ./build.zig.zon ]; }; nativeBuildInputs = [ zig installShellFiles ]; zigBuildFlags = if stdenvNoCC.hostPlatform.isLinux then [ "-Ddir-env=XDG_DATA_DIRS" ] else [ ]; zigDeps = zig.fetchDeps { inherit (finalAttrs) pname version; src = with lib.fileset; toSource { root = ./.; fileset = unions [ ./build.zig ./build.zig.zon ]; }; fetchAll = true; hash = "sha256-p5AXWVd7ksJHHKUF7syLEcxaAOu/PWwuG7FAvM2orXA="; }; postConfigure = '' ln -s ${finalAttrs.zigDeps} "$ZIG_GLOBAL_CACHE_DIR/p" ''; postInstall = '' installShellCompletion --fish --cmd ${finalAttrs.pname} <(cat << "EOF" set -l commands list status start stop delete complete -c ${finalAttrs.pname} --no-files complete -c ${finalAttrs.pname} \ -s h -l help \ -d "Show help text" complete -c ${finalAttrs.pname} \ -n "not __fish_seen_subcommand_from $commands" \ -a "status" \ -d "Show a running timer or a last stopped timer" complete -c ${finalAttrs.pname} \ -n "not __fish_seen_subcommand_from $commands" \ -a "list" \ -d "List timers" complete -c ${finalAttrs.pname} \ -n "not __fish_seen_subcommand_from $commands" \ -a "start" \ -d "Start a new timer" complete -c ${finalAttrs.pname} \ -n "not __fish_seen_subcommand_from $commands" \ -a "stop" \ -d "Stop a running timer" complete -c ${finalAttrs.pname} \ -n "not __fish_seen_subcommand_from $commands" \ -a "delete" \ -d "Delete a timer from history" complete -c ${finalAttrs.pname} \ -n "__fish_seen_subcommand_from stop" \ -a "(${finalAttrs.pname} list running -c id,title)" complete -c ${finalAttrs.pname} \ -n "__fish_seen_subcommand_from delete" \ -a "(${finalAttrs.pname} list -c id,title)" complete -c ${finalAttrs.pname} \ -n "__fish_seen_subcommand_from list status" \ -s f -l format --no-files --require-parameter \ -d "Output format" \ -a "tsv jsonl" complete -c ${finalAttrs.pname} \ -n "__fish_seen_subcommand_from list status" \ -s c -l columns --no-files --require-parameter \ -d "Output columns" \ -a "(__fish_append , id title status started stopped)" complete -c ${finalAttrs.pname} \ -n "__fish_seen_subcommand_from list status" \ -s d -l datetime --no-files --require-parameter \ -d "Output datetime format" \ -a "system unix" EOF) ''; })
-
-
apps/cli/src/Options.zig (new)
-
@@ -0,0 +1,42 @@// Copyright 2026 Shota FUJI // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. // // SPDX-License-Identifier: MPL-2.0 const Options = @This(); const std = @import("std"); const config = @import("config"); app_dir: std.Io.Dir, pub const InitError = std.Io.Dir.OpenError; pub fn init(io: std.Io, environ_map: *const std.process.Environ.Map) InitError!Options { const app_dir = app_dir: { if (config.dir_env) |dir_env| { if (environ_map.get(dir_env)) |path| { const base = try std.Io.Dir.cwd().openDir(io, path, .{}); defer base.close(io); errdefer std.log.info("App directory = {s}/timetracker", .{path}); break :app_dir try base.openDir(io, "timetracker", .{}); } } errdefer std.log.info("App directory = $PWD/.timetracker", .{}); break :app_dir try std.Io.Dir.cwd().openDir(io, ".timetracker", .{}); }; return .{ .app_dir = app_dir, }; } pub fn deinit(self: Options, io: std.Io) void { self.app_dir.close(io); }
-
-
-
@@ -0,0 +1,164 @@// Copyright 2026 Shota FUJI // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. // // SPDX-License-Identifier: MPL-2.0 const std = @import("std"); const uuid = @import("uuid"); const Options = @import("../Options.zig"); const errors = @import("../errors.zig"); const events = @import("../events.zig"); const timer = @import("../timer.zig"); pub const Error = errors.CommandLineError || events.Iterator.InitError || std.Io.Reader.LimitedAllocError || events.Envelope.AppendError || events.Manifest.SaveError || timer.AggregateError || std.mem.Allocator.Error || error{TimerNotFound}; fn printUsage(io: std.Io, file: std.Io.File) !void { var buf: [512]u8 = undefined; var writer = file.writer(io, &buf); try writer.interface.writeAll( \\timetracker delete - Delete a timer from history \\ \\[USAGE] \\timetracker delete [OPTIONS] <TITLE-OR-ID> \\ \\[PARAMETERS] \\TITLE-OR-ID Title text or ID of the timer to delete. \\ \\[OPTIONS] \\-h, --help Print this message to stdout and exits. \\ \\ ); try writer.flush(); } pub fn run(args: *std.process.Args.Iterator, gpa: std.mem.Allocator, io: std.Io, opts: Options) Error!void { var maybe_title_or_id: ?[]const u8 = null; defer if (maybe_title_or_id) |slice| gpa.free(slice); while (args.next()) |arg| { if (std.mem.eql(u8, "-h", arg) or std.mem.eql(u8, "--help", arg)) { printUsage(io, std.Io.File.stdout()) catch |err| { std.log.err("Failed to print usage to stdout: {t}", .{err}); return Error.WriteToStdoutFailed; }; return; } if (maybe_title_or_id != null) { std.log.err("You cannot set TITLE-OR-ID more than once", .{}); printUsage(io, std.Io.File.stderr()) catch return Error.WriteToStderrFailed; return Error.InvalidUsage; } maybe_title_or_id = try gpa.dupe(u8, arg); } // Using `maybe_title_or_id` rather than putting this logic inside the `orelse` below // makes freeing the slice simpler. if (maybe_title_or_id == null) { const stdin = std.Io.File.stdin(); if (!(try stdin.isTty(io))) { var read_buf: [128]u8 = undefined; var reader = stdin.reader(io, &read_buf); maybe_title_or_id = reader.interface.allocRemaining(gpa, .limited(256)) catch |err| { std.log.err("Unable to read from stdin: {t}", .{err}); return err; }; } } const title_or_id = if (maybe_title_or_id) |title_or_id| std.mem.trim(u8, title_or_id, " \n") else { std.log.err("TITLE_OR_ID is required", .{}); printUsage(io, std.Io.File.stderr()) catch return Error.WriteToStderrFailed; return Error.InvalidUsage; }; if (title_or_id.len == 0) { std.log.err("TITLE_OR_ID cannot be empty", .{}); printUsage(io, std.Io.File.stderr()) catch return Error.WriteToStderrFailed; return Error.InvalidUsage; } var manifest = try events.Manifest.open(gpa, io, &opts.app_dir); defer manifest.deinit(gpa); const maybe_id = uuid.urn.deserialize(title_or_id) catch null; var iter = try events.Iterator.init(io, &manifest); var timer_iter: timer.TimerIterator = .{ .events_iter = &iter }; defer timer_iter.deinit(gpa); timer_loop: while (try timer_iter.next(gpa, io)) |t| { defer t.deinit(gpa); const timer_id, const timer_title = switch (t) { .stopped => |stopped| .{ stopped.id, stopped.title }, .running => |running| .{ running.id, running.title }, }; const matches = matches: { if (maybe_id) |id| { if (timer_id == id) { break :matches true; } } break :matches std.mem.eql(u8, title_or_id, timer_title); }; if (!matches) { continue :timer_loop; } const event_id = uuid.v4.new(io); const envelope: events.Envelope = .{ .parent = manifest.tail, .id = event_id, .event = .{ .timer_deleted = .{ .event_id = event_id, .timer_id = timer_id, .created_at = std.Io.Clock.real.now(io).toSeconds(), } }, }; envelope.append(gpa, io, &manifest) catch |err| { std.log.err("Failed to save event file: {t}", .{err}); return err; }; manifest.tail = event_id; manifest.save(io) catch |err| { std.log.err("Failed to update manifest file: {t}", .{err}); return err; }; var stderr = std.Io.File.stderr(); defer stderr.close(io); var write_buf: [1024]u8 = undefined; var writer = stderr.writer(io, &write_buf); try writer.interface.print("Deleted \"{s}\"\n", .{timer_title}); try writer.interface.flush(); return; } std.log.err("No timer matches to {s}", .{title_or_id}); return Error.TimerNotFound; }
-
-
-
@@ -0,0 +1,125 @@// Copyright 2026 Shota FUJI // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. // // SPDX-License-Identifier: MPL-2.0 const std = @import("std"); const uuid = @import("uuid"); const Options = @import("../Options.zig"); const errors = @import("../errors.zig"); const events = @import("../events.zig"); const timer = @import("../timer.zig"); const options = @import("./options.zig"); pub const Error = errors.CommandLineError || events.Manifest.OpenError || events.Iterator.InitError || events.Iterator.IterateError || std.mem.Allocator.Error; fn printUsage(io: std.Io, file: std.Io.File) !void { var buf: [512]u8 = undefined; var writer = file.writer(io, &buf); try writer.interface.writeAll( \\timetracker list - Print running and stopped timers \\ \\[USAGE] \\timetracker list [FILTER] [OPTIONS] \\ \\[PARAMETERS] \\FILTER Display timers only matches to this filter. \\ Available values: running, stopped \\ \\[OPTIONS] \\-h, --help Print this message to stdout and exits. \\ \\ ); try options.writePrintOptionsUsage(&writer.interface); try writer.flush(); } pub fn run(args: *std.process.Args.Iterator, gpa: std.mem.Allocator, io: std.Io, opts: Options) Error!void { var filter: ?timer.State = null; var print_options_builder: options.PrintOptionsBuilder = .{}; arg_loop: while (args.next()) |arg| { if (std.mem.eql(u8, "-h", arg) or std.mem.eql(u8, "--help", arg)) { printUsage(io, std.Io.File.stdout()) catch |err| { std.log.err("Failed to print usage to stdout: {t}", .{err}); return Error.WriteToStdoutFailed; }; return; } switch (print_options_builder.handle(arg, args) catch |err| { switch (err) { errors.CommandLineError.InvalidUsage => { printUsage(io, std.Io.File.stderr()) catch return Error.WriteToStderrFailed; return err; }, else => return err, } }) { .handled => continue, .unhandled => {}, } inline for (@typeInfo(timer.State).@"enum".fields) |field| { if (std.mem.eql(u8, field.name, arg)) { if (filter != null) { std.log.err("You cannot set filter more than once", .{}); printUsage(io, std.Io.File.stderr()) catch return Error.WriteToStderrFailed; return Error.InvalidUsage; } filter = @enumFromInt(field.value); continue :arg_loop; } } std.log.err("Unknown option: {s}", .{arg}); return Error.InvalidUsage; } const print_opts = print_options_builder.build(); var stdout = std.Io.File.stdout(); defer stdout.close(io); var write_buf: [1024]u8 = undefined; var writer = stdout.writer(io, &write_buf); var manifest = try events.Manifest.open(gpa, io, &opts.app_dir); defer manifest.deinit(gpa); var iter = try events.Iterator.init(io, &manifest); if (filter == .running) { const result = try timer.aggregate(timer.Aggregation.RunningOnly, gpa, io, &iter); defer result.deinit(gpa); for (result.running) |t| { timer.Timer.print(&.{ .running = t }, &writer.interface, print_opts) catch return Error.WriteToStdoutFailed; } } else { const result = try timer.aggregate(timer.Aggregation.Full, gpa, io, &iter); defer result.deinit(gpa); if (filter == null) { for (result.running) |t| { timer.Timer.print(&.{ .running = t }, &writer.interface, print_opts) catch return Error.WriteToStdoutFailed; } } for (result.stopped) |t| { timer.Timer.print(&.{ .stopped = t }, &writer.interface, print_opts) catch return Error.WriteToStdoutFailed; } } writer.flush() catch return Error.WriteToStdoutFailed; }
-
-
-
@@ -0,0 +1,124 @@// Copyright 2026 Shota FUJI // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. // // SPDX-License-Identifier: MPL-2.0 const std = @import("std"); const errors = @import("../errors.zig"); const timer = @import("../timer.zig"); pub fn writePrintOptionsUsage(writer: *std.Io.Writer) std.Io.Writer.Error!void { try writer.writeAll( \\-f <FORMAT>, --format <FORMAT> \\ Specify output format. \\ Available FORMAT is: \\ * tsv (default) \\ * jsonl \\ \\-c <COLUMNS>, --columns <COLUMNS> \\ Comma-separated columns (fields) to output. \\ This affects column order and visibility. \\ Available COLUMNS are: \\ * id \\ * title \\ * status \\ * started \\ * stopped \\ Every column will be printed if unset. \\ \\-d <DATETIME>, --datetime <DATETIME> \\ Specify datetime output format. \\ Available DATETIME is: \\ * system ... Using system locale and timezone \\ * unix ... UNIX seconds \\ Default value is "system" for TSV and "unix" \\ for "JSONL". \\ ); } pub const PrintOptionsBuilder = struct { order: ?timer.PrintColumn.Order = null, datetime: ?timer.DateTimeFormat = null, format: timer.PrintFormat = .tsv, pub const HandleResult = enum { handled, unhandled, }; pub const HandleError = errors.CommandLineError; pub fn handle(self: *PrintOptionsBuilder, arg: []const u8, args: *std.process.Args.Iterator) HandleError!HandleResult { if (std.mem.eql(u8, "-c", arg) or std.mem.eql(u8, "--columns", arg)) { const value = args.next() orelse { std.log.err("{s} option requires a value", .{arg}); return HandleError.InvalidUsage; }; self.order = timer.PrintColumn.Order.parse(value) catch |err| { std.log.err("{s} is not a valid option value for {s}: {t}", .{ value, arg, err, }); return HandleError.InvalidUsage; }; return .handled; } if (std.mem.eql(u8, "-f", arg) or std.mem.eql(u8, "--format", arg)) { const value = args.next() orelse { std.log.err("{s} option requires a value", .{arg}); return HandleError.InvalidUsage; }; self.format = timer.PrintFormat.parse(value) catch |err| { std.log.err("{s} is not a valid option value for {s}: {t}", .{ value, arg, err, }); return HandleError.InvalidUsage; }; return .handled; } if (std.mem.eql(u8, "-d", arg) or std.mem.eql(u8, "--datetime", arg)) { const value = args.next() orelse { std.log.err("{s} option requires a value", .{arg}); return HandleError.InvalidUsage; }; self.datetime = timer.DateTimeFormat.parse(value) catch |err| { std.log.err("{s} is not a valid option value for {s}: {t}", .{ value, arg, err, }); return HandleError.InvalidUsage; }; return .handled; } return .unhandled; } pub fn build(self: *const PrintOptionsBuilder) timer.Timer.PrintOptions { return .{ .format = self.format, .datetime = self.datetime orelse switch (self.format) { .tsv => .system, .jsonl => .unix, }, .order = self.order orelse .fixed(&.{ .id, .status, .title, .started, .stopped }), }; } };
-
-
-
@@ -0,0 +1,123 @@// Copyright 2026 Shota FUJI // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. // // SPDX-License-Identifier: MPL-2.0 const std = @import("std"); const uuid = @import("uuid"); const Options = @import("../Options.zig"); const errors = @import("../errors.zig"); const events = @import("../events.zig"); pub const Error = errors.CommandLineError || events.Iterator.InitError || std.Io.Reader.LimitedAllocError || events.Envelope.AppendError || events.Manifest.SaveError || std.mem.Allocator.Error; fn printUsage(io: std.Io, file: std.Io.File) !void { var buf: [512]u8 = undefined; var writer = file.writer(io, &buf); try writer.interface.writeAll( \\timetracker start - Start a new timer \\ \\[USAGE] \\timetracker start [OPTIONS] <TITLE> \\ \\[PARAMETERS] \\TITLE Title text of the new timer. \\ \\[OPTIONS] \\-h, --help Print this message to stdout and exits. \\ \\ ); try writer.flush(); } pub fn run(args: *std.process.Args.Iterator, gpa: std.mem.Allocator, io: std.Io, opts: Options) Error!void { var maybe_title: ?[]const u8 = null; defer if (maybe_title) |slice| gpa.free(slice); while (args.next()) |arg| { if (std.mem.eql(u8, "-h", arg) or std.mem.eql(u8, "--help", arg)) { printUsage(io, std.Io.File.stdout()) catch |err| { std.log.err("Failed to print usage to stdout: {t}", .{err}); return Error.WriteToStdoutFailed; }; return; } if (maybe_title != null) { std.log.err("You cannot set title more than once", .{}); printUsage(io, std.Io.File.stderr()) catch return Error.WriteToStderrFailed; return Error.InvalidUsage; } maybe_title = try gpa.dupe(u8, arg); } // Using `maybe_title` rather than putting this logic inside the `orelse` below // makes freeing the slice simpler. if (maybe_title == null) { const stdin = std.Io.File.stdin(); if (!(try stdin.isTty(io))) { var read_buf: [128]u8 = undefined; var reader = stdin.reader(io, &read_buf); maybe_title = reader.interface.allocRemaining(gpa, .limited(256)) catch |err| { std.log.err("Unable to read from stdin: {t}", .{err}); return err; }; } } const title = maybe_title orelse { std.log.err("Title is required", .{}); printUsage(io, std.Io.File.stderr()) catch return Error.WriteToStderrFailed; return Error.InvalidUsage; }; var manifest = try events.Manifest.open(gpa, io, &opts.app_dir); defer manifest.deinit(gpa); const event_id = uuid.v4.new(io); const envelope: events.Envelope = .{ .parent = manifest.tail, .id = event_id, .event = .{ .timer_started = .{ .event_id = event_id, .timer_id = uuid.v4.new(io), .created_at = std.Io.Clock.real.now(io).toSeconds(), .title = title, } }, }; envelope.append(gpa, io, &manifest) catch |err| { std.log.err("Failed to save event file: {t}", .{err}); return err; }; manifest.tail = event_id; manifest.save(io) catch |err| { std.log.err("Failed to update manifest file: {t}", .{err}); return err; }; var stderr = std.Io.File.stderr(); defer stderr.close(io); var write_buf: [1024]u8 = undefined; var writer = stderr.writer(io, &write_buf); try writer.interface.print("Created \"{s}\"\n", .{title}); try writer.interface.flush(); }
-
-
-
@@ -0,0 +1,118 @@// Copyright 2026 Shota FUJI // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. // // SPDX-License-Identifier: MPL-2.0 const std = @import("std"); const uuid = @import("uuid"); const Options = @import("../Options.zig"); const errors = @import("../errors.zig"); const events = @import("../events.zig"); const timer = @import("../timer.zig"); const options = @import("./options.zig"); const Error = errors.CommandLineError || events.Iterator.InitError || events.Iterator.IterateError || timer.FindError || std.mem.Allocator.Error || error{ TimerNotFound, }; fn printUsage(io: std.Io, file: std.Io.File) !void { var buf: [1024]u8 = undefined; var writer = file.writer(io, &buf); try writer.interface.writeAll( \\timetracker status - Print current status of a timer \\ \\[USAGE] \\timetracker status [ID] [OPTIONS] \\ \\[PARAMETERS] \\ID An ID of the timer to print. If ID is not set, \\ this command prints the most recent updated timer. \\ \\[OPTIONS] \\-h, --help Print this message to stdout and exits. \\ \\ ); try options.writePrintOptionsUsage(&writer.interface); try writer.flush(); } pub fn run(args: *std.process.Args.Iterator, gpa: std.mem.Allocator, io: std.Io, opts: Options) Error!void { var maybe_id: ?[]const u8 = null; defer if (maybe_id) |slice| gpa.free(slice); var print_options_builder: options.PrintOptionsBuilder = .{}; while (args.next()) |arg| { if (std.mem.eql(u8, "-h", arg) or std.mem.eql(u8, "--help", arg)) { printUsage(io, std.Io.File.stdout()) catch |err| { std.log.err("Failed to print usage to stdout: {t}", .{err}); return Error.WriteToStdoutFailed; }; return; } switch (print_options_builder.handle(arg, args) catch |err| { switch (err) { errors.CommandLineError.InvalidUsage => { printUsage(io, std.Io.File.stderr()) catch return Error.WriteToStderrFailed; return err; }, else => return err, } }) { .handled => continue, .unhandled => {}, } if (maybe_id == null) { maybe_id = try gpa.dupe(u8, arg); continue; } else { std.log.err("You can't pass positional parameter more than once (ID_OR_TITLE is already set.)", .{}); printUsage(io, std.Io.File.stderr()) catch return Error.WriteToStderrFailed; return errors.CommandLineError.InvalidUsage; } std.log.err("Unknown option: {s}", .{arg}); return Error.InvalidUsage; } const print_opts = print_options_builder.build(); var stdout = std.Io.File.stdout(); defer stdout.close(io); var write_buf: [1024]u8 = undefined; var writer = stdout.writer(io, &write_buf); var manifest = try events.Manifest.open(gpa, io, &opts.app_dir); defer manifest.deinit(gpa); var iter = try events.Iterator.init(io, &manifest); const result = if (maybe_id) |id| try timer.findById(gpa, io, &iter, uuid.urn.deserialize(id) catch |err| { std.log.err("ID is not UUID: {t}", .{err}); printUsage(io, std.Io.File.stderr()) catch return Error.WriteToStderrFailed; return errors.CommandLineError.InvalidUsage; }) else try timer.getLatest(gpa, io, &iter); const found = result orelse { std.log.err("Timer not found.", .{}); return Error.TimerNotFound; }; defer found.deinit(gpa); found.print(&writer.interface, print_opts) catch return Error.WriteToStdoutFailed; writer.flush() catch return Error.WriteToStdoutFailed; }
-
-
-
@@ -0,0 +1,157 @@// Copyright 2026 Shota FUJI // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. // // SPDX-License-Identifier: MPL-2.0 const std = @import("std"); const uuid = @import("uuid"); const Options = @import("../Options.zig"); const errors = @import("../errors.zig"); const events = @import("../events.zig"); const timer = @import("../timer.zig"); pub const Error = errors.CommandLineError || events.Iterator.InitError || std.Io.Reader.LimitedAllocError || events.Envelope.AppendError || events.Manifest.SaveError || timer.AggregateError || std.mem.Allocator.Error || error{TimerNotFound}; fn printUsage(io: std.Io, file: std.Io.File) !void { var buf: [512]u8 = undefined; var writer = file.writer(io, &buf); try writer.interface.writeAll( \\timetracker stop - Stop a timer \\ \\[USAGE] \\timetracker stop [OPTIONS] <TITLE-OR-ID> \\ \\[PARAMETERS] \\TITLE-OR-ID Title text or ID of the timer to stop. \\ \\[OPTIONS] \\-h, --help Print this message to stdout and exits. \\ \\ ); try writer.flush(); } pub fn run(args: *std.process.Args.Iterator, gpa: std.mem.Allocator, io: std.Io, opts: Options) Error!void { var maybe_title_or_id: ?[]const u8 = null; defer if (maybe_title_or_id) |slice| gpa.free(slice); while (args.next()) |arg| { if (std.mem.eql(u8, "-h", arg) or std.mem.eql(u8, "--help", arg)) { printUsage(io, std.Io.File.stdout()) catch |err| { std.log.err("Failed to print usage to stdout: {t}", .{err}); return Error.WriteToStdoutFailed; }; return; } if (maybe_title_or_id != null) { std.log.err("You cannot set TITLE-OR-ID more than once", .{}); printUsage(io, std.Io.File.stderr()) catch return Error.WriteToStderrFailed; return Error.InvalidUsage; } maybe_title_or_id = try gpa.dupe(u8, arg); } // Using `maybe_title_or_id` rather than putting this logic inside the `orelse` below // makes freeing the slice simpler. if (maybe_title_or_id == null) { const stdin = std.Io.File.stdin(); if (!(try stdin.isTty(io))) { var read_buf: [128]u8 = undefined; var reader = stdin.reader(io, &read_buf); maybe_title_or_id = reader.interface.allocRemaining(gpa, .limited(256)) catch |err| { std.log.err("Unable to read from stdin: {t}", .{err}); return err; }; } } const title_or_id = maybe_title_or_id orelse { std.log.err("TITLE_OR_ID is required", .{}); printUsage(io, std.Io.File.stderr()) catch return Error.WriteToStderrFailed; return Error.InvalidUsage; }; var manifest = try events.Manifest.open(gpa, io, &opts.app_dir); defer manifest.deinit(gpa); const maybe_id = uuid.urn.deserialize(title_or_id) catch null; var iter = try events.Iterator.init(io, &manifest); var timer_iter: timer.TimerIterator = .{ .events_iter = &iter }; defer timer_iter.deinit(gpa); while (try timer_iter.next(gpa, io)) |t| { defer t.deinit(gpa); switch (t) { .stopped => break, .running => |running| { const matches = matches: { if (maybe_id) |id| { if (running.id == id) { break :matches true; } } break :matches std.mem.eql(u8, title_or_id, running.title); }; if (!matches) { break; } const event_id = uuid.v4.new(io); const envelope: events.Envelope = .{ .parent = manifest.tail, .id = event_id, .event = .{ .timer_stopped = .{ .event_id = event_id, .timer_id = running.id, .created_at = std.Io.Clock.real.now(io).toSeconds(), } }, }; envelope.append(gpa, io, &manifest) catch |err| { std.log.err("Failed to save event file: {t}", .{err}); return err; }; manifest.tail = event_id; manifest.save(io) catch |err| { std.log.err("Failed to update manifest file: {t}", .{err}); return err; }; var stderr = std.Io.File.stderr(); defer stderr.close(io); var write_buf: [1024]u8 = undefined; var writer = stderr.writer(io, &write_buf); try writer.interface.print("Stopped \"{s}\"\n", .{running.title}); try writer.interface.flush(); return; }, } } std.log.err("No running timer matches to {s}", .{title_or_id}); return Error.TimerNotFound; }
-
-
apps/cli/src/errors.zig (new)
-
@@ -0,0 +1,15 @@// Copyright 2026 Shota FUJI // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. // // SPDX-License-Identifier: MPL-2.0 //! Errors shared across files. pub const CommandLineError = error{ WriteToStdoutFailed, WriteToStderrFailed, InvalidUsage, };
-
-
apps/cli/src/events.zig (new)
-
@@ -0,0 +1,478 @@// Copyright 2026 Shota FUJI // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. // // SPDX-License-Identifier: MPL-2.0 const std = @import("std"); const uuid = @import("uuid"); pub const EventType = enum(u8) { timer_deleted = 0, timer_started = 1, timer_stopped = 2, timer_title_changed = 3, }; // uuid package does not define error sets. const UrnParseError = error{ InvalidHexChar, MalformedUrn, }; const ParseError = std.mem.Allocator.Error || std.json.ParseError(std.json.Scanner) || std.json.ParseError(std.json.Reader) || UrnParseError; pub const Manifest = struct { version: u64 = 1, tail: ?uuid.Uuid = null, events_dir: []const u8 = "events", app_dir: *const std.Io.Dir, const Json = struct { version: u64 = 1, tail: ?[]const u8 = null, @"events-dir": []const u8, }; fn openFile(io: std.Io, app_dir: std.Io.Dir, opts: std.Io.File.OpenFlags) std.Io.File.OpenError!std.Io.File { return app_dir.openFile(io, "timetracker.manifest.json", opts) catch |err| { std.log.err("Failed to open manifest file: {t}", .{err}); return err; }; } fn openEventsDir(self: *const Manifest, io: std.Io) std.Io.Dir.OpenError!std.Io.Dir { return self.app_dir.openDir(io, self.events_dir, .{ .iterate = true }); } pub const OpenError = std.Io.File.OpenError || std.Io.Dir.OpenError || std.mem.Allocator.Error || UrnParseError || error{ MalformedManifest, }; pub fn open(gpa: std.mem.Allocator, io: std.Io, app_dir: *const std.Io.Dir) OpenError!Manifest { const manifest_file = try openFile(io, app_dir.*, .{}); defer manifest_file.close(io); var manifest_read_buf: [1024]u8 = undefined; var manifest_file_reader = manifest_file.reader(io, &manifest_read_buf); var manifest_reader = std.json.Reader.init(gpa, &manifest_file_reader.interface); defer manifest_reader.deinit(); const manifest = std.json.parseFromTokenSource( Json, gpa, &manifest_reader, .{ .allocate = .alloc_if_needed }, ) catch |err| { std.log.err("Failed to parse manifest file: {t}", .{err}); return OpenError.MalformedManifest; }; defer manifest.deinit(); if (manifest.value.version != 1) { std.log.err("Unsupported manifest version: {d}", .{manifest.value.version}); return OpenError.MalformedManifest; } const tail = if (manifest.value.tail) |t| try uuid.urn.deserialize(t) else null; const events_dir = try gpa.dupe(u8, manifest.value.@"events-dir"); errdefer gpa.free(events_dir); return .{ .version = manifest.value.version, .tail = tail, .events_dir = events_dir, .app_dir = app_dir, }; } pub const SaveError = std.Io.File.OpenError || std.Io.Writer.Error; pub fn save(self: *const Manifest, io: std.Io) SaveError!void { const manifest_file = try openFile(io, self.app_dir.*, .{ .mode = .write_only }); defer manifest_file.close(io); var write_buf: [1024]u8 = undefined; var writer = manifest_file.writer(io, &write_buf); const tail: ?[]const u8 = if (self.tail) |t| &uuid.urn.serialize(t) else null; var json_formatter = std.json.fmt(Json{ .version = self.version, .tail = tail, .@"events-dir" = self.events_dir, }, .{ .emit_null_optional_fields = false }); try json_formatter.format(&writer.interface); try writer.interface.flush(); } /// `gpa` must be same to the one passed to `open`. pub fn deinit(self: *Manifest, gpa: std.mem.Allocator) void { gpa.free(self.events_dir); } }; pub const Envelope = struct { parent: ?uuid.Uuid = null, id: uuid.Uuid, event: Event, const Json = struct { parent: ?[]const u8 = null, id: []const u8, @"event-type": @typeInfo(EventType).@"enum".tag_type = @intFromEnum(EventType.timer_deleted), @"event-data": []const u8, }; fn init(allocator: std.mem.Allocator, reader: *std.Io.Reader) ParseError!Envelope { var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); var source = std.json.Reader.init(arena.allocator(), reader); defer source.deinit(); const envelope = try std.json.parseFromTokenSourceLeaky(Envelope.Json, arena.allocator(), &source, .{ .allocate = .alloc_if_needed, .ignore_unknown_fields = true, }); return .{ .parent = if (envelope.parent) |parent| try uuid.urn.deserialize(parent) else null, .id = try uuid.urn.deserialize(envelope.id), .event = try Event.init(allocator, arena.allocator(), @enumFromInt(envelope.@"event-type"), envelope.@"event-data"), }; } fn writeJson(self: *const Envelope, gpa: std.mem.Allocator, writer: *std.Io.Writer) std.Io.Writer.Error!void { var event_writer = std.Io.Writer.Allocating.init(gpa); defer event_writer.deinit(); const event_fmt = std.json.fmt(self.event, .{ .emit_null_optional_fields = false }); try event_fmt.format(&event_writer.writer); const envelope_fmt = std.json.fmt(Json{ .parent = if (self.parent) |p| &uuid.urn.serialize(p) else null, .id = &uuid.urn.serialize(self.id), .@"event-type" = @intFromEnum(self.event), .@"event-data" = event_writer.written(), }, .{ .emit_null_optional_fields = false }); try envelope_fmt.format(writer); } pub const AppendError = std.Io.File.OpenError || std.Io.Writer.Error || std.mem.Allocator.Error; pub fn append(self: *const Envelope, gpa: std.mem.Allocator, io: std.Io, manifest: *Manifest) AppendError!void { const filename = try std.fmt.allocPrint(gpa, "{s}.json", .{uuid.urn.serialize(self.id)}); defer gpa.free(filename); var events_dir = try manifest.openEventsDir(io); defer events_dir.close(io); var file = try events_dir.createFile(io, filename, .{}); defer file.close(io); var write_buf: [1024]u8 = undefined; var writer = file.writer(io, &write_buf); try self.writeJson(gpa, &writer.interface); try writer.interface.flush(); } }; test Envelope { const src: Envelope = .{ .id = 0xa84841db47f8ac9b2043d152f7089191, .event = .{ .timer_started = .{ // 919108f7-52d1-4320-9bac-f847db4148a8 .event_id = 0xa84841db47f8ac9b2043d152f7089191, .created_at = 1645568542, // 30e415df-356d-4481-a66f-4a3ab06dadde .timer_id = 0x30e415df356d4481a66f4a3ab06dadde, .title = "Sample Timer", }, }, }; var writer = std.Io.Writer.Allocating.init(std.testing.allocator); defer writer.deinit(); try src.writeJson(std.testing.allocator, &writer.writer); var reader = std.Io.Reader.fixed(writer.written()); const parsed = try Envelope.init(std.testing.allocator, &reader); defer parsed.event.deinit(std.testing.allocator); try std.testing.expectEqualDeep(src, parsed); } pub const Iterator = struct { events_dir: std.Io.Dir, cursor: ?uuid.Uuid, pub const InitError = Manifest.OpenError; /// Caller has to call `deinit` after use. pub fn init(io: std.Io, manifest: *const Manifest) InitError!Iterator { return .{ .events_dir = try manifest.openEventsDir(io), .cursor = manifest.tail, }; } pub const IterateError = std.Io.File.OpenError || std.mem.Allocator.Error || ParseError; /// Caller must call `deinit` on the returned event after use. pub fn next(self: *Iterator, gpa: std.mem.Allocator, io: std.Io) IterateError!?Event { const cursor = self.cursor orelse return null; const filename = try std.fmt.allocPrint(gpa, "{s}.json", .{uuid.urn.serialize(cursor)}); defer gpa.free(filename); const file = self.events_dir.openFile(io, filename, .{}) catch |err| { std.log.err("Unable to open event file {s}.json: {t}", .{ filename, err }); return err; }; defer file.close(io); var read_buf: [1024]u8 = undefined; var reader = file.reader(io, &read_buf); const envelope = try Envelope.init(gpa, &reader.interface); self.cursor = envelope.parent; return envelope.event; } }; pub const TimerDeleted = struct { event_id: uuid.Uuid, created_at: i64, timer_id: uuid.Uuid, const Json = struct { @"event-id": []const u8, @"created-at": i64, @"timer-id": []const u8, }; fn init(json: Json) UrnParseError!TimerDeleted { return .{ .event_id = try uuid.urn.deserialize(json.@"event-id"), .created_at = json.@"created-at", .timer_id = try uuid.urn.deserialize(json.@"timer-id"), }; } pub fn jsonStringify(value: @This(), jws: *std.json.Stringify) !void { try jws.beginObject(); try jws.objectField("event-id"); try jws.write(uuid.urn.serialize(value.event_id)); try jws.objectField("created-at"); try jws.write(value.created_at); try jws.objectField("timer-id"); try jws.write(uuid.urn.serialize(value.timer_id)); try jws.endObject(); } }; pub const TimerStarted = struct { event_id: uuid.Uuid, created_at: i64, timer_id: uuid.Uuid, title: []const u8, const Json = struct { @"event-id": []const u8, @"created-at": i64, @"timer-id": []const u8, title: []const u8, }; fn init(allocator: std.mem.Allocator, json: Json) (std.mem.Allocator.Error || UrnParseError)!TimerStarted { return .{ .event_id = try uuid.urn.deserialize(json.@"event-id"), .created_at = json.@"created-at", .timer_id = try uuid.urn.deserialize(json.@"timer-id"), .title = try allocator.dupe(u8, json.title), }; } /// `allocator` should be the same one passed to `init`. fn deinit(self: TimerStarted, allocator: std.mem.Allocator) void { allocator.free(self.title); } pub fn jsonStringify(value: @This(), jws: *std.json.Stringify) !void { try jws.beginObject(); try jws.objectField("event-id"); try jws.write(uuid.urn.serialize(value.event_id)); try jws.objectField("created-at"); try jws.write(value.created_at); try jws.objectField("timer-id"); try jws.write(uuid.urn.serialize(value.timer_id)); try jws.objectField("title"); try jws.write(value.title); try jws.endObject(); } }; pub const TimerStopped = struct { event_id: uuid.Uuid, created_at: i64, timer_id: uuid.Uuid, const Json = struct { @"event-id": []const u8, @"created-at": i64, @"timer-id": []const u8, }; fn init(json: Json) UrnParseError!TimerStopped { return .{ .event_id = try uuid.urn.deserialize(json.@"event-id"), .created_at = json.@"created-at", .timer_id = try uuid.urn.deserialize(json.@"timer-id"), }; } pub fn jsonStringify(value: @This(), jws: *std.json.Stringify) !void { try jws.beginObject(); try jws.objectField("event-id"); try jws.write(uuid.urn.serialize(value.event_id)); try jws.objectField("created-at"); try jws.write(value.created_at); try jws.objectField("timer-id"); try jws.write(uuid.urn.serialize(value.timer_id)); try jws.endObject(); } }; pub const TimerTitleChanged = struct { event_id: uuid.Uuid, created_at: i64, timer_id: uuid.Uuid, new_title: []const u8, const Json = struct { @"event-id": []const u8, @"created-at": i64, @"timer-id": []const u8, @"new-title": []const u8, }; fn init(allocator: std.mem.Allocator, json: Json) (std.mem.Allocator.Error || UrnParseError)!TimerTitleChanged { return .{ .event_id = try uuid.urn.deserialize(json.@"event-id"), .created_at = json.@"created-at", .timer_id = try uuid.urn.deserialize(json.@"timer-id"), .new_title = try allocator.dupe(u8, json.@"new-title"), }; } /// `allocator` should be the same one passed to `init`. fn deinit(self: TimerTitleChanged, allocator: std.mem.Allocator) void { allocator.free(self.new_title); } pub fn jsonStringify(value: @This(), jws: *std.json.Stringify) !void { try jws.beginObject(); try jws.objectField("event-id"); try jws.write(uuid.urn.serialize(value.event_id)); try jws.objectField("created-at"); try jws.write(value.created_at); try jws.objectField("timer-id"); try jws.write(uuid.urn.serialize(value.timer_id)); try jws.objectField("new-title"); try jws.write(value.new_title); try jws.endObject(); } }; pub const Event = union(EventType) { timer_deleted: TimerDeleted, timer_started: TimerStarted, timer_stopped: TimerStopped, timer_title_changed: TimerTitleChanged, fn init(allocator: std.mem.Allocator, arena: std.mem.Allocator, event_type: EventType, event_data: []const u8) ParseError!Event { return switch (event_type) { EventType.timer_deleted => .{ .timer_deleted = try TimerDeleted.init( try std.json.parseFromSliceLeaky(TimerDeleted.Json, arena, event_data, .{ .allocate = .alloc_if_needed, .ignore_unknown_fields = true, }), ), }, EventType.timer_started => .{ .timer_started = try TimerStarted.init( allocator, try std.json.parseFromSliceLeaky(TimerStarted.Json, arena, event_data, .{ .allocate = .alloc_if_needed, .ignore_unknown_fields = true, }), ), }, EventType.timer_stopped => .{ .timer_stopped = try TimerStopped.init( try std.json.parseFromSliceLeaky(TimerStopped.Json, arena, event_data, .{ .allocate = .alloc_if_needed, .ignore_unknown_fields = true, }), ), }, EventType.timer_title_changed => .{ .timer_title_changed = try TimerTitleChanged.init( allocator, try std.json.parseFromSliceLeaky(TimerTitleChanged.Json, arena, event_data, .{ .allocate = .alloc_if_needed, .ignore_unknown_fields = true, }), ), }, }; } pub fn deinit(self: Event, allocator: std.mem.Allocator) void { switch (self) { .timer_started => |ev| ev.deinit(allocator), .timer_title_changed => |ev| ev.deinit(allocator), else => {}, } } pub fn jsonStringify(value: Event, jws: *std.json.Stringify) !void { switch (value) { .timer_deleted => |ev| try jws.write(ev), .timer_started => |ev| try jws.write(ev), .timer_stopped => |ev| try jws.write(ev), .timer_title_changed => |ev| try jws.write(ev), } } };
-
-
apps/cli/src/main.zig (new)
-
@@ -0,0 +1,121 @@// Copyright 2026 Shota FUJI // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. // // SPDX-License-Identifier: MPL-2.0 const std = @import("std"); const config = @import("config"); const Options = @import("Options.zig"); const delete = @import("./commands/delete.zig"); const list = @import("./commands/list.zig"); const start = @import("./commands/start.zig"); const status = @import("./commands/status.zig"); const stop = @import("./commands/stop.zig"); const errors = @import("./errors.zig"); const CommandLineError = errors.CommandLineError; test { _ = @import("./events.zig"); _ = @import("./timer.zig"); } fn printUsage(io: std.Io, file: std.Io.File) !void { var buf: [512]u8 = undefined; var writer = file.writer(io, &buf); try writer.interface.writeAll( \\timetracker - Timetracking CLI \\ \\[USAGE] \\# CLI \\timetracker [OPTIONS] <COMMAND> \\ \\[COMMANDS] \\list List timers. \\status Show a timer. \\start Start a new timer. \\stop Stop a running timer. \\delete Delete a timer from history. \\ \\[OPTIONS] \\-h, --help Print this message to stdout and exits. \\ ); try writer.flush(); } fn run(init: *const std.process.Init) !void { const options = Options.init(init.io, init.environ_map) catch |err| { std.log.err("Cannot open application directory: {t}", .{err}); return error.CannotOpenDataDirectory; }; defer options.deinit(init.io); var args = try init.minimal.args.iterateAllocator(init.gpa); defer args.deinit(); // Skip the program name. _ = args.skip(); while (args.next()) |arg| { if (std.mem.eql(u8, "-h", arg) or std.mem.eql(u8, "--help", arg)) { printUsage(init.io, std.Io.File.stdout()) catch |err| { std.log.err("Failed to print usage to stdout: {t}", .{err}); return CommandLineError.WriteToStdoutFailed; }; return; } if (std.mem.eql(u8, "list", arg)) { try list.run(&args, init.gpa, init.io, options); return; } if (std.mem.eql(u8, "status", arg)) { try status.run(&args, init.gpa, init.io, options); return; } if (std.mem.eql(u8, "start", arg)) { try start.run(&args, init.gpa, init.io, options); return; } if (std.mem.eql(u8, "stop", arg)) { try stop.run(&args, init.gpa, init.io, options); return; } if (std.mem.eql(u8, "delete", arg)) { try delete.run(&args, init.gpa, init.io, options); return; } std.log.err("Unknown option: {s}", .{arg}); printUsage(init.io, std.Io.File.stderr()) catch return CommandLineError.WriteToStderrFailed; return CommandLineError.InvalidUsage; } std.log.err("COMMAND is required.", .{}); printUsage(init.io, std.Io.File.stderr()) catch return CommandLineError.WriteToStderrFailed; return CommandLineError.InvalidUsage; } pub fn main(init: std.process.Init) u8 { run(&init) catch |err| return switch (err) { CommandLineError.InvalidUsage => 3, error.TimerNotFound => 4, CommandLineError.WriteToStdoutFailed, CommandLineError.WriteToStderrFailed => 5, error.CannotOpenDataDirectory => 6, error.OutOfMemory => 9, else => 1, }; return 0; }
-
-
apps/cli/src/timer.zig (new)
-
@@ -0,0 +1,813 @@// Copyright 2026 Shota FUJI // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. // // SPDX-License-Identifier: MPL-2.0 const std = @import("std"); const uuid = @import("uuid"); const events = @import("./events.zig"); const libc = @cImport(@cInclude("time.h")); pub const RunningTimer = struct { id: uuid.Uuid, title: []const u8, started_at: i64, pub fn deinit(self: RunningTimer, allocator: std.mem.Allocator) void { allocator.free(self.title); } }; pub const StoppedTimer = struct { id: uuid.Uuid, title: []const u8, started_at: i64, stopped_at: i64, pub fn deinit(self: StoppedTimer, allocator: std.mem.Allocator) void { allocator.free(self.title); } }; pub const PrintColumn = enum { id, title, status, started, stopped, const size = @typeInfo(PrintColumn).@"enum".fields.len; pub const Order = struct { len: usize, columns: [size]PrintColumn, pub const ParseError = error{ UnknownColumn, TooManyColumns, DuplicatedColumns, }; pub fn parse(str: []const u8) ParseError!Order { var len: usize = 0; var cols: [size]PrintColumn = undefined; var iter = std.mem.splitScalar(u8, str, ','); token_loop: while (iter.next()) |token| { if (len >= size) { return ParseError.TooManyColumns; } inline for (@typeInfo(PrintColumn).@"enum".fields) |field| { if (std.mem.eql(u8, field.name, token)) { const e: PrintColumn = @enumFromInt(field.value); for (cols[0..len]) |c| if (c == e) { return ParseError.DuplicatedColumns; }; cols[len] = e; len += 1; continue :token_loop; } } return ParseError.UnknownColumn; } return .{ .len = len, .columns = cols }; } test parse { { const actual = try parse("id"); const expected: []const PrintColumn = &.{.id}; try std.testing.expectEqualSlices(PrintColumn, expected, actual.toSlice()); } { const actual = try parse("status,started,stopped,title"); const expected: []const PrintColumn = &.{ .status, .started, .stopped, .title }; try std.testing.expectEqualSlices(PrintColumn, expected, actual.toSlice()); } { const actual = parse("version,status,started,stopped,title"); try std.testing.expectError(ParseError.UnknownColumn, actual); } { const actual = parse("status, started, stopped, title"); try std.testing.expectError(ParseError.UnknownColumn, actual); } { const actual = parse("id,status,status"); try std.testing.expectError(ParseError.DuplicatedColumns, actual); } { const actual = parse("id,status,title,started,stopped,id"); try std.testing.expectError(ParseError.TooManyColumns, actual); } } pub fn fixed(comptime columns: []const PrintColumn) Order { comptime { if (columns.len > size) { @compileError("\"columns\" length cannot exceed the number of PrintColumn fields"); } } var cols: [size]PrintColumn = undefined; for (columns, 0..) |c, i| { cols[i] = c; } return .{ .len = columns.len, .columns = cols, }; } pub fn toSlice(self: *const Order) []const PrintColumn { return self.columns[0..self.len]; } test fixed { const t0 = fixed(&.{ .id, .started, .status, .title }); try std.testing.expectEqualSlices(PrintColumn, &.{ .id, .started, .status, .title }, t0.toSlice()); const t1 = fixed(&.{.stopped}); try std.testing.expectEqualSlices(PrintColumn, &.{.stopped}, t1.toSlice()); } }; }; test { _ = PrintColumn.Order; } pub const PrintFormat = enum { tsv, jsonl, pub const ParseError = error{ UnknownFormat, }; pub fn parse(str: []const u8) ParseError!PrintFormat { inline for (@typeInfo(PrintFormat).@"enum".fields) |field| { if (std.mem.eql(u8, str, field.name)) { return @enumFromInt(field.value); } } return ParseError.UnknownFormat; } }; pub const DateTimeFormat = enum { /// Format using the current locale and timezone. system, /// Unix seconds. unix, pub const ParseError = error{ UnknownFormat, }; pub fn parse(str: []const u8) ParseError!DateTimeFormat { inline for (@typeInfo(DateTimeFormat).@"enum".fields) |field| { if (std.mem.eql(u8, str, field.name)) { return @enumFromInt(field.value); } } return ParseError.UnknownFormat; } pub fn print(self: DateTimeFormat, writer: *std.Io.Writer, unix_sec: i64) std.Io.Writer.Error!void { switch (self) { .unix => try writer.printInt(unix_sec, 10, .lower, .{}), .system => { libc.tzset(); var tm: libc.tm = undefined; _ = libc.localtime_r(&unix_sec, &tm); // Reasonable size, I guess. var buf: [256:0]u8 = undefined; @memset(&buf, 0); const wrote = libc.strftime(&buf, buf.len, "%c", &tm); try writer.writeAll(buf[0..wrote]); }, } } }; test { _ = DateTimeFormat; } pub const State = enum { running, stopped, }; pub const Timer = union(State) { running: RunningTimer, stopped: StoppedTimer, pub fn deinit(self: *const Timer, allocator: std.mem.Allocator) void { switch (self.*) { .running => |t| t.deinit(allocator), .stopped => |t| t.deinit(allocator), } } pub const PrintOptions = struct { format: PrintFormat, order: PrintColumn.Order, datetime: DateTimeFormat, }; pub fn print(self: *const Timer, writer: *std.Io.Writer, opts: PrintOptions) std.Io.Writer.Error!void { switch (opts.format) { .tsv => { for (opts.order.toSlice(), 0..) |column, i| { if (i > 0) { try writer.writeInt(u8, '\t', .native); } switch (column) { .id => try writer.writeAll(&uuid.urn.serialize(switch (self.*) { .running => |t| t.id, .stopped => |t| t.id, })), // User can't input title containing newline or tab character via GUI. // This is fine. .title => try writer.writeAll(switch (self.*) { .running => |t| t.title, .stopped => |t| t.title, }), .status => try writer.writeAll(@tagName(self.*)), .started => try opts.datetime.print(writer, switch (self.*) { .running => |t| t.started_at, .stopped => |t| t.started_at, }), .stopped => switch (self.*) { .running => {}, .stopped => |t| try opts.datetime.print(writer, t.stopped_at), }, } } }, .jsonl => { var stringify: std.json.Stringify = .{ .writer = writer, .options = .{ .emit_null_optional_fields = false, .whitespace = .minified }, }; try stringify.beginObject(); for (opts.order.toSlice()) |column| { switch (column) { .id => { try stringify.objectField("id"); try stringify.write(uuid.urn.serialize(switch (self.*) { .running => |t| t.id, .stopped => |t| t.id, })); }, .title => { try stringify.objectField("title"); try stringify.write(switch (self.*) { .running => |t| t.title, .stopped => |t| t.title, }); }, .status => { try stringify.objectField("status"); try stringify.write(@tagName(self.*)); }, .started => { const started_at = switch (self.*) { .running => |t| t.started_at, .stopped => |t| t.started_at, }; try stringify.objectField("started"); switch (opts.datetime) { .unix => try stringify.write(started_at), .system => { try stringify.beginWriteRaw(); try stringify.writer.writeInt(u8, '"', .native); try opts.datetime.print(writer, started_at); try stringify.writer.writeInt(u8, '"', .native); stringify.endWriteRaw(); }, } }, .stopped => switch (self.*) { .stopped => |t| { try stringify.objectField("stopped"); switch (opts.datetime) { .unix => try stringify.write(t.stopped_at), .system => { try stringify.beginWriteRaw(); try stringify.writer.writeInt(u8, '"', .native); try opts.datetime.print(writer, t.stopped_at); try stringify.writer.writeInt(u8, '"', .native); stringify.endWriteRaw(); }, } }, .running => {}, }, } } try stringify.endObject(); }, } try writer.writeInt(u8, '\n', .native); } test "TSV single" { var alloc = std.Io.Writer.Allocating.init(std.testing.allocator); defer alloc.deinit(); try print(&.{ .running = .{ .id = 0xa84841db47f8ac9b2043d152f7089191, .title = "Foo", .started_at = 1, }, }, &alloc.writer, .{ .datetime = .unix, .format = .tsv, .order = PrintColumn.Order.fixed(&.{.id}), }); try std.testing.expectEqualSlices( u8, "919108f7-52d1-4320-9bac-f847db4148a8\n", alloc.written(), ); } test "TSV running full" { var alloc = std.Io.Writer.Allocating.init(std.testing.allocator); defer alloc.deinit(); try print(&.{ .running = .{ .id = 0xa84841db47f8ac9b2043d152f7089191, .title = "Foo", .started_at = 1, }, }, &alloc.writer, .{ .datetime = .unix, .format = .tsv, .order = PrintColumn.Order.fixed(&.{ .status, .title, .id, .started, .stopped }), }); try std.testing.expectEqualSlices( u8, "running\tFoo\t919108f7-52d1-4320-9bac-f847db4148a8\t1\t\n", alloc.written(), ); } test "TSV stopped" { var alloc = std.Io.Writer.Allocating.init(std.testing.allocator); defer alloc.deinit(); try print(&.{ .stopped = .{ .id = 0xa84841db47f8ac9b2043d152f7089191, .title = "Foo", .started_at = 1, .stopped_at = 2, }, }, &alloc.writer, .{ .datetime = .unix, .format = .tsv, .order = PrintColumn.Order.fixed(&.{ .status, .title, .id, .started, .stopped }), }); try std.testing.expectEqualSlices( u8, "stopped\tFoo\t919108f7-52d1-4320-9bac-f847db4148a8\t1\t2\n", alloc.written(), ); } test "JSONL single" { var alloc = std.Io.Writer.Allocating.init(std.testing.allocator); defer alloc.deinit(); try print(&.{ .running = .{ .id = 0xa84841db47f8ac9b2043d152f7089191, .title = "Foo", .started_at = 1, }, }, &alloc.writer, .{ .datetime = .unix, .format = .jsonl, .order = PrintColumn.Order.fixed(&.{.id}), }); try std.testing.expectEqualSlices( u8, \\{"id":"919108f7-52d1-4320-9bac-f847db4148a8"} \\ , alloc.written(), ); } test "JSONL running full" { var alloc = std.Io.Writer.Allocating.init(std.testing.allocator); defer alloc.deinit(); try print(&.{ .running = .{ .id = 0xa84841db47f8ac9b2043d152f7089191, .title = "Foo", .started_at = 1, }, }, &alloc.writer, .{ .datetime = .unix, .format = .jsonl, .order = PrintColumn.Order.fixed(&.{ .status, .title, .id, .started, .stopped }), }); try std.testing.expectEqualSlices( u8, \\{"status":"running","title":"Foo","id":"919108f7-52d1-4320-9bac-f847db4148a8","started":1} \\ , alloc.written(), ); } test "JSONL stopped" { var alloc = std.Io.Writer.Allocating.init(std.testing.allocator); defer alloc.deinit(); try print(&.{ .stopped = .{ .id = 0xa84841db47f8ac9b2043d152f7089191, .title = "Foo", .started_at = 1, .stopped_at = 2, }, }, &alloc.writer, .{ .datetime = .unix, .format = .jsonl, .order = PrintColumn.Order.fixed(&.{ .status, .title, .id, .started, .stopped }), }); try std.testing.expectEqualSlices( u8, \\{"status":"stopped","title":"Foo","id":"919108f7-52d1-4320-9bac-f847db4148a8","started":1,"stopped":2} \\ , alloc.written(), ); } }; test { _ = Timer; } pub const Aggregation = struct { pub const RunningOnly = struct { running: []RunningTimer, pub fn deinit(self: RunningOnly, allocator: std.mem.Allocator) void { for (self.running) |t| t.deinit(allocator); allocator.free(self.running); } }; pub const Full = struct { running: []RunningTimer, stopped: []StoppedTimer, pub fn deinit(self: Full, allocator: std.mem.Allocator) void { for (self.running) |t| t.deinit(allocator); allocator.free(self.running); for (self.stopped) |t| t.deinit(allocator); allocator.free(self.stopped); } }; }; pub const AggregateError = std.mem.Allocator.Error || events.Iterator.IterateError; /// Caller is responsible for calling `deinit` on the returned struct after use. pub fn aggregate(Type: type, gpa: std.mem.Allocator, io: std.Io, iterator: *events.Iterator) AggregateError!Type { if (Type != Aggregation.RunningOnly and Type != Aggregation.Full) { @compileError("Type parameter of \"aggregate\" must be either one of \"State.RunningOnly\" or \"State.Full\""); } var event_list = std.ArrayList(events.Event).empty; defer event_list.deinit(gpa); defer for (event_list.items) |event| event.deinit(gpa); while (try iterator.next(gpa, io)) |event| { try event_list.append(gpa, event); } var running_timers = std.array_hash_map.Auto(uuid.Uuid, RunningTimer).empty; defer running_timers.deinit(gpa); errdefer for (running_timers.values()) |timer| timer.deinit(gpa); var stopped_timers = if (Type == Aggregation.Full) std.array_hash_map.Auto(uuid.Uuid, StoppedTimer).empty else std.array_hash_map.Auto(void, void).empty; defer if (Type == Aggregation.Full) stopped_timers.deinit(gpa); errdefer if (Type == Aggregation.Full) for (stopped_timers.values()) |timer| timer.deinit(gpa); for (0..event_list.items.len) |i| { const event = event_list.items[event_list.items.len - i - 1]; switch (event) { .timer_started => |started| { try running_timers.put(gpa, started.timer_id, .{ .id = started.timer_id, .title = try gpa.dupe(u8, started.title), .started_at = started.created_at, }); }, .timer_stopped => |stopped| { const found = running_timers.fetchOrderedRemove(stopped.timer_id) orelse { std.log.warn("Detected stopping non-existing timer ID ({s}), skipping", .{uuid.urn.serialize(stopped.timer_id)}); continue; }; if (Type == Aggregation.Full) { try stopped_timers.put(gpa, stopped.timer_id, .{ .id = found.value.id, .title = found.value.title, .started_at = found.value.started_at, .stopped_at = stopped.created_at, }); } else { found.value.deinit(gpa); } }, .timer_deleted => |deleted| { if (running_timers.fetchOrderedRemove(deleted.timer_id)) |timer| { timer.value.deinit(gpa); } if (Type == Aggregation.Full) { if (stopped_timers.fetchOrderedRemove(deleted.timer_id)) |timer| { timer.value.deinit(gpa); } } }, .timer_title_changed => |title_changed| { if (running_timers.getPtr(title_changed.timer_id)) |timer| { gpa.free(timer.title); timer.title = try gpa.dupe(u8, title_changed.new_title); } if (Type == Aggregation.Full) { if (stopped_timers.getPtr(title_changed.timer_id)) |timer| { gpa.free(timer.title); timer.title = try gpa.dupe(u8, title_changed.new_title); } } }, } } if (Type == Aggregation.RunningOnly) { return .{ .running = try gpa.dupe(RunningTimer, running_timers.values()), }; } else { const running = try gpa.dupe(RunningTimer, running_timers.values()); errdefer gpa.free(running); const stopped = try gpa.dupe(StoppedTimer, stopped_timers.values()); errdefer gpa.free(stopped); return .{ .running = running, .stopped = stopped, }; } } pub const FindError = std.mem.Allocator.Error || events.Iterator.IterateError; /// Caller is responsible for calling `deinit` on the returned struct after use. pub fn findById(gpa: std.mem.Allocator, io: std.Io, iterator: *events.Iterator, id: uuid.Uuid) FindError!?Timer { var stopped_at: ?i64 = null; var latest_title: ?[]const u8 = null; errdefer if (latest_title) |str| gpa.free(str); while (try iterator.next(gpa, io)) |event| { defer event.deinit(gpa); switch (event) { .timer_deleted => |deleted| { if (deleted.timer_id == id) { return null; } }, .timer_started => |started| { if (started.timer_id == id) { const title = latest_title orelse try gpa.dupe(u8, started.title); if (stopped_at) |s| { return .{ .stopped = .{ .id = id, .title = title, .started_at = started.created_at, .stopped_at = s, }, }; } else { return .{ .running = .{ .id = id, .title = title, .started_at = started.created_at, }, }; } } }, .timer_stopped => |stopped| { if (stopped.timer_id == id) { stopped_at = stopped.created_at; } }, .timer_title_changed => |changed| { if (changed.timer_id == id and latest_title == null) { latest_title = try gpa.dupe(u8, changed.new_title); } }, } } return null; } pub const TimerIterator = struct { events_iter: *events.Iterator, title_changes: std.hash_map.AutoHashMapUnmanaged(uuid.Uuid, []const u8) = .empty, stopped_timestamps: std.hash_map.AutoHashMapUnmanaged(uuid.Uuid, i64) = .empty, deleted_timer_ids: std.hash_map.AutoHashMapUnmanaged(uuid.Uuid, void) = .empty, pub fn deinit(self: *TimerIterator, gpa: std.mem.Allocator) void { var changes_iter = self.title_changes.iterator(); while (changes_iter.next()) |entry| { gpa.free(entry.value_ptr.*); } self.title_changes.deinit(gpa); self.stopped_timestamps.deinit(gpa); self.deleted_timer_ids.deinit(gpa); } pub const Error = events.Iterator.IterateError || std.mem.Allocator.Error; /// Caller is responsible for calling `deinit` on the returned struct after use. pub fn next(self: *TimerIterator, gpa: std.mem.Allocator, io: std.Io) Error!?Timer { while (try self.events_iter.next(gpa, io)) |event| { defer event.deinit(gpa); switch (event) { .timer_deleted => |deleted| { try self.deleted_timer_ids.put(gpa, deleted.timer_id, {}); }, .timer_stopped => |stopped| if (!self.deleted_timer_ids.contains(stopped.timer_id)) { try self.stopped_timestamps.put(gpa, stopped.timer_id, stopped.created_at); }, .timer_title_changed => |changed| if (!self.deleted_timer_ids.contains(changed.timer_id)) { const new_title = try gpa.dupe(u8, changed.new_title); errdefer gpa.free(new_title); if (try self.title_changes.fetchPut(gpa, changed.timer_id, new_title)) |prev| { gpa.free(prev.value); } }, .timer_started => |started| if (!self.deleted_timer_ids.contains(started.timer_id)) { const stopped_at = if (self.stopped_timestamps.fetchRemove(started.timer_id)) |entry| entry.value else null; const title = if (self.title_changes.fetchRemove(started.timer_id)) |entry| entry.value else try gpa.dupe(u8, started.title); if (stopped_at) |ts| { return .{ .stopped = .{ .id = started.timer_id, .title = title, .started_at = started.created_at, .stopped_at = ts, }, }; } else { return .{ .running = .{ .id = started.timer_id, .title = title, .started_at = started.created_at, }, }; } }, } } return null; } }; /// Returns the most recent updated timer. /// /// Returns `null` if there is no timers, or the most recent event does not have /// corresponding "started" event, which means history is fucked up. /// /// Caller is responsible for calling `deinit` on the returned struct after use. pub fn getLatest(gpa: std.mem.Allocator, io: std.Io, iterator: *events.Iterator) FindError!?Timer { var deleted_timer_ids = std.ArrayList(uuid.Uuid).empty; defer deleted_timer_ids.deinit(gpa); var id: ?uuid.Uuid = null; var stopped_at: ?i64 = null; var latest_title: ?[]const u8 = null; errdefer if (latest_title) |str| gpa.free(str); while (try iterator.next(gpa, io)) |event| { defer event.deinit(gpa); switch (event) { .timer_deleted => |deleted| { try deleted_timer_ids.append(gpa, deleted.timer_id); }, .timer_started => |started| { if (id == null or id == started.timer_id) { const title = latest_title orelse try gpa.dupe(u8, started.title); if (stopped_at) |s| { return .{ .stopped = .{ .id = started.timer_id, .title = title, .started_at = started.created_at, .stopped_at = s, }, }; } else { return .{ .running = .{ .id = started.timer_id, .title = title, .started_at = started.created_at, }, }; } } }, .timer_stopped => |stopped| { if (id == null) { id = stopped.timer_id; } if (stopped.timer_id == id) { stopped_at = stopped.created_at; id = stopped.timer_id; } }, .timer_title_changed => |changed| { if (id == null) { id = changed.timer_id; } if (changed.timer_id == id and latest_title == null) { latest_title = try gpa.dupe(u8, changed.new_title); } }, } } return null; }
-
-
-
@@ -35,10 +35,10 @@in rec { packages = forEachSystems ( { pkgs, ... }: rec { { pkgs, ... }: rec { default = gtk; gtk = pkgs.callPackage ./apps/gtk/default.nix { }; cli = pkgs.callPackage ./apps/cli { }; } );
-
@@ -67,8 +67,7 @@); devShells = forEachSystems ( { pkgs, system }: { { pkgs, system }: { default = pkgs.mkShell { packages = with pkgs;
-