๐Ÿš€ Update nixos with a single command tangled.org/badwatt.com/zix
zig nixos
1

Configure Feed

Select the types of activity you want to include in your feed.

chore: init

Alvaro (May 31, 2026, 12:25 AM +0200) 8f3b2ee8

+2484
+2
.gitignore
··· 1 + *zig-* 2 + coverage
+32
AGENTS.md
··· 1 + # Project Rules 2 + 3 + ## Workflow 4 + 5 + - **TDD mandatory**: every change requires failing test first, then passing code, then refactor. 6 + - **Commit freely**: commit anytime without asking. 7 + - **Push requires approval**: never push. always ask first. 8 + - **Coverage**: 100% line coverage via kcov. no exceptions. 9 + 10 + ## Verification Steps 11 + 12 + 1. Write failing test. 13 + 2. Make test pass with minimal code. 14 + 3. Refactor. 15 + 4. Run `./taskfile test` โ†’ all pass. 16 + 5. Run `./taskfile coverage` โ†’ 100.00%. 17 + 6. Commit. 18 + 19 + ## Technical Constraints 20 + 21 + - Zig 0.16.0+ 22 + - Dependency injection via `Deps` struct. 23 + - No hardcoded stdout/stdin. 24 + - `use_llvm = true` on test binary for kcov. 25 + 26 + ## Questions? 27 + 28 + Ask before uncertain decisions. 29 + 30 + ## Style Guide 31 + 32 + See [`docs/TIGER_STYLE.md`](docs/TIGER_STYLE.md) for TigerBeetle coding style guidelines.
+21
LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2024 ZIX 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+126
README.md
··· 1 + > :warning: **MIRROR**: this project is a mirror from https://codefloe.com/badwatt/zix 2 + 3 + # ZIX 4 + 5 + ![](vhs/zix.gif) 6 + 7 + CLI tool for managing NixOS configuration. 8 + 9 + ## Requirements 10 + 11 + - **Zig 0.16.0** or later 12 + - **Nix** (for nix-shell, kcov) 13 + 14 + ## Installation 15 + 16 + ```sh 17 + git clone https://github.com/alvaro17f/zix.git 18 + cd zix 19 + zig build run 20 + ``` 21 + 22 + Move binary to PATH: 23 + 24 + ```sh 25 + sudo mv zig-out/bin/zix <PATH> 26 + ``` 27 + 28 + ### NixOS 29 + 30 + ```sh 31 + nix run github:alvaro17f/zix#target.x86_64-linux-musl 32 + ``` 33 + 34 + Add to flake: 35 + 36 + ```nix 37 + { 38 + inputs = { 39 + zix.url = "github:alvaro17f/zix"; 40 + }; 41 + } 42 + ``` 43 + 44 + ```nix 45 + { inputs, pkgs, ... }: 46 + { 47 + home.packages = [ 48 + inputs.zix.packages.${pkgs.system}.default 49 + ]; 50 + } 51 + ``` 52 + 53 + ## Build Commands 54 + 55 + All tasks via `zig build`: 56 + 57 + | Command | Description | 58 + |---|---| 59 + | `zig build` | Compile project | 60 + | `zig build run` | Build and run | 61 + | `zig build test --summary all` | Run test suite | 62 + | `zig build coverage` | Run tests under kcov, print line coverage | 63 + | `zig build docs` | Generate autodoc HTML | 64 + | `zig build docs:serve` | Build docs + serve at `localhost:8000` | 65 + | `zig build fmt` | Not available โ€” use `zig fmt src/` directly | 66 + 67 + ## Coverage 68 + 69 + 100% line coverage via **kcov**: 70 + 71 + ```sh 72 + zig build coverage 73 + # 100.0% (423/423 lines) 74 + ``` 75 + 76 + Tests run with LLVM backend (`.use_llvm = true`) for accurate DWARF instrumentation. 77 + 78 + ## Documentation 79 + 80 + ```sh 81 + zig build docs # generate to zig-out/docs/ 82 + zig build docs:serve # build + serve at http://localhost:8000 83 + ``` 84 + 85 + Autodoc uses `///` (declarations) and `//!` (module-level) comments. No doc comments inside function bodies โ€” use `//` there. 86 + 87 + ## Usage 88 + 89 + ``` 90 + *************************************************** 91 + ZIX - A simple CLI tool to update your nixos system 92 + *************************************************** 93 + -r : set repo path (default is $HOME/.dotfiles) 94 + -n : set hostname (default is OS hostname) 95 + -k : set generations to keep (default is 10) 96 + -u : set update to true (default is false) 97 + -d : set diff to true (default is false) 98 + -h, help : Display this help message 99 + -v, version : Display the current version 100 + ``` 101 + 102 + ## Project Structure 103 + 104 + ``` 105 + build.zig Build configuration 106 + build/coverage.zig kcov JSON parser (Zig) 107 + build/serve.zig Static HTTP server for autodoc (Zig) 108 + src/main.zig Entry point, allocator setup 109 + src/app/init.zig CLI flag parsing, app dispatch 110 + src/app/cli.zig Command building, execution pipeline 111 + src/app/config.zig Config struct with defaults + validation 112 + src/core/commands.zig Shell command string builders 113 + src/core/io.zig Formatted output + ANSI style constants 114 + src/core/process.zig Shell process runner 115 + src/core/ui.zig Terminal UI: titles, help, prompts 116 + src/core/static_allocator.zig Two-phase allocator (init โ†’ static โ†’ deinit) 117 + ``` 118 + 119 + ## License 120 + 121 + MIT. See LICENSE. 122 + 123 + ## Style Guide 124 + 125 + This project follows [TigerBeetle's TIGER_STYLE](docs/TIGER_STYLE.md). 126 +
+138
build.zig
··· 1 + //! Build configuration for zix. 2 + //! 3 + //! Defines targets: exe, test, coverage, docs, docs:serve. 4 + //! Module creation is deduplicated via `createModule`. 5 + 6 + const std = @import("std"); 7 + const zon = @import("build.zig.zon"); 8 + 9 + pub const version = std.SemanticVersion.parse(zon.version) catch @panic("Invalid version in build.zig.zon"); 10 + 11 + /// Creates the root module with shared imports (zon module). 12 + /// Used by exe, test, and doc targets to avoid duplication. 13 + fn createModule(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode) *std.Build.Module { 14 + const mod = b.createModule(.{ 15 + .root_source_file = b.path("src/main.zig"), 16 + .target = target, 17 + .optimize = optimize, 18 + .link_libc = true, 19 + }); 20 + const zon_mod = b.createModule(.{ 21 + .root_source_file = b.path("build.zig.zon"), 22 + }); 23 + mod.addImport("zon", zon_mod); 24 + return mod; 25 + } 26 + 27 + pub fn build(b: *std.Build) void { 28 + const target = b.standardTargetOptions(.{}); 29 + const optimize = b.standardOptimizeOption(.{}); 30 + const mod = createModule(b, target, optimize); 31 + 32 + addExe(b, mod); 33 + addTest(b, mod); 34 + const docs_install = addDocs(b, mod); 35 + addCoverage(b, mod, target, optimize); 36 + addDocsServe(b, target, optimize, docs_install); 37 + } 38 + 39 + /// Builds and installs the zix executable. Adds `run` step. 40 + fn addExe(b: *std.Build, mod: *std.Build.Module) void { 41 + const exe = b.addExecutable(.{ 42 + .name = "zix", 43 + .root_module = mod, 44 + .version = version, 45 + }); 46 + b.installArtifact(exe); 47 + 48 + const run_cmd = b.addRunArtifact(exe); 49 + run_cmd.step.dependOn(b.getInstallStep()); 50 + if (b.args) |args| { 51 + run_cmd.addArgs(args); 52 + } 53 + b.step("run", "Run the app").dependOn(&run_cmd.step); 54 + } 55 + 56 + /// Adds `test` step. Optionally installs test binary with `-Dtest-bin=true`. 57 + fn addTest(b: *std.Build, mod: *std.Build.Module) void { 58 + const install_bin = b.option(bool, "test-bin", "Install test binary for coverage analysis") orelse false; 59 + 60 + const compile = b.addTest(.{ 61 + .name = "zix-test", 62 + .root_module = mod, 63 + .use_llvm = true, 64 + }); 65 + if (install_bin) b.installArtifact(compile); 66 + 67 + const run_tests = b.addRunArtifact(compile); 68 + b.step("test", "Run unit tests").dependOn(&run_tests.step); 69 + } 70 + 71 + /// Adds `coverage` step: runs kcov via nix-shell, then prints summary 72 + /// using the Zig coverage-report tool. 73 + fn addCoverage(b: *std.Build, mod: *std.Build.Module, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode) void { 74 + // Test binary 75 + const test_compile = b.addTest(.{ 76 + .name = "zix-test", 77 + .root_module = mod, 78 + .use_llvm = true, 79 + }); 80 + b.installArtifact(test_compile); 81 + 82 + // Coverage report tool 83 + const report_mod = b.createModule(.{ 84 + .root_source_file = b.path("build/coverage.zig"), 85 + .target = target, 86 + .optimize = optimize, 87 + }); 88 + const report_exe = b.addExecutable(.{ 89 + .name = "coverage-report", 90 + .root_module = report_mod, 91 + }); 92 + b.installArtifact(report_exe); 93 + 94 + // kcov step 95 + const kcov = b.addSystemCommand(&.{ "sh", "-c" }); 96 + const test_bin = b.pathJoin(&.{ b.install_path, "bin", "zix-test" }); 97 + const report_bin = b.pathJoin(&.{ b.install_path, "bin", "coverage-report" }); 98 + const kcov_run = std.fmt.allocPrint(b.allocator, "rm -rf coverage && nix-shell -p kcov --run 'kcov --include-pattern=src ./coverage {s}' && {s} coverage", .{ test_bin, report_bin }) catch @panic("OOM"); 99 + kcov.addArg(kcov_run); 100 + kcov.step.dependOn(b.getInstallStep()); 101 + b.step("coverage", "Run tests under kcov and print line coverage").dependOn(&kcov.step); 102 + } 103 + 104 + /// Adds `docs` step: builds autodoc and installs to zig-out/docs/. 105 + fn addDocs(b: *std.Build, mod: *std.Build.Module) *std.Build.Step { 106 + const compile = b.addTest(.{ 107 + .name = "zix-docs", 108 + .root_module = mod, 109 + }); 110 + const install = b.addInstallDirectory(.{ 111 + .source_dir = compile.getEmittedDocs(), 112 + .install_dir = .prefix, 113 + .install_subdir = "docs", 114 + }); 115 + b.step("docs", "Build documentation").dependOn(&install.step); 116 + return &install.step; 117 + } 118 + 119 + /// Adds `docs:serve` step: builds docs then starts a local HTTP server. 120 + /// The server reads files from the installed docs directory. 121 + fn addDocsServe(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, docs_install: *std.Build.Step) void { 122 + const serve_mod = b.createModule(.{ 123 + .root_source_file = b.path("build/serve.zig"), 124 + .target = target, 125 + .optimize = optimize, 126 + }); 127 + const serve_exe = b.addExecutable(.{ 128 + .name = "serve", 129 + .root_module = serve_mod, 130 + }); 131 + 132 + const serve_cmd = b.addRunArtifact(serve_exe); 133 + serve_cmd.addArg(b.pathJoin(&.{ b.install_path, "docs" })); 134 + 135 + serve_cmd.step.dependOn(docs_install); 136 + 137 + b.step("docs:serve", "Build docs and serve at localhost:8000").dependOn(&serve_cmd.step); 138 + }
+12
build.zig.zon
··· 1 + .{ 2 + .name = .zix, 3 + .version = "0.9.4", 4 + .fingerprint = 0x4cc61d7440c1804b, 5 + .minimum_zig_version = "0.16.0", 6 + .dependencies = .{}, 7 + .paths = .{ 8 + "build.zig", 9 + "build.zig.zon", 10 + "src", 11 + }, 12 + }
+60
build/coverage.zig
··· 1 + //! Coverage report parser for kcov JSON output. 2 + //! 3 + //! Walks the coverage directory, finds coverage.json, and prints 4 + //! a summary line: `{percent}% ({covered}/{total} lines)`. 5 + //! Designed to be built and run as part of `zig build coverage`. 6 + 7 + const std = @import("std"); 8 + 9 + /// Entry point. Reads kcov output directory, parses coverage.json, 10 + /// and prints percentage summary. Exits 1 if no data found. 11 + pub fn main(init: std.process.Init) !void { 12 + const allocator = init.gpa; 13 + const io = init.io; 14 + const args = init.minimal.args; 15 + 16 + if (args.vector.len < 2) { 17 + std.debug.print("usage: coverage-report <coverage-dir>\n", .{}); 18 + std.process.exit(1); 19 + } 20 + const coverage_dir = std.mem.sliceTo(args.vector[1], 0); 21 + 22 + const cwd = std.Io.Dir.cwd(); 23 + 24 + const dir = cwd.openDir(io, coverage_dir, .{ .iterate = true }) catch |err| switch (err) { 25 + error.FileNotFound => { 26 + std.debug.print("No coverage data found\n", .{}); 27 + std.process.exit(1); 28 + }, 29 + else => return err, 30 + }; 31 + 32 + var walker = try dir.walk(allocator); 33 + defer walker.deinit(); 34 + 35 + while (try walker.next(io)) |entry| { 36 + if (entry.kind != .file) continue; 37 + if (!std.mem.endsWith(u8, entry.basename, "coverage.json")) continue; 38 + 39 + const path = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ coverage_dir, entry.path }); 40 + defer allocator.free(path); 41 + 42 + const contents = cwd.readFileAlloc(io, path, allocator, .limited(1 << 20)) catch continue; 43 + defer allocator.free(contents); 44 + 45 + const parsed = std.json.parseFromSlice(std.json.Value, allocator, contents, .{}) catch continue; 46 + defer parsed.deinit(); 47 + 48 + const root = parsed.value.object; 49 + const pct_str = root.get("percent_covered").?.string; 50 + const pct = std.fmt.parseFloat(f64, pct_str) catch 0.0; 51 + const covered = root.get("covered_lines").?.integer; 52 + const total = root.get("total_lines").?.integer; 53 + 54 + std.debug.print("{d:.1}% ({d}/{d} lines)\n", .{ pct, covered, total }); 55 + return; 56 + } 57 + 58 + std.debug.print("No coverage data found\n", .{}); 59 + std.process.exit(1); 60 + }
+123
build/serve.zig
··· 1 + //! Static HTTP file server for Zig autodoc. 2 + //! 3 + //! Serves files from a directory over HTTP/1.1 with `Connection: close`. 4 + //! Supports WASM, JS, HTML, JSON, CSS, SVG, and TAR content types. 5 + //! Designed to be built and run as part of `zig build docs:serve`. 6 + 7 + const std = @import("std"); 8 + const Io = std.Io; 9 + 10 + /// Listens on port 8000 and serves files from the directory given as 11 + /// the first argument. Defaults to current directory if no argument. 12 + pub fn main(init: std.process.Init) !void { 13 + const io = init.io; 14 + const args = init.minimal.args; 15 + 16 + const port: u16 = 8000; 17 + const serve_dir = if (args.vector.len > 1) std.mem.sliceTo(args.vector[1], 0) else "."; 18 + 19 + const address = Io.net.IpAddress.parseIp4("0.0.0.0", port) catch { 20 + std.debug.print("invalid port\n", .{}); 21 + std.process.exit(1); 22 + }; 23 + var server = Io.net.IpAddress.listen(&address, io, .{}) catch |err| { 24 + std.debug.print("failed to listen on port {d}: {s}\n", .{ port, @errorName(err) }); 25 + std.process.exit(1); 26 + }; 27 + 28 + std.debug.print("serving {s} at http://localhost:{d}\n", .{ serve_dir, port }); 29 + 30 + while (true) { 31 + const stream = server.accept(io) catch continue; 32 + handleClient(io, stream, serve_dir) catch continue; 33 + } 34 + } 35 + 36 + /// Reads one HTTP request, resolves the path to a file in `serve_dir`, 37 + /// and writes the response with appropriate `Content-Type` and 38 + /// `Connection: close` headers. 39 + fn handleClient(io: Io, stream: Io.net.Stream, serve_dir: []const u8) !void { 40 + var rbuf: [8192]u8 = undefined; 41 + var net_reader = stream.reader(io, &rbuf); 42 + var reader = &net_reader.interface; 43 + 44 + // Read request headers 45 + while (true) { 46 + reader.fillMore() catch break; 47 + const data = reader.buffer[0..reader.end]; 48 + if (std.mem.indexOf(u8, data, "\r\n\r\n")) |_| break; 49 + } 50 + 51 + const request = reader.buffer[reader.seek..reader.end]; 52 + 53 + // Parse path from request line: "GET /path HTTP/1.1" 54 + var path_start: usize = 0; 55 + var path_end: usize = 0; 56 + for (request, 0..) |c, i| { 57 + if (c == ' ' and path_start == 0 and i >= 3) { 58 + path_start = i + 1; 59 + } else if (c == ' ' and path_start > 0 and path_end == 0) { 60 + path_end = i; 61 + break; 62 + } 63 + } 64 + if (path_start == 0 or path_end == 0) return; 65 + const raw_path = request[path_start..path_end]; 66 + 67 + // Default to index.html for directory paths 68 + const path = if (std.mem.endsWith(u8, raw_path, "/") or std.mem.endsWith(u8, raw_path, "/..")) 69 + "/index.html" 70 + else 71 + raw_path; 72 + 73 + // Block path traversal 74 + if (std.mem.containsAtLeast(u8, path, 1, "../")) { 75 + respond(io, stream, "HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 15\r\n\r\n403 Forbidden"); 76 + return; 77 + } 78 + 79 + const file_path = path[1..]; // strip leading / 80 + 81 + var path_buf: [4096]u8 = undefined; 82 + const full_path = std.fmt.bufPrint(&path_buf, "{s}/{s}", .{ serve_dir, file_path }) catch return; 83 + 84 + const content_type = ct: { 85 + if (std.mem.endsWith(u8, file_path, ".html")) break :ct "text/html"; 86 + if (std.mem.endsWith(u8, file_path, ".js")) break :ct "application/javascript"; 87 + if (std.mem.endsWith(u8, file_path, ".wasm")) break :ct "application/wasm"; 88 + if (std.mem.endsWith(u8, file_path, ".css")) break :ct "text/css"; 89 + if (std.mem.endsWith(u8, file_path, ".json")) break :ct "application/json"; 90 + if (std.mem.endsWith(u8, file_path, ".svg")) break :ct "image/svg+xml"; 91 + if (std.mem.endsWith(u8, file_path, ".tar")) break :ct "application/x-tar"; 92 + break :ct "application/octet-stream"; 93 + }; 94 + 95 + var gpa_buf: [1 << 25]u8 = undefined; 96 + var gpa_fixed = std.heap.FixedBufferAllocator.init(&gpa_buf); 97 + const allocator = gpa_fixed.allocator(); 98 + 99 + const cwd = Io.Dir.cwd(); 100 + const contents = cwd.readFileAlloc(io, full_path, allocator, .limited(1 << 25)) catch { 101 + respond(io, stream, "HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 13\r\n\r\n404 Not Found"); 102 + return; 103 + }; 104 + 105 + var wbuf: [4096]u8 = undefined; 106 + var net_writer = stream.writer(io, &wbuf); 107 + var writer = &net_writer.interface; 108 + writer.print( 109 + "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: {d}\r\nContent-Type: {s}\r\n\r\n", 110 + .{ contents.len, content_type }, 111 + ) catch return; 112 + writer.writeAll(contents) catch return; 113 + writer.flush() catch return; 114 + } 115 + 116 + /// Sends a complete HTTP response in one write. 117 + fn respond(io: Io, stream: Io.net.Stream, msg: []const u8) void { 118 + var wbuf: [512]u8 = undefined; 119 + var net_writer = stream.writer(io, &wbuf); 120 + var writer = &net_writer.interface; 121 + writer.writeAll(msg) catch return; 122 + writer.flush() catch return; 123 + }
+511
docs/TIGER_STYLE.md
··· 1 + # TigerStyle 2 + 3 + ## The Essence Of Style 4 + 5 + > โ€œThere are three things extremely hard: steel, a diamond, and to know one's self.โ€ โ€” Benjamin 6 + > Franklin 7 + 8 + TigerBeetle's coding style is evolving. A collective give-and-take at the intersection of 9 + engineering and art. Numbers and human intuition. Reason and experience. First principles and 10 + knowledge. Precision and poetry. Just like music. A tight beat. A rare groove. Words that rhyme and 11 + rhymes that break. Biodigital jazz. This is what we've learned along the way. The best is yet to 12 + come. 13 + 14 + ## Why Have Style? 15 + 16 + Another word for style is design. 17 + 18 + > โ€œThe design is not just what it looks like and feels like. The design is how it works.โ€ โ€” Steve 19 + > Jobs 20 + 21 + Our design goals are safety, performance, and developer experience. In that order. All three are 22 + important. Good style advances these goals. Does the code make for more or less safety, performance 23 + or developer experience? That is why we need style. 24 + 25 + Put this way, style is more than readability, and readability is table stakes, a means to an end 26 + rather than an end in itself. 27 + 28 + > โ€œ...in programming, style is not something to pursue directly. Style is necessary only where 29 + > understanding is missing.โ€ โ”€ [Let Over 30 + > Lambda](https://letoverlambda.com/index.cl/guest/chap1.html) 31 + 32 + This document explores how we apply these design goals to coding style. First, a word on simplicity, 33 + elegance and technical debt. 34 + 35 + ## On Simplicity And Elegance 36 + 37 + Simplicity is not a free pass. It's not in conflict with our design goals. It need not be a 38 + concession or a compromise. 39 + 40 + Rather, simplicity is how we bring our design goals together, how we identify the โ€œsuper ideaโ€ that 41 + solves the axes simultaneously, to achieve something elegant. 42 + 43 + > โ€œSimplicity and elegance are unpopular because they require hard work and discipline to achieveโ€ โ€” 44 + > Edsger Dijkstra 45 + 46 + Contrary to popular belief, simplicity is also not the first attempt but the hardest revision. It's 47 + easy to say โ€œlet's do something simpleโ€, but to do that in practice takes thought, multiple passes, 48 + many sketches, and still we may have to [โ€œthrow one 49 + awayโ€](https://en.wikipedia.org/wiki/The_Mythical_Man-Month). 50 + 51 + The hardest part, then, is how much thought goes into everything. 52 + 53 + We spend this mental energy upfront, proactively rather than reactively, because we know that when 54 + the thinking is done, what is spent on the design will be dwarfed by the implementation and testing, 55 + and then again by the costs of operation and maintenance. 56 + 57 + An hour or day of design is worth weeks or months in production: 58 + 59 + > โ€œthe simple and elegant systems tend to be easier and faster to design and get right, more 60 + > efficient in execution, and much more reliableโ€ โ€” Edsger Dijkstra 61 + 62 + ## Technical Debt 63 + 64 + What could go wrong? What's wrong? Which question would we rather ask? The former, because code, 65 + like steel, is less expensive to change while it's hot. A problem solved in production is many times 66 + more expensive than a problem solved in implementation, or a problem solved in design. 67 + 68 + Since it's hard enough to discover showstoppers, when we do find them, we solve them. We don't allow 69 + potential memcpy latency spikes, or exponential complexity algorithms to slip through. 70 + 71 + > โ€œYou shall not pass!โ€ โ€” Gandalf 72 + 73 + In other words, TigerBeetle has a โ€œzero technical debtโ€ policy. We do it right the first time. This 74 + is important because the second time may not transpire, and because doing good work, that we can be 75 + proud of, builds momentum. 76 + 77 + We know that what we ship is solid. We may lack crucial features, but what we have meets our design 78 + goals. This is the only way to make steady incremental progress, knowing that the progress we have 79 + made is indeed progress. 80 + 81 + ## Safety 82 + 83 + > โ€œThe rules act like the seat-belt in your car: initially they are perhaps a little uncomfortable, 84 + > but after a while their use becomes second-nature and not using them becomes unimaginable.โ€ โ€” 85 + > Gerard J. Holzmann 86 + 87 + [NASA's Power of Ten โ€” Rules for Developing Safety Critical 88 + Code](https://spinroot.com/gerard/pdf/P10.pdf) will change the way you code forever. To expand: 89 + 90 + - Use **only very simple, explicit control flow** for clarity. **Do not use recursion** to ensure 91 + that all executions that should be bounded are bounded. Use **only a minimum of excellent 92 + abstractions** but only if they make the best sense of the domain. Abstractions are [never zero 93 + cost](https://isaacfreund.com/blog/2022-05/). Every abstraction introduces the risk of a leaky 94 + abstraction. 95 + 96 + - **Put a limit on everything** because, in reality, this is what we expectโ€”everything has a limit. 97 + For example, all loops and all queues must have a fixed upper bound to prevent infinite loops or 98 + tail latency spikes. This follows the [โ€œfail-fastโ€](https://en.wikipedia.org/wiki/Fail-fast) 99 + principle so that violations are detected sooner rather than later. Where a loop cannot terminate 100 + (e.g. an event loop), this must be asserted. 101 + 102 + - Use explicitly-sized types like `u32` for everything, avoid architecture-specific `usize`. 103 + 104 + - **Assertions detect programmer errors. Unlike operating errors, which are expected and which must 105 + be handled, assertion failures are unexpected. The only correct way to handle corrupt code is to 106 + crash. Assertions downgrade catastrophic correctness bugs into liveness bugs. Assertions are a 107 + force multiplier for discovering bugs by fuzzing.** 108 + 109 + - **Assert all function arguments and return values, pre/postconditions and invariants.** A 110 + function must not operate blindly on data it has not checked. The purpose of a function is to 111 + increase the probability that a program is correct. Assertions within a function are part of how 112 + functions serve this purpose. The assertion density of the code must average a minimum of two 113 + assertions per function. 114 + 115 + - **[Pair assertions](https://tigerbeetle.com/blog/2023-12-27-it-takes-two-to-contract).** For 116 + every property you want to enforce, try to find at least two different code paths where an 117 + assertion can be added. For example, assert validity of data right before writing it to disk, 118 + and also immediately after reading from disk. 119 + 120 + - On occasion, you may use a blatantly true assertion instead of a comment as stronger 121 + documentation where the assertion condition is critical and surprising. 122 + 123 + - Split compound assertions: prefer `assert(a); assert(b);` over `assert(a and b);`. 124 + The former is simpler to read, and provides more precise information if the condition fails. 125 + 126 + - Use single-line `if` to assert an implication: `if (a) assert(b)`. 127 + 128 + - **Assert the relationships of compile-time constants** as a sanity check, and also to document 129 + and enforce [subtle 130 + invariants](https://github.com/coilhq/tigerbeetle/blob/db789acfb93584e5cb9f331f9d6092ef90b53ea6/src/vsr/journal.zig#L45-L47) 131 + or [type 132 + sizes](https://github.com/coilhq/tigerbeetle/blob/578ac603326e1d3d33532701cb9285d5d2532fe7/src/ewah.zig#L41-L53). 133 + Compile-time assertions are extremely powerful because they are able to check a program's design 134 + integrity _before_ the program even executes. 135 + 136 + - **The golden rule of assertions is to assert the _positive space_ that you do expect AND to 137 + assert the _negative space_ that you do not expect** because where data moves across the 138 + valid/invalid boundary between these spaces is where interesting bugs are often found. This is 139 + also why **tests must test exhaustively**, not only with valid data but also with invalid data, 140 + and as valid data becomes invalid. 141 + 142 + - Assertions are a safety net, not a substitute for human understanding. With simulation testing, 143 + there is the temptation to trust the fuzzer. But a fuzzer can prove only the presence of bugs, 144 + not their absence. Therefore: 145 + - Build a precise mental model of the code first, 146 + - encode your understanding in the form of assertions, 147 + - write the code and comments to explain and justify the mental model to your reviewer, 148 + - and use VOPR as the final line of defense, to find bugs in your and reviewer's understanding 149 + of code. 150 + 151 + - All memory must be statically allocated at startup. **No memory may be dynamically allocated (or 152 + freed and reallocated) after initialization.** This avoids unpredictable behavior that can 153 + significantly affect performance, and avoids use-after-free. As a second-order effect, it is our 154 + experience that this also makes for more efficient, simpler designs that are more performant and 155 + easier to maintain and reason about, compared to designs that do not consider all possible memory 156 + usage patterns upfront as part of the design. 157 + 158 + - Declare variables at the **smallest possible scope**, and **minimize the number of variables in 159 + scope**, to reduce the probability that variables are misused. 160 + 161 + - There's a sharp discontinuity between a function fitting on a screen, and having to scroll to 162 + see how long it is. For this physical reason we enforce a **hard limit of 70 lines per function**. 163 + Art is born of constraints. There are many ways to cut a wall of code into chunks of 70 lines, 164 + but only a few splits will feel right. Some rules of thumb: 165 + 166 + * Good function shape is often the inverse of an hourglass: a few parameters, a simple return 167 + type, and a lot of meaty logic between the braces. 168 + * Centralize control flow. When splitting a large function, try to keep all switch/if 169 + statements in the "parent" function, and move non-branchy logic fragments to helper 170 + functions. Divide responsibility. All control flow should be handled by _one_ function, the rest shouldn't 171 + care about control flow at all. In other words, 172 + ["push `if`s up and `for`s down"](https://matklad.github.io/2023/11/15/push-ifs-up-and-fors-down.html). 173 + * Similarly, centralize state manipulation. Let the parent function keep all relevant state in 174 + local variables, and use helpers to compute what needs to change, rather than applying the 175 + change directly. Keep leaf functions pure. 176 + 177 + - Appreciate, from day one, **all compiler warnings at the compiler's strictest setting**. 178 + 179 + - Whenever your program has to interact with external entities, **don't do things directly in 180 + reaction to external events**. Instead, your program should run at its own pace. Not only does 181 + this make your program safer by keeping the control flow of your program under your control, it 182 + also improves performance for the same reason (you get to batch, instead of context switching on 183 + every event). Additionally, this makes it easier to maintain bounds on work done per time period. 184 + 185 + Beyond these rules: 186 + 187 + - Compound conditions that evaluate multiple booleans make it difficult for the reader to verify 188 + that all cases are handled. Split compound conditions into simple conditions using nested 189 + `if/else` branches. Split complex `else if` chains into `else { if { } }` trees. This makes the 190 + branches and cases clear. Again, consider whether a single `if` does not also need a matching 191 + `else` branch, to ensure that the positive and negative spaces are handled or asserted. 192 + 193 + - Negations are not easy! State invariants positively. When working with lengths and indexes, this 194 + form is easy to get right (and understand): 195 + 196 + ```zig 197 + if (index < length) { 198 + // The invariant holds. 199 + } else { 200 + // The invariant doesn't hold. 201 + } 202 + ``` 203 + 204 + This form is harder, and also goes against the grain of how `index` would typically be compared to 205 + `length`, for example, in a loop condition: 206 + 207 + ```zig 208 + if (index >= length) { 209 + // It's not true that the invariant holds. 210 + } 211 + ``` 212 + 213 + - All errors must be handled. An [analysis of production failures in distributed data-intensive 214 + systems](https://www.usenix.org/system/files/conference/osdi14/osdi14-paper-yuan.pdf) found that 215 + the majority of catastrophic failures could have been prevented by simple testing of error 216 + handling code. 217 + 218 + > โ€œSpecifically, we found that almost all (92%) of the catastrophic system failures are the result 219 + > of incorrect handling of non-fatal errors explicitly signaled in software.โ€ 220 + 221 + - **Always motivate, always say why**. Never forget to say why. Because if you explain the rationale 222 + for a decision, it not only increases the hearer's understanding, and makes them more likely to 223 + adhere or comply, but it also shares criteria with them with which to evaluate the decision and 224 + its importance. 225 + 226 + - **Explicitly pass options to library functions at the call site, instead of relying on the 227 + defaults**. For example, write `@prefetch(a, .{ .cache = .data, .rw = .read, .locality = 3 });` 228 + over `@prefetch(a, .{});`. This improves readability but most of all avoids latent, potentially 229 + catastrophic bugs in case the library ever changes its defaults. 230 + 231 + ## Performance 232 + 233 + > โ€œThe lack of back-of-the-envelope performance sketches is the root of all evil.โ€ โ€” Rivacindela 234 + > Hudsoni 235 + 236 + - Think about performance from the outset, from the beginning. **The best time to solve performance, 237 + to get the huge 1000x wins, is in the design phase, which is precisely when we can't measure or 238 + profile.** It's also typically harder to fix a system after implementation and profiling, and the 239 + gains are less. So you have to have mechanical sympathy. Like a carpenter, work with the grain. 240 + 241 + - **Perform back-of-the-envelope sketches with respect to the four resources (network, disk, memory, 242 + CPU) and their two main characteristics (bandwidth, latency).** Sketches are cheap. Use sketches 243 + to be โ€œroughly rightโ€ and land within 90% of the global maximum. 244 + 245 + - Optimize for the slowest resources first (network, disk, memory, CPU) in that order, after 246 + compensating for the frequency of usage, because faster resources may be used many times more. For 247 + example, a memory cache miss may be as expensive as a disk fsync, if it happens many times more. 248 + 249 + - Distinguish between the control plane and data plane. A clear delineation between control plane 250 + and data plane through the use of batching enables a high level of assertion safety without losing 251 + performance. See our [July 2021 talk on Zig SHOWTIME](https://youtu.be/BH2jvJ74npM?t=1958) for 252 + examples. 253 + 254 + - Amortize network, disk, memory and CPU costs by batching accesses. 255 + 256 + - Let the CPU be a sprinter doing the 100m. Be predictable. Don't force the CPU to zig zag and 257 + change lanes. Give the CPU large enough chunks of work. This comes back to batching. 258 + 259 + - Be explicit. Minimize dependence on the compiler to do the right thing for you. 260 + 261 + In particular, extract hot loops into stand-alone functions with primitive arguments without 262 + `self` (see [an example](https://github.com/tigerbeetle/tigerbeetle/blob/0.16.19/src/lsm/compaction.zig#L1932-L1937)). 263 + That way, the compiler doesn't need to prove that it can cache struct's fields in registers, and a 264 + human reader can spot redundant computations easier. 265 + 266 + ## Developer Experience 267 + 268 + > โ€œThere are only two hard things in Computer Science: cache invalidation, naming things, and 269 + > off-by-one errors.โ€ โ€” Phil Karlton 270 + 271 + ### Naming Things 272 + 273 + - **Get the nouns and verbs just right.** Great names are the essence of great code, they capture 274 + what a thing is or does, and provide a crisp, intuitive mental model. They show that you 275 + understand the domain. Take time to find the perfect name, to find nouns and verbs that work 276 + together, so that the whole is greater than the sum of its parts. 277 + 278 + - Use `snake_case` for function, variable, and file names. The underscore is the closest thing we 279 + have as programmers to a space, and helps to separate words and encourage descriptive names. We 280 + don't use Zig's `CamelCase.zig` style for "struct" files to keep the convention simple and 281 + consistent. 282 + 283 + - Do not abbreviate variable names, unless the variable is a primitive integer type used as an 284 + argument to a sort function or matrix calculation. Use long form arguments in scripts: `--force`, 285 + not `-f`. Single letter flags are for interactive usage. 286 + 287 + - Use proper capitalization for acronyms (`VSRState`, not `VsrState`). 288 + 289 + - For the rest, follow the Zig style guide. 290 + 291 + - Add units or qualifiers to variable names, and put the units or qualifiers last, sorted by 292 + descending significance, so that the variable starts with the most significant word, and ends with 293 + the least significant word. For example, `latency_ms_max` rather than `max_latency_ms`. This will 294 + then line up nicely when `latency_ms_min` is added, as well as group all variables that relate to 295 + latency. 296 + 297 + - Infuse names with meaning. For example, `allocator: Allocator` is a good, if boring name, 298 + but `gpa: Allocator` and `arena: Allocator` are excellent. They inform the reader whether 299 + `deinit` should be called explicitly. 300 + 301 + - When choosing related names, try hard to find names with the same number of characters so that 302 + related variables all line up in the source. For example, as arguments to a memcpy function, 303 + `source` and `target` are better than `src` and `dest` because they have the second-order effect 304 + that any related variables such as `source_offset` and `target_offset` will all line up in 305 + calculations and slices. This makes the code symmetrical, with clean blocks that are easier for 306 + the eye to parse and for the reader to check. 307 + 308 + - When a single function calls out to a helper function or callback, prefix the name of the helper 309 + function with the name of the calling function to show the call history. For example, 310 + `read_sector()` and `read_sector_callback()`. 311 + 312 + - Callbacks go last in the list of parameters. This mirrors control flow: callbacks are also 313 + _invoked_ last. 314 + 315 + - _Order_ matters for readability (even if it doesn't affect semantics). On the first read, a file 316 + is read top-down, so put important things near the top. The `main` function goes first. 317 + 318 + The same goes for `structs`, the order is fields then types then methods: 319 + 320 + ```zig 321 + time: Time, 322 + process_id: ProcessID, 323 + 324 + const ProcessID = struct { cluster: u128, replica: u8 }; 325 + const Tracer = @This(); // This alias concludes the types section. 326 + 327 + pub fn init(gpa: std.mem.Allocator, time: Time) !Tracer { 328 + ... 329 + } 330 + ``` 331 + 332 + If a nested type is complex, make it a top-level struct. 333 + 334 + At the same time, not everything has a single right order. When in doubt, consider sorting 335 + alphabetically, taking advantage of big-endian naming. 336 + 337 + - Don't overload names with multiple meanings that are context-dependent. For example, TigerBeetle 338 + has a feature called _pending transfers_ where a pending transfer can be subsequently _posted_ or 339 + _voided_. At first, we called them _two-phase commit transfers_, but this overloaded the 340 + _two-phase commit_ terminology that was used in our consensus protocol, causing confusion. 341 + 342 + - Think of how names will be used outside the code, in documentation or communication. For example, 343 + a noun is often a better descriptor than an adjective or present participle, because a noun can be 344 + directly used in correspondence without having to be rephrased. Compare `replica.pipeline` vs 345 + `replica.preparing`. The former can be used directly as a section header in a document or 346 + conversation, whereas the latter must be clarified. Noun names compose more clearly for derived 347 + identifiers, e.g. `config.pipeline_max`. 348 + 349 + - Zig has named arguments through the `options: struct` pattern. Use it when arguments can be 350 + mixed up. A function taking two `u64` must use an options struct. If an argument can be `null`, 351 + it should be named so that the meaning of `null` literal at the call site is clear. 352 + 353 + Because dependencies like an allocator or a tracer are singletons with unique types, they should 354 + be threaded through constructors positionally, from the most general to the most specific. 355 + 356 + - **Write descriptive commit messages** that inform and delight the reader, because your commit 357 + messages are being read. Note that a pull request description is not stored in the git repository 358 + and is invisible in `git blame`, and therefore is not a replacement for a commit message. 359 + 360 + - Don't forget to say why. Code alone is not documentation. Use comments to explain why you wrote 361 + the code the way you did. Show your workings. 362 + 363 + - Don't forget to say how. For example, when writing a test, think of writing a description at the 364 + top to explain the goal and methodology of the test, to help your reader get up to speed, or to 365 + skip over sections, without forcing them to dive in. 366 + 367 + - Comments are sentences, with a space after the slash, with a capital letter and a full stop, or a 368 + colon if they relate to something that follows. Comments are well-written prose describing the 369 + code, not just scribblings in the margin. Comments after the end of a line _can_ be phrases, with 370 + no punctuation. 371 + 372 + ### Cache Invalidation 373 + 374 + - Don't duplicate variables or take aliases to them. This will reduce the probability that state 375 + gets out of sync. 376 + 377 + - If you don't mean a function argument to be copied when passed by value, and if the argument type 378 + is more than 16 bytes, then pass the argument as `*const`. This will catch bugs where the caller 379 + makes an accidental copy on the stack before calling the function. 380 + 381 + - Construct larger structs _in-place_ by passing an _out pointer_ during initialization. 382 + 383 + In-place initializations can assume **pointer stability** and **immovable types** while 384 + eliminating intermediate copy-move allocations, which can lead to undesirable stack growth. 385 + 386 + Keep in mind that in-place initializations are viral โ€” if any field is initialized 387 + in-place, the entire container struct should be initialized in-place as well. 388 + 389 + **Prefer:** 390 + ```zig 391 + fn init(target: *LargeStruct) !void { 392 + target.* = .{ 393 + // in-place initialization. 394 + }; 395 + } 396 + 397 + fn main() !void { 398 + var target: LargeStruct = undefined; 399 + try target.init(); 400 + } 401 + ``` 402 + 403 + **Over:** 404 + ```zig 405 + fn init() !LargeStruct { 406 + return LargeStruct { 407 + // moving the initialized object. 408 + } 409 + } 410 + 411 + fn main() !void { 412 + var target = try LargeStruct.init(); 413 + } 414 + ``` 415 + 416 + - **Shrink the scope** to minimize the number of variables at play and reduce the probability that 417 + the wrong variable is used. 418 + 419 + - Calculate or check variables close to where/when they are used. **Don't introduce variables before 420 + they are needed.** Don't leave them around where they are not. This will reduce the probability of 421 + a POCPOU (place-of-check to place-of-use), a distant cousin to the infamous 422 + [TOCTOU](https://en.wikipedia.org/wiki/Time-of-check_to_time-of-use). Most bugs come down to a 423 + semantic gap, caused by a gap in time or space, because it's harder to check code that's not 424 + contained along those dimensions. 425 + 426 + - Use simpler function signatures and return types to reduce dimensionality at the call site, the 427 + number of branches that need to be handled at the call site, because this dimensionality can also 428 + be viral, propagating through the call chain. For example, as a return type, `void` trumps `bool`, 429 + `bool` trumps `u64`, `u64` trumps `?u64`, and `?u64` trumps `!u64`. 430 + 431 + - Ensure that functions run to completion without suspending, so that precondition assertions are 432 + true throughout the lifetime of the function. These assertions are useful documentation without a 433 + suspend, but may be misleading otherwise. 434 + 435 + - Be on your guard for **[buffer bleeds](https://en.wikipedia.org/wiki/Heartbleed)**. This is a 436 + buffer underflow, the opposite of a buffer overflow, where a buffer is not fully utilized, with 437 + padding not zeroed correctly. This may not only leak sensitive information, but may cause 438 + deterministic guarantees as required by TigerBeetle to be violated. 439 + 440 + - Use newlines to **group resource allocation and deallocation**, i.e. before the resource 441 + allocation and after the corresponding `defer` statement, to make leaks easier to spot. 442 + 443 + ### Off-By-One Errors 444 + 445 + - **The usual suspects for off-by-one errors are casual interactions between an `index`, a `count` 446 + or a `size`.** These are all primitive integer types, but should be seen as distinct types, with 447 + clear rules to cast between them. To go from an `index` to a `count` you need to add one, since 448 + indexes are _0-based_ but counts are _1-based_. To go from a `count` to a `size` you need to 449 + multiply by the unit. Again, this is why including units and qualifiers in variable names is 450 + important. 451 + 452 + - Show your intent with respect to division. For example, use `@divExact()`, `@divFloor()` or 453 + `div_ceil()` to show the reader you've thought through all the interesting scenarios where 454 + rounding may be involved. 455 + 456 + ### Style By The Numbers 457 + 458 + - Run `zig fmt`. 459 + 460 + - Use 4 spaces of indentation, rather than 2 spaces, as that is more obvious to the eye at a 461 + distance. 462 + 463 + - Hard limit all line lengths, without exception, to at most 100 columns for a good typographic 464 + "measure". Use it up. Never go beyond. Nothing should be hidden by a horizontal scrollbar. Let 465 + your editor help you by setting a column ruler. To wrap a function signature, call or data 466 + structure, add a trailing comma, close your eyes and let `zig fmt` do the rest. 467 + 468 + Similar to function length, the motivation behind the number 100 is physical: just enough 469 + to fit two copies of the code side-by-side on a screen. 470 + 471 + - Add braces to the `if` statement unless it fits on a single line for consistency and defense in 472 + depth against "goto fail;" bugs. 473 + 474 + ### Dependencies 475 + 476 + TigerBeetle has **a โ€œzero dependenciesโ€ policy**, apart from the Zig toolchain. Dependencies, in 477 + general, inevitably lead to supply chain attacks, safety and performance risk, and slow install 478 + times. For foundational infrastructure in particular, the cost of any dependency is further 479 + amplified throughout the rest of the stack. 480 + 481 + ### Tooling 482 + 483 + Similarly, tools have costs. A small standardized toolbox is simpler to operate than an array of 484 + specialized instruments each with a dedicated manual. Our primary tool is Zig. It may not be the 485 + best for everything, but it's good enough for most things. We invest into our Zig tooling to ensure 486 + that we can tackle new problems quickly, with a minimum of accidental complexity in our local 487 + development environment. 488 + 489 + > โ€œThe right tool for the job is often the tool you are already usingโ€”adding new tools has a higher 490 + > cost than many people appreciateโ€ โ€” John Carmack 491 + 492 + For example, the next time you write a script, instead of `scripts/*.sh`, write `scripts/*.zig`. 493 + 494 + This not only makes your script cross-platform and portable, but introduces type safety and 495 + increases the probability that running your script will succeed for everyone on the team, instead of 496 + hitting a Bash/Shell/OS-specific issue. 497 + 498 + Standardizing on Zig for tooling is important to ensure that we reduce dimensionality, as the team, 499 + and therefore the range of personal tastes, grows. This may be slower for you in the short term, but 500 + makes for more velocity for the team in the long term. 501 + 502 + ## The Last Stage 503 + 504 + At the end of the day, keep trying things out, have fun, and rememberโ€”it's called TigerBeetle, not 505 + only because it's fast, but because it's small! 506 + 507 + > You donโ€™t really suppose, do you, that all your adventures and escapes were managed by mere luck, 508 + > just for your sole benefit? You are a very fine person, Mr. Baggins, and I am very fond of you; 509 + > but you are only quite a little fellow in a wide world after all!โ€ 510 + > 511 + > โ€œThank goodness!โ€ said Bilbo laughing, and handed him the tobacco-jar.
+78
flake.lock
··· 1 + { 2 + "nodes": { 3 + "flake-utils": { 4 + "inputs": { 5 + "systems": "systems" 6 + }, 7 + "locked": { 8 + "lastModified": 1731533236, 9 + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 + "owner": "numtide", 11 + "repo": "flake-utils", 12 + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 + "type": "github" 14 + }, 15 + "original": { 16 + "owner": "numtide", 17 + "repo": "flake-utils", 18 + "type": "github" 19 + } 20 + }, 21 + "nixpkgs": { 22 + "locked": { 23 + "lastModified": 1779593580, 24 + "narHash": "sha256-le3WvQyzAQjBZnb7q2c8C5Fk2c9LgN/Oq+b0KiD4fM4=", 25 + "owner": "nixos", 26 + "repo": "nixpkgs", 27 + "rev": "d849bb215dcdf71bce3e686839ccdb4219e84b2f", 28 + "type": "github" 29 + }, 30 + "original": { 31 + "owner": "nixos", 32 + "repo": "nixpkgs", 33 + "type": "github" 34 + } 35 + }, 36 + "root": { 37 + "inputs": { 38 + "zig2nix": "zig2nix" 39 + } 40 + }, 41 + "systems": { 42 + "locked": { 43 + "lastModified": 1681028828, 44 + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 45 + "owner": "nix-systems", 46 + "repo": "default", 47 + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 48 + "type": "github" 49 + }, 50 + "original": { 51 + "owner": "nix-systems", 52 + "repo": "default", 53 + "type": "github" 54 + } 55 + }, 56 + "zig2nix": { 57 + "inputs": { 58 + "flake-utils": "flake-utils", 59 + "nixpkgs": "nixpkgs" 60 + }, 61 + "locked": { 62 + "lastModified": 1779594911, 63 + "narHash": "sha256-KbABCdJWpZqWhkltmKZCAt0EM66mwYm+MsSGUIUIE0o=", 64 + "owner": "Cloudef", 65 + "repo": "zig2nix", 66 + "rev": "2c3152b86d94c7c514908592357bd5a862a1b185", 67 + "type": "github" 68 + }, 69 + "original": { 70 + "owner": "Cloudef", 71 + "repo": "zig2nix", 72 + "type": "github" 73 + } 74 + } 75 + }, 76 + "root": "root", 77 + "version": 7 78 + }
+73
flake.nix
··· 1 + { 2 + description = "Zig project flake"; 3 + 4 + inputs = { 5 + zig2nix.url = "github:Cloudef/zig2nix"; 6 + }; 7 + 8 + outputs = 9 + { zig2nix, ... }: 10 + let 11 + flake-utils = zig2nix.inputs.flake-utils; 12 + in 13 + (flake-utils.lib.eachDefaultSystem ( 14 + system: 15 + let 16 + env = zig2nix.outputs.zig-env.${system} { zig = zig2nix.outputs.packages.${system}.zig-0_16_0; }; 17 + in 18 + with builtins; 19 + with env.pkgs.lib; 20 + rec { 21 + packages.foreign = env.package ( 22 + { 23 + src = cleanSource ./.; 24 + 25 + nativeBuildInputs = with env.pkgs; [ ]; 26 + 27 + buildInputs = with env.pkgs; [ ]; 28 + 29 + zigPreferMusl = true; 30 + 31 + zigDisableWrap = true; 32 + } 33 + // optionalAttrs (!pathExists ./build.zig.zon) { 34 + pname = "zix"; 35 + version = "0.0.0"; 36 + } 37 + ); 38 + 39 + packages.default = packages.foreign.overrideAttrs (attrs: { 40 + zigPreferMusl = false; 41 + 42 + zigWrapperBins = with env.pkgs; [ ]; 43 + 44 + zigWrapperLibs = with env.pkgs; [ ]; 45 + }); 46 + 47 + apps.bundle = { 48 + type = "app"; 49 + program = "${packages.foreign}/bin/default"; 50 + }; 51 + 52 + # nix run . 53 + apps.default = env.app [ ] "zig build run --release=fast -- \"$@\""; 54 + 55 + # nix run .#build 56 + apps.build = env.app [ ] "zig build --release=fast \"$@\""; 57 + 58 + # nix run .#test 59 + apps.test = env.app [ ] "zig build test --release=fast -- \"$@\""; 60 + 61 + # nix run .#docs 62 + apps.docs = env.app [ ] "zig build docs --release=fast -- \"$@\""; 63 + 64 + # nix run .#zig2nix 65 + apps.zig2nix = env.app [ ] "zig2nix \"$@\""; 66 + 67 + # nix develop 68 + devShells.default = env.mkShell { 69 + nativeBuildInputs = with env.pkgs; [ ]; 70 + }; 71 + } 72 + )); 73 + }
+322
src/app/cli.zig
··· 1 + //! CLI orchestration: builds commands, executes update workflow, manages git staging. 2 + //! Two-phase design: init (allocation allowed) โ†’ static (zero allocation). 3 + //! All side effects injected via Deps for testability. 4 + 5 + const std = @import("std"); 6 + const io = @import("../core/io.zig"); 7 + const cmd = @import("../core/commands.zig"); 8 + const process = @import("../core/process.zig"); 9 + const Config = @import("config.zig").Config; 10 + 11 + /// Injected side effects: shell execution, user confirmation, UI output. 12 + /// Each field is a function pointer enabling full mock substitution in tests. 13 + pub const Deps = struct { 14 + /// Run a shell command. Returns exit code. 15 + run: *const fn (std.Io, []const u8, process.RunOpts) anyerror!i32, 16 + /// Prompt user for yes/no confirmation. `default_yes` sets default, `message` overrides prompt text. 17 + confirm: *const fn (*std.Io.Writer, bool, ?[]const u8) anyerror!bool, 18 + /// Print a styled section title to output. 19 + printTitle: *const fn (*std.Io.Writer, []const u8) anyerror!void, 20 + /// Print the full configuration summary to output. 21 + configPrint: *const fn (*std.Io.Writer, Config) anyerror!void, 22 + }; 23 + 24 + /// Pre-built command strings. All allocation happens in buildCommands(). 25 + /// After buildCommands() returns, zero allocation is needed. 26 + pub const Commands = struct { 27 + git_pull: []const u8, 28 + git_diff: []const u8, 29 + git_status: []const u8, 30 + git_add: []const u8, 31 + nix_update: []const u8, 32 + nix_rebuild: []const u8, 33 + nix_keep: []const u8, 34 + 35 + /// nixDiff is a compile-time constant. No allocation needed. 36 + pub const nix_diff = cmd.nixDiff; 37 + }; 38 + 39 + /// Phase 1: Init. Build all command strings. Allocation allowed. 40 + pub fn buildCommands(config: Config, allocator: std.mem.Allocator) !Commands { 41 + // Assert preconditions. 42 + std.debug.assert(config.repo.len > 0); 43 + std.debug.assert(config.hostname.len > 0); 44 + std.debug.assert(config.keep > 0); 45 + 46 + return Commands{ 47 + .git_pull = try cmd.gitPull(allocator, config.repo), 48 + .git_diff = try cmd.gitDiff(allocator, config.repo), 49 + .git_status = try cmd.gitStatus(allocator, config.repo), 50 + .git_add = try cmd.gitAdd(allocator, config.repo), 51 + .nix_update = try cmd.nixUpdate(allocator, config.repo), 52 + .nix_rebuild = try cmd.nixRebuild(allocator, config.repo, config.hostname), 53 + .nix_keep = try cmd.nixKeep(allocator, config.keep), 54 + }; 55 + } 56 + 57 + /// Phase 2: Static. Execute all operations using pre-built commands. 58 + /// Zero allocation in this phase. 59 + pub fn execute( 60 + cli_io: std.Io, 61 + writer: *std.Io.Writer, 62 + config: Config, 63 + commands: Commands, 64 + deps: Deps, 65 + ) !void { 66 + // Assert preconditions: config must be valid, commands must not be empty. 67 + std.debug.assert(config.repo.len > 0); 68 + std.debug.assert(config.hostname.len > 0); 69 + 70 + try deps.printTitle(writer, "ZIX Configuration"); 71 + try deps.configPrint(writer, config); 72 + 73 + if (try deps.confirm(writer, true, null)) { 74 + // Git pull. 75 + try deps.printTitle(writer, "Git Pull"); 76 + const pull_status = try deps.run(cli_io, commands.git_pull, .{}); 77 + if (pull_status != 0) { 78 + try io.printTo(writer, "{s}Failed to pull changes{s}\n", .{ io.Red, io.Reset }); 79 + return error.GitPullFailed; 80 + } 81 + 82 + // Nix update (optional). 83 + if (config.update) { 84 + try deps.printTitle(writer, "Nix Update"); 85 + _ = try deps.run(cli_io, commands.nix_update, .{}); 86 + } 87 + 88 + // Stage git changes. 89 + try stageGitChanges(cli_io, writer, commands, deps); 90 + 91 + // Nixos rebuild. 92 + try deps.printTitle(writer, "Nixos Rebuild"); 93 + _ = try deps.run(cli_io, commands.nix_rebuild, .{}); 94 + _ = try deps.run(cli_io, commands.nix_keep, .{}); 95 + 96 + // Nix diff (optional). 97 + if (config.diff) { 98 + try deps.printTitle(writer, "Nix Diff"); 99 + _ = try deps.run(cli_io, Commands.nix_diff, .{}); 100 + } 101 + } 102 + } 103 + 104 + /// Check for unstaged changes via git-diff, prompt user, optionally git-add. 105 + /// Returns silently if no changes detected. 106 + fn stageGitChanges( 107 + cli_io: std.Io, 108 + writer: *std.Io.Writer, 109 + commands: Commands, 110 + deps: Deps, 111 + ) !void { 112 + // Assert preconditions: command strings must have been built. 113 + std.debug.assert(commands.git_diff.len > 0); 114 + 115 + // diff --exit-code returns 1 when there are unstaged changes. 116 + const has_changes = try deps.run(cli_io, commands.git_diff, .{ .output = false }); 117 + if (has_changes != 1) return; 118 + 119 + try deps.printTitle(writer, "Git Changes"); 120 + _ = try deps.run(cli_io, commands.git_status, .{}); 121 + 122 + if (try deps.confirm( 123 + writer, 124 + true, 125 + "Do you want to add these changes to the stage?", 126 + )) { 127 + _ = deps.run(cli_io, commands.git_add, .{}) catch |err| { 128 + try io.printTo( 129 + writer, 130 + "{s}Failed to add changes to the stage: {}{s}\n", 131 + .{ io.Red, err, io.Reset }, 132 + ); 133 + return; 134 + }; 135 + try io.printTo( 136 + writer, 137 + "{s}Changes added to git stage successfully{s}\n", 138 + .{ io.Green, io.Reset }, 139 + ); 140 + } else { 141 + try io.printTo( 142 + writer, 143 + "{s}Changes not added to stage{s}\n", 144 + .{ io.Red, io.Reset }, 145 + ); 146 + } 147 + } 148 + 149 + // --- Test Mocks --- 150 + 151 + /// Returns 1 for git-diff commands (simulating unstaged changes), 0 otherwise. 152 + fn mockRun(_: std.Io, command: []const u8, _: process.RunOpts) anyerror!i32 { 153 + if (std.mem.startsWith(u8, command, "git -C")) { 154 + if (std.mem.indexOf(u8, command, "diff --exit-code") != null) return 1; 155 + } 156 + return 0; 157 + } 158 + 159 + /// Tracks call count. Returns true for all calls except the second. 160 + var confirm_call_count: u32 = 0; 161 + fn mockConfirmCounting( 162 + _: *std.Io.Writer, 163 + _: bool, 164 + _: ?[]const u8, 165 + ) anyerror!bool { 166 + confirm_call_count += 1; 167 + return confirm_call_count != 2; 168 + } 169 + 170 + /// Always confirms yes. 171 + fn mockConfirmTrue( 172 + _: *std.Io.Writer, 173 + _: bool, 174 + _: ?[]const u8, 175 + ) anyerror!bool { 176 + return true; 177 + } 178 + 179 + /// Always declines (returns false). 180 + fn mockConfirmFalse( 181 + _: *std.Io.Writer, 182 + _: bool, 183 + _: ?[]const u8, 184 + ) anyerror!bool { 185 + return false; 186 + } 187 + 188 + /// No-op title printer for tests. 189 + fn mockPrintTitle(_: *std.Io.Writer, _: []const u8) anyerror!void {} 190 + 191 + /// No-op config printer for tests. 192 + fn mockConfigPrint(_: *std.Io.Writer, _: Config) anyerror!void {} 193 + 194 + // --- Tests --- 195 + 196 + /// Helper: build a minimal Config with given flags. 197 + fn testConfig(update: bool, diff: bool) Config { 198 + return Config{ 199 + .repo = "r", 200 + .hostname = "h", 201 + .keep = 1, 202 + .update = update, 203 + .diff = diff, 204 + }; 205 + } 206 + 207 + test "execute branches" { 208 + var memory: [4096]u8 = undefined; 209 + var fba = std.heap.FixedBufferAllocator.init(&memory); 210 + const allocator = fba.allocator(); 211 + 212 + var buf: [4096]u8 = undefined; 213 + var writer = std.Io.Writer.fixed(&buf); 214 + const cli_io = std.testing.io; 215 + 216 + const deps = Deps{ 217 + .run = mockRun, 218 + .confirm = mockConfirmTrue, 219 + .printTitle = mockPrintTitle, 220 + .configPrint = mockConfigPrint, 221 + }; 222 + 223 + // Phase 1: build commands. 224 + const commands = try buildCommands(testConfig(true, true), allocator); 225 + 226 + // Phase 2: execute (zero alloc from here). 227 + 228 + // confirm false => early return. 229 + const no_deps = Deps{ 230 + .run = mockRun, 231 + .confirm = mockConfirmFalse, 232 + .printTitle = mockPrintTitle, 233 + .configPrint = mockConfigPrint, 234 + }; 235 + try execute(cli_io, &writer, testConfig(false, false), commands, no_deps); 236 + 237 + // git pull fails. 238 + const fail_deps = Deps{ 239 + .run = struct { 240 + fn f(_: std.Io, _: []const u8, _: process.RunOpts) anyerror!i32 { 241 + return 1; 242 + } 243 + }.f, 244 + .confirm = mockConfirmTrue, 245 + .printTitle = mockPrintTitle, 246 + .configPrint = mockConfigPrint, 247 + }; 248 + try std.testing.expectError( 249 + error.GitPullFailed, 250 + execute(cli_io, &writer, testConfig(false, false), commands, fail_deps), 251 + ); 252 + 253 + // update + diff + git changes + add. 254 + try execute(cli_io, &writer, testConfig(true, true), commands, deps); 255 + 256 + // no git changes (diff returns 0). 257 + const no_changes_deps = Deps{ 258 + .run = struct { 259 + fn f(_: std.Io, _: []const u8, _: process.RunOpts) anyerror!i32 { 260 + return 0; 261 + } 262 + }.f, 263 + .confirm = mockConfirmTrue, 264 + .printTitle = mockPrintTitle, 265 + .configPrint = mockConfigPrint, 266 + }; 267 + try execute(cli_io, &writer, testConfig(false, false), commands, no_changes_deps); 268 + } 269 + 270 + test "execute git add failure" { 271 + var memory: [4096]u8 = undefined; 272 + var fba = std.heap.FixedBufferAllocator.init(&memory); 273 + const allocator = fba.allocator(); 274 + 275 + var buf: [4096]u8 = undefined; 276 + var writer = std.Io.Writer.fixed(&buf); 277 + const cli_io = std.testing.io; 278 + 279 + const commands = try buildCommands(testConfig(false, true), allocator); 280 + 281 + const add_fail_deps = Deps{ 282 + .run = struct { 283 + fn f(_: std.Io, c: []const u8, _: process.RunOpts) anyerror!i32 { 284 + if (std.mem.indexOf(u8, c, "add .") != null) return error.MockError; 285 + if (std.mem.indexOf(u8, c, "diff --exit-code") != null) return 1; 286 + return 0; 287 + } 288 + }.f, 289 + .confirm = mockConfirmTrue, 290 + .printTitle = mockPrintTitle, 291 + .configPrint = mockConfigPrint, 292 + }; 293 + 294 + try execute(cli_io, &writer, testConfig(false, true), commands, add_fail_deps); 295 + const out = std.mem.sliceTo(&buf, 0); 296 + try std.testing.expect(std.mem.indexOf(u8, out, "Failed to add changes") != null); 297 + try std.testing.expect(std.mem.indexOf(u8, out, "successfully") == null); 298 + } 299 + 300 + test "execute decline add changes" { 301 + var memory: [4096]u8 = undefined; 302 + var fba = std.heap.FixedBufferAllocator.init(&memory); 303 + const allocator = fba.allocator(); 304 + 305 + var buf: [4096]u8 = undefined; 306 + var writer = std.Io.Writer.fixed(&buf); 307 + const cli_io = std.testing.io; 308 + 309 + const commands = try buildCommands(testConfig(false, true), allocator); 310 + 311 + confirm_call_count = 0; 312 + const decline_deps = Deps{ 313 + .run = mockRun, 314 + .confirm = mockConfirmCounting, 315 + .printTitle = mockPrintTitle, 316 + .configPrint = mockConfigPrint, 317 + }; 318 + try execute(cli_io, &writer, testConfig(false, true), commands, decline_deps); 319 + const out = std.mem.sliceTo(&buf, 0); 320 + try std.testing.expect(std.mem.indexOf(u8, out, "not added") != null); 321 + try std.testing.expect(std.mem.indexOf(u8, out, "successfully") == null); 322 + }
+80
src/app/config.zig
··· 1 + //! Application configuration for zix. 2 + //! 3 + //! Defines `Config` with defaults, validation, and round-trip tests. 4 + //! Hostname defaults to OS hostname; repo to `~/.dotfiles`; keep to 10. 5 + 6 + const std = @import("std"); 7 + 8 + /// CLI configuration struct. All fields have sensible defaults. 9 + pub const Config = struct { 10 + /// Path to the dotfiles git repository. 11 + repo: []const u8, 12 + /// NixOS hostname for flake rebuild target. 13 + hostname: []const u8, 14 + /// Number of generations to keep when pruning. 15 + keep: u8, 16 + /// Whether to run `nix flake update` before rebuild. 17 + update: bool, 18 + /// Whether to show nix profile diff-closures after rebuild. 19 + diff: bool, 20 + 21 + /// Returns a Config with OS hostname, default repo, and keep=10. 22 + /// Caller must provide a buffer of `HOST_NAME_MAX` bytes for hostname. 23 + pub fn defaults(hostname_buf: *[std.posix.HOST_NAME_MAX]u8) Config { 24 + return .{ 25 + .repo = "~/.dotfiles", 26 + .hostname = std.posix.gethostname(hostname_buf) catch "unknown", 27 + .keep = 10, 28 + .update = false, 29 + .diff = false, 30 + }; 31 + } 32 + 33 + /// Validates config invariants. Returns an error message or null if valid. 34 + pub fn validate(self: Config) ?[]const u8 { 35 + if (self.repo.len == 0) return "repo path cannot be empty"; 36 + if (self.keep == 0) return "generations to keep must be > 0"; 37 + return null; 38 + } 39 + }; 40 + 41 + test "defaults produces valid config" { 42 + var buf: [std.posix.HOST_NAME_MAX]u8 = undefined; 43 + const config = Config.defaults(&buf); 44 + try std.testing.expect(config.repo.len > 0); 45 + try std.testing.expect(config.hostname.len > 0); 46 + try std.testing.expect(config.keep > 0); 47 + } 48 + 49 + test "validate accepts valid config" { 50 + const config = Config{ 51 + .repo = "~/.dotfiles", 52 + .hostname = "nixos", 53 + .keep = 10, 54 + .update = false, 55 + .diff = false, 56 + }; 57 + try std.testing.expectEqual(@as(?[]const u8, null), config.validate()); 58 + } 59 + 60 + test "validate rejects empty repo" { 61 + const config = Config{ 62 + .repo = "", 63 + .hostname = "nixos", 64 + .keep = 10, 65 + .update = false, 66 + .diff = false, 67 + }; 68 + try std.testing.expect(config.validate() != null); 69 + } 70 + 71 + test "validate rejects zero keep" { 72 + const config = Config{ 73 + .repo = "~/.dotfiles", 74 + .hostname = "nixos", 75 + .keep = 0, 76 + .update = false, 77 + .diff = false, 78 + }; 79 + try std.testing.expect(config.validate() != null); 80 + }
+234
src/app/init.zig
··· 1 + //! Application entry point and argument dispatch for zix. 2 + //! 3 + //! Parses CLI flags, validates config, transitions StaticAllocator 4 + //! from .init to .static, and delegates to `cli.execute`. 5 + 6 + const std = @import("std"); 7 + const io = @import("../core/io.zig"); 8 + const ui = @import("../core/ui.zig"); 9 + const cli_module = @import("./cli.zig"); 10 + const buildCommands = cli_module.buildCommands; 11 + const execute = cli_module.execute; 12 + const equal = std.mem.eql; 13 + const process = @import("../core/process.zig"); 14 + const Config = @import("config.zig").Config; 15 + const StaticAllocator = @import("../core/static_allocator.zig"); 16 + const VERSION = @import("zon").version; 17 + 18 + /// Result of argument parsing: continue execution, show help, or show version. 19 + const ParseResult = enum { 20 + success, 21 + help, 22 + version, 23 + }; 24 + 25 + /// Main orchestration function. Parses args, validates config, 26 + /// transitions allocator to .static, and runs CLI workflow. 27 + /// Returns error on failure, exits gracefully for help/version. 28 + pub fn run( 29 + cli_io: std.Io, 30 + writer: *std.Io.Writer, 31 + args: []const []const u8, 32 + deps: cli_module.Deps, 33 + static_allocator: *StaticAllocator, 34 + ) !void { 35 + std.debug.assert(args.len >= 1); 36 + 37 + const allocator = static_allocator.allocator(); 38 + 39 + var hostname_buf: [std.posix.HOST_NAME_MAX]u8 = undefined; 40 + var config = Config.defaults(&hostname_buf); 41 + 42 + // No args beyond program name โ€” run with defaults. 43 + if (args.len <= 1) { 44 + std.debug.assert(config.keep > 0); 45 + const commands = try buildCommands(config, allocator); 46 + static_allocator.transition_from_init_to_static(); 47 + return try execute(cli_io, writer, config, commands, deps); 48 + } 49 + 50 + const result = parseFlags(args, &config, writer); 51 + switch (result) { 52 + .help => return try ui.printHelp(writer), 53 + .version => return try ui.printVersion(writer, VERSION), 54 + .success => {}, 55 + } 56 + 57 + if (config.validate()) |error_message| { 58 + return try io.printTo(writer, "{s}Error: {s}{s}\n", .{ io.Red, error_message, io.Reset }); 59 + } 60 + 61 + const commands = try buildCommands(config, allocator); 62 + static_allocator.transition_from_init_to_static(); 63 + return try execute(cli_io, writer, config, commands, deps); 64 + } 65 + 66 + /// Parses CLI flags and subcommands. Mutates config in place. 67 + /// Returns `.help` or `.version` for early exit, `.success` to continue. 68 + fn parseFlags( 69 + args: []const []const u8, 70 + config: *Config, 71 + writer: *std.Io.Writer, 72 + ) ParseResult { 73 + var arg_index: u32 = 1; 74 + while (arg_index < args.len) : (arg_index += 1) { 75 + const arg = args[arg_index]; 76 + std.debug.assert(arg.len > 0); 77 + if (arg[0] == '-') { 78 + for (arg[1..]) |flag| { 79 + switch (flag) { 80 + 'h' => return .help, 81 + 'v' => return .version, 82 + 'd' => config.diff = true, 83 + 'u' => config.update = true, 84 + 'r', 'n', 'k' => { 85 + arg_index += 1; 86 + if (arg_index >= args.len) { 87 + _ = io.printTo( 88 + writer, 89 + "{s}Error: \"-{c}\" flag requires an argument\n{s}", 90 + .{ io.Red, flag, io.Reset }, 91 + ) catch {}; 92 + return .success; 93 + } 94 + if (flag == 'r') config.repo = args[arg_index]; 95 + if (flag == 'n') config.hostname = args[arg_index]; 96 + if (flag == 'k') { 97 + const number = std.fmt.parseInt(u8, args[arg_index], 10) catch { 98 + _ = io.printTo( 99 + writer, 100 + "{s}Error: Value of \"-k\" flag is not numeric.\n{s}", 101 + .{ io.Red, io.Reset }, 102 + ) catch {}; 103 + return .success; 104 + }; 105 + config.keep = number; 106 + } 107 + }, 108 + else => { 109 + _ = io.printTo( 110 + writer, 111 + "{s}Error: Unknown flag \"-{c}\"\n{s}", 112 + .{ io.Red, flag, io.Reset }, 113 + ) catch {}; 114 + return .success; 115 + }, 116 + } 117 + } 118 + } else { 119 + if (equal(u8, arg, "help")) return .help; 120 + if (equal(u8, arg, "version")) return .version; 121 + _ = io.printTo( 122 + writer, 123 + "{s}Error: Unknown argument \"{s}\"\n{s}", 124 + .{ io.Red, arg, io.Reset }, 125 + ) catch {}; 126 + return .success; 127 + } 128 + } 129 + return .success; 130 + } 131 + 132 + /// No-op mock for process.run in tests. 133 + fn mockRun(_: std.Io, _: []const u8, _: process.RunOpts) anyerror!i32 { 134 + return 0; 135 + } 136 + /// Always-yes mock for ui.confirm in tests. 137 + noinline fn mockConfirm( 138 + _: *std.Io.Writer, 139 + _: bool, 140 + _: ?[]const u8, 141 + ) anyerror!bool { 142 + return true; 143 + } 144 + /// No-op mock for ui.printTitle in tests. 145 + noinline fn mockPrintTitle(_: *std.Io.Writer, _: []const u8) anyerror!void {} 146 + /// No-op mock for ui.configPrint in tests. 147 + noinline fn mockConfigPrint(_: *std.Io.Writer, _: Config) anyerror!void {} 148 + 149 + test "run flag branches" { 150 + const test_io = std.testing.io; 151 + 152 + var memory: [4096]u8 = undefined; 153 + var fba = std.heap.FixedBufferAllocator.init(&memory); 154 + 155 + const TestCase = struct { 156 + args: []const []const u8, 157 + expect_contains: ?[]const u8 = null, 158 + }; 159 + const cases = &[_]TestCase{ 160 + .{ .args = &.{ "zix", "-h" }, .expect_contains = "ZIX" }, 161 + .{ .args = &.{ "zix", "-v" }, .expect_contains = VERSION }, 162 + .{ .args = &.{ "zix", "help" }, .expect_contains = "ZIX" }, 163 + .{ .args = &.{ "zix", "version" }, .expect_contains = VERSION }, 164 + .{ .args = &.{ "zix", "unknown" }, .expect_contains = "Unknown argument" }, 165 + .{ .args = &.{ "zix", "-r" }, .expect_contains = "requires an argument" }, 166 + .{ .args = &.{ "zix", "-n" }, .expect_contains = "requires an argument" }, 167 + .{ .args = &.{ "zix", "-k", "abc" }, .expect_contains = "not numeric" }, 168 + .{ .args = &.{ "zix", "-k", "5", "-h" }, .expect_contains = "ZIX" }, 169 + .{ .args = &.{ "zix", "-x" }, .expect_contains = "Unknown flag" }, 170 + .{ .args = &.{ "zix", "-d" }, .expect_contains = null }, 171 + .{ .args = &.{ "zix", "-u" }, .expect_contains = null }, 172 + .{ .args = &.{ "zix", "-d", "help" }, .expect_contains = "ZIX" }, 173 + .{ .args = &.{ "zix", "-d", "unknown" }, .expect_contains = "Unknown argument" }, 174 + }; 175 + 176 + const mock_deps = cli_module.Deps{ 177 + .run = mockRun, 178 + .confirm = mockConfirm, 179 + .printTitle = mockPrintTitle, 180 + .configPrint = mockConfigPrint, 181 + }; 182 + 183 + for (cases) |tc| { 184 + fba.reset(); 185 + var static_alloc = StaticAllocator.init(fba.allocator()); 186 + var buf = [_]u8{0} ** 2048; 187 + var writer = std.Io.Writer.fixed(&buf); 188 + run(test_io, &writer, tc.args, mock_deps, &static_alloc) catch continue; 189 + if (tc.expect_contains) |needle| { 190 + const out = std.mem.sliceTo(&buf, 0); 191 + try std.testing.expect(std.mem.indexOf(u8, out, needle) != null); 192 + } 193 + } 194 + } 195 + 196 + test "run reaches cli" { 197 + const test_io = std.testing.io; 198 + var buf: [256]u8 = undefined; 199 + var writer = std.Io.Writer.fixed(&buf); 200 + 201 + var memory: [4096]u8 = undefined; 202 + var fba = std.heap.FixedBufferAllocator.init(&memory); 203 + var static_allocator = StaticAllocator.init(fba.allocator()); 204 + 205 + const mock_deps = cli_module.Deps{ 206 + .run = mockRun, 207 + .confirm = mockConfirm, 208 + .printTitle = mockPrintTitle, 209 + .configPrint = mockConfigPrint, 210 + }; 211 + 212 + try run(test_io, &writer, &.{"zix"}, mock_deps, &static_allocator); 213 + } 214 + 215 + test "run rejects invalid config via flags" { 216 + const test_io = std.testing.io; 217 + var buf: [256]u8 = undefined; 218 + var writer = std.Io.Writer.fixed(&buf); 219 + 220 + var memory: [4096]u8 = undefined; 221 + var fba = std.heap.FixedBufferAllocator.init(&memory); 222 + var static_allocator = StaticAllocator.init(fba.allocator()); 223 + 224 + const mock_deps = cli_module.Deps{ 225 + .run = mockRun, 226 + .confirm = mockConfirm, 227 + .printTitle = mockPrintTitle, 228 + .configPrint = mockConfigPrint, 229 + }; 230 + 231 + try run(test_io, &writer, &.{ "zix", "-k", "0" }, mock_deps, &static_allocator); 232 + const out = std.mem.sliceTo(&buf, 0); 233 + try std.testing.expect(std.mem.indexOf(u8, out, "Error") != null); 234 + }
+103
src/core/commands.zig
··· 1 + //! Shell command builders for git and nix operations. 2 + //! 3 + //! Each function allocates and returns a shell command string. 4 + //! All assert preconditions (e.g., non-empty repo paths). 5 + 6 + const std = @import("std"); 7 + 8 + /// Returns `git -C <repo> pull`. 9 + pub fn gitPull(allocator: std.mem.Allocator, repo: []const u8) ![]const u8 { 10 + std.debug.assert(repo.len > 0); 11 + return std.fmt.allocPrint(allocator, "git -C {s} pull", .{repo}); 12 + } 13 + 14 + /// Returns `git -C <repo> diff --exit-code`. Exit code 1 means changes exist. 15 + pub fn gitDiff(allocator: std.mem.Allocator, repo: []const u8) ![]const u8 { 16 + std.debug.assert(repo.len > 0); 17 + return std.fmt.allocPrint(allocator, "git -C {s} diff --exit-code", .{repo}); 18 + } 19 + 20 + /// Returns `git -C <repo> status --porcelain`. 21 + pub fn gitStatus(allocator: std.mem.Allocator, repo: []const u8) ![]const u8 { 22 + std.debug.assert(repo.len > 0); 23 + return std.fmt.allocPrint(allocator, "git -C {s} status --porcelain", .{repo}); 24 + } 25 + 26 + /// Returns `git -C <repo> add .`. 27 + pub fn gitAdd(allocator: std.mem.Allocator, repo: []const u8) ![]const u8 { 28 + std.debug.assert(repo.len > 0); 29 + return std.fmt.allocPrint(allocator, "git -C {s} add .", .{repo}); 30 + } 31 + 32 + /// Returns `nix flake update --flake <repo>`. 33 + pub fn nixUpdate(allocator: std.mem.Allocator, repo: []const u8) ![]const u8 { 34 + std.debug.assert(repo.len > 0); 35 + return std.fmt.allocPrint(allocator, "nix flake update --flake {s}", .{repo}); 36 + } 37 + 38 + /// Returns `sudo nixos-rebuild switch --flake <repo>#<hostname> --show-trace`. 39 + pub fn nixRebuild( 40 + allocator: std.mem.Allocator, 41 + repo: []const u8, 42 + hostname: []const u8, 43 + ) ![]const u8 { 44 + std.debug.assert(repo.len > 0); 45 + std.debug.assert(hostname.len > 0); 46 + return std.fmt.allocPrint( 47 + allocator, 48 + "sudo nixos-rebuild switch --flake {s}#{s} --show-trace", 49 + .{ repo, hostname }, 50 + ); 51 + } 52 + 53 + /// Returns `sudo nix-env --profile /nix/var/nix/profiles/system --delete-generations +<n>`. 54 + pub fn nixKeep( 55 + allocator: std.mem.Allocator, 56 + generations_to_keep: u8, 57 + ) ![]const u8 { 58 + std.debug.assert(generations_to_keep > 0); 59 + return std.fmt.allocPrint( 60 + allocator, 61 + "sudo nix-env --profile /nix/var/nix/profiles/system" ++ " --delete-generations +{d}", 62 + .{generations_to_keep}, 63 + ); 64 + } 65 + 66 + /// Static command string for nix profile diff-closures with awk filtering. 67 + const nix_diff_profile = " --profile /nix/var/nix/profiles/system"; 68 + const nix_diff_awk = " | awk '/Version/{print; exit} 1'"; 69 + 70 + /// Pre-built nix diff command. No allocation needed โ€” comptime concatenated. 71 + pub const nixDiff = 72 + "nix profile diff-closures" ++ nix_diff_profile ++ " | tac" ++ nix_diff_awk ++ " | tac"; 73 + 74 + test "command strings" { 75 + const alloc = std.testing.allocator; 76 + const s0 = try gitPull(alloc, "/repo"); 77 + defer alloc.free(s0); 78 + try std.testing.expectEqualStrings("git -C /repo pull", s0); 79 + const s1 = try gitDiff(alloc, "/repo"); 80 + defer alloc.free(s1); 81 + try std.testing.expectEqualStrings("git -C /repo diff --exit-code", s1); 82 + const s2 = try gitStatus(alloc, "/repo"); 83 + defer alloc.free(s2); 84 + try std.testing.expectEqualStrings("git -C /repo status --porcelain", s2); 85 + const s3 = try gitAdd(alloc, "/repo"); 86 + defer alloc.free(s3); 87 + try std.testing.expectEqualStrings("git -C /repo add .", s3); 88 + const s4 = try nixUpdate(alloc, "/repo"); 89 + defer alloc.free(s4); 90 + try std.testing.expectEqualStrings("nix flake update --flake /repo", s4); 91 + const s5 = try nixRebuild(alloc, "/repo", "host"); 92 + defer alloc.free(s5); 93 + try std.testing.expectEqualStrings( 94 + "sudo nixos-rebuild switch --flake /repo#host --show-trace", 95 + s5, 96 + ); 97 + const s6 = try nixKeep(alloc, 5); 98 + defer alloc.free(s6); 99 + try std.testing.expectEqualStrings( 100 + "sudo nix-env --profile /nix/var/nix/profiles/system" ++ " --delete-generations +5", 101 + s6, 102 + ); 103 + }
+52
src/core/io.zig
··· 1 + //! I/O utilities: formatted output and ANSI style constants. 2 + //! 3 + //! `printTo` wraps `Writer.print` + `flush` for immediate output. 4 + //! Style constants provide shorthand ANSI escape sequences. 5 + 6 + const std = @import("std"); 7 + 8 + // --- Formatting --- 9 + 10 + /// Writes formatted text to `writer` and flushes immediately. 11 + /// Asserts format string is non-empty. 12 + pub fn printTo( 13 + writer: *std.Io.Writer, 14 + comptime format: []const u8, 15 + args: anytype, 16 + ) !void { 17 + // Assert preconditions: format must not be empty. 18 + std.debug.assert(format.len > 0); 19 + 20 + try writer.print(format, args); 21 + try writer.flush(); 22 + } 23 + 24 + // --- ANSI Styles --- 25 + 26 + /// ANSI foreground color escape sequences and reset codes. 27 + pub const Red = "\x1b[31m"; 28 + pub const Green = "\x1b[32m"; 29 + pub const Yellow = "\x1b[33m"; 30 + pub const Blue = "\x1b[34m"; 31 + pub const Magenta = "\x1b[35m"; 32 + pub const Cyan = "\x1b[36m"; 33 + pub const Gray = "\x1b[37m"; 34 + pub const Black = "\x1b[30m"; 35 + pub const Reset = "\x1b[0m"; 36 + pub const Bold = "\x1b[1m"; 37 + pub const Underline = "\x1b[4m"; 38 + 39 + test "printTo writes exact bytes" { 40 + var buf: [256]u8 = undefined; 41 + var writer = std.Io.Writer.fixed(&buf); 42 + try printTo(&writer, "hello {d} {s}", .{ 42, "world" }); 43 + try std.testing.expectEqualStrings("hello 42 world", buf[0..14]); 44 + } 45 + 46 + test "style constants are non-empty" { 47 + const fields = @typeInfo(@This()).@"struct".fields; 48 + inline for (fields) |field| { 49 + const val = @field(@This(), field.name); 50 + try std.testing.expect(val.len > 0); 51 + } 52 + }
+35
src/core/process.zig
··· 1 + //! Shell process execution wrapper. 2 + //! 3 + //! Spawns commands via `sh -c` with configurable stdout/stderr suppression. 4 + 5 + const std = @import("std"); 6 + 7 + /// Options for process execution. `output` controls whether 8 + /// stdout and stderr are inherited (true) or suppressed (false). 9 + pub const RunOpts = struct { output: bool = true }; 10 + 11 + /// Spawns `sh -c <command>` and returns the exit code. 12 + /// Asserts command is non-empty. Returns 1 for non-exit terminations. 13 + pub fn run(io: std.Io, command: []const u8, opts: RunOpts) !i32 { 14 + std.debug.assert(command.len > 0); 15 + 16 + const shellCommand = [_][]const u8{ "sh", "-c", command }; 17 + var child = try std.process.spawn(io, .{ 18 + .argv = &shellCommand, 19 + .stdin = .inherit, 20 + .stdout = if (opts.output) .inherit else .ignore, 21 + .stderr = if (opts.output) .inherit else .ignore, 22 + }); 23 + const term = try child.wait(io); 24 + switch (term) { 25 + .exited => |code| return code, 26 + else => return 1, 27 + } 28 + } 29 + 30 + test "run basic commands" { 31 + const io = std.testing.io; 32 + try std.testing.expectEqual(@as(i32, 0), try run(io, "true", .{})); 33 + try std.testing.expectEqual(@as(i32, 1), try run(io, "false", .{})); 34 + try std.testing.expectEqual(@as(i32, 1), try run(io, "kill -9 $$", .{})); 35 + }
+131
src/core/static_allocator.zig
··· 1 + //! Three-phase allocator that enforces zero allocation at runtime. 2 + //! 3 + //! Phase .init: alloc, resize, and free are allowed. Used during startup. 4 + //! Phase .static: only free is allowed. Any alloc or resize panics. 5 + //! Phase .deinit: only free is allowed. Used during shutdown cleanup. 6 + //! 7 + //! The allocator wraps a parent allocator (typically ArenaAllocator). 8 + //! Free always delegates โ€” even in .static โ€” because std.fmt.allocPrint 9 + //! internally frees intermediate buffers. 10 + 11 + const std = @import("std"); 12 + const assert = std.debug.assert; 13 + const mem = std.mem; 14 + const Alignment = mem.Alignment; 15 + 16 + const StaticAllocator = @This(); 17 + 18 + parent_allocator: mem.Allocator, 19 + state: State, 20 + 21 + /// Allocator lifecycle states. 22 + const State = enum { 23 + /// Allow alloc, resize, and free. Used during startup/init. 24 + /// Accidentally calling free switches to .deinit for errdefer compatibility. 25 + init, 26 + /// Block all allocation. Runtime phase โ€” any alloc call panics. 27 + static, 28 + /// Allow free only. Shutdown phase. 29 + deinit, 30 + }; 31 + 32 + /// Creates a StaticAllocator wrapping `parent_allocator`. Starts in .init state. 33 + pub fn init(parent_allocator: mem.Allocator) StaticAllocator { 34 + return .{ 35 + .parent_allocator = parent_allocator, 36 + .state = .init, 37 + }; 38 + } 39 + 40 + /// Transitions from .init to .static. Panics if not in .init. 41 + /// Call after all startup allocation is complete. 42 + pub fn transition_from_init_to_static(self: *StaticAllocator) void { 43 + assert(self.state == .init); 44 + self.state = .static; 45 + } 46 + 47 + /// Transitions from .static to .deinit. Panics if in .init 48 + /// (transition_to_static was never called). 49 + pub fn transition_from_static_to_deinit(self: *StaticAllocator) void { 50 + assert(self.state == .static); 51 + self.state = .deinit; 52 + } 53 + 54 + /// Safe version: only transitions if currently in .static state. 55 + /// Used when early returns (help, version, errors) may skip transition_to_static. 56 + pub fn transition_from_static_to_deinit_if_static(self: *StaticAllocator) void { 57 + if (self.state == .static) self.state = .deinit; 58 + } 59 + 60 + /// Returns a std.mem.Allocator that delegates to this StaticAllocator. 61 + /// Asserts invariant: must be in .init or .static state to vend an allocator. 62 + pub fn allocator(self: *StaticAllocator) mem.Allocator { 63 + assert(self.state == .init or self.state == .static); 64 + return .{ 65 + .ptr = self, 66 + .vtable = &.{ 67 + .alloc = alloc, 68 + .resize = resize, 69 + .remap = remap, 70 + .free = free, 71 + }, 72 + }; 73 + } 74 + 75 + /// Allocates memory. Panics if not in .init state. 76 + fn alloc(ctx: *anyopaque, len: usize, ptr_align: Alignment, ret_addr: usize) ?[*]u8 { 77 + const self: *StaticAllocator = @ptrCast(@alignCast(ctx)); 78 + assert(self.state == .init); 79 + return self.parent_allocator.rawAlloc(len, ptr_align, ret_addr); 80 + } 81 + 82 + /// Resizes allocation. Panics if not in .init phase. 83 + fn resize(ctx: *anyopaque, buf: []u8, buf_align: Alignment, new_len: usize, ret_addr: usize) bool { 84 + const self: *StaticAllocator = @ptrCast(@alignCast(ctx)); 85 + assert(self.state == .init); 86 + return self.parent_allocator.rawResize(buf, buf_align, new_len, ret_addr); 87 + } 88 + 89 + /// Remaps allocation. Panics if not in .init phase. 90 + fn remap(ctx: *anyopaque, buf: []u8, buf_align: Alignment, new_len: usize, ret_addr: usize) ?[*]u8 { 91 + const self: *StaticAllocator = @ptrCast(@alignCast(ctx)); 92 + assert(self.state == .init); 93 + return self.parent_allocator.rawRemap(buf, buf_align, new_len, ret_addr); 94 + } 95 + 96 + /// Frees memory. Allowed in all states because std.fmt.allocPrint 97 + /// internally frees intermediate buffers during both .init and .deinit. 98 + fn free(ctx: *anyopaque, buf: []u8, buf_align: Alignment, ret_addr: usize) void { 99 + const self: *StaticAllocator = @ptrCast(@alignCast(ctx)); 100 + _ = self.state; 101 + return self.parent_allocator.rawFree(buf, buf_align, ret_addr); 102 + } 103 + 104 + test "StaticAllocator blocks alloc after transition" { 105 + var arena_instance = std.heap.ArenaAllocator.init(std.testing.allocator); 106 + defer arena_instance.deinit(); 107 + 108 + var static_alloc = StaticAllocator.init(arena_instance.allocator()); 109 + const a = static_alloc.allocator(); 110 + 111 + // During init: alloc works. 112 + const buf = try a.alloc(u8, 64); 113 + a.free(buf); 114 + 115 + // During init: resize works (realloc triggers resize vtable). 116 + const new_buf = try a.alloc(u8, 32); 117 + _ = a.resize(new_buf, 64); 118 + a.free(new_buf); 119 + 120 + // Transition to static. 121 + static_alloc.transition_from_init_to_static(); 122 + 123 + // During static: free still works. 124 + const buf2 = try arena_instance.allocator().alloc(u8, 64); 125 + a.free(buf2); 126 + 127 + // Transition to deinit. 128 + static_alloc.transition_from_static_to_deinit(); 129 + const buf3 = try arena_instance.allocator().alloc(u8, 32); 130 + a.free(buf3); 131 + }
+263
src/core/ui.zig
··· 1 + //! Terminal UI functions: titles, help, version, config display, and confirmation prompts. 2 + //! 3 + //! All output uses `io.printTo` for immediate flush. `confirm` reads 4 + //! stdin byte-by-byte via posix.read to avoid buffering issues. 5 + 6 + const std = @import("std"); 7 + const io = @import("io.zig"); 8 + const equal = std.mem.eql; 9 + 10 + /// Maximum border width for printed titles. 11 + const MAX_TITLE_LEN: u32 = 128; 12 + 13 + /// Prints a bordered, colored title with asterisk borders. 14 + /// Asserts text is non-empty and fits within MAX_TITLE_LEN. 15 + pub fn printTitle(writer: *std.Io.Writer, text: []const u8) !void { 16 + std.debug.assert(text.len > 0); 17 + std.debug.assert(text.len + 4 <= MAX_TITLE_LEN); 18 + 19 + var border_buf: [MAX_TITLE_LEN]u8 = undefined; 20 + const border = border_buf[0 .. text.len + 4]; 21 + for (border) |*c| { 22 + c.* = '*'; 23 + } 24 + try io.printTo( 25 + writer, 26 + "{s}\n{s}\n* {s}{s}{s} *\n{s}\n{s}", 27 + .{ io.Blue, border, io.Red, text, io.Blue, border, io.Reset }, 28 + ); 29 + } 30 + 31 + /// Prints the CLI help text with flag descriptions. 32 + pub fn printHelp(writer: *std.Io.Writer) !void { 33 + try io.printTo(writer, 34 + \\ 35 + \\ ***************************************************** 36 + \\ ZIX - A simple CLI tool to update your nixos system 37 + \\ ***************************************************** 38 + \\ -r : set repo path (default is $HOME/.dotfiles) 39 + \\ -n : set hostname (default is OS hostname) 40 + \\ -k : set generations to keep (default is 10) 41 + \\ -u : set update to true (default is false) 42 + \\ -d : set diff to true (default is false) 43 + \\ -h, help : Display this help message 44 + \\ -v, version : Display the current version 45 + \\ 46 + \\ 47 + , .{}); 48 + } 49 + 50 + /// Prints the version number with colored formatting. 51 + /// Asserts version string is non-empty. 52 + pub fn printVersion(writer: *std.Io.Writer, version: []const u8) !void { 53 + std.debug.assert(version.len > 0); 54 + try io.printTo( 55 + writer, 56 + "{s}\nZIX version: {s}{s}\n{s}", 57 + .{ io.Yellow, io.Cyan, version, io.Reset }, 58 + ); 59 + } 60 + 61 + /// Prints a single config line: `โ—‰ label = value`. Uses comptime type 62 + /// detection to format strings vs other types. Suppresses trailing 63 + /// newline for the last field when `options.new_line` is false. 64 + fn printConfigLine( 65 + writer: *std.Io.Writer, 66 + label: []const u8, 67 + value: anytype, 68 + options: struct { new_line: bool = true }, 69 + ) !void { 70 + std.debug.assert(label.len > 0); 71 + const value_fmt = comptime if (@TypeOf(value) == []const u8) "{" ++ "s}" else "{" ++ "}"; 72 + try io.printTo(writer, "{s}โ—‰ {s}{s}{s} = {s}" ++ value_fmt ++ "{s}{s}", .{ 73 + io.Cyan, 74 + io.Red, 75 + label, 76 + io.Reset, 77 + io.Cyan, 78 + value, 79 + io.Reset, 80 + if (options.new_line) "\n" else "", 81 + }); 82 + } 83 + 84 + /// Prints all config fields with colored labels via `printConfigLine`. 85 + /// The last field omits the trailing newline. 86 + pub fn configPrint(writer: *std.Io.Writer, config: @import("../app/config.zig").Config) !void { 87 + std.debug.assert(config.repo.len > 0); 88 + const fields = @typeInfo(@TypeOf(config)).@"struct".fields; 89 + inline for (fields, 0..) |field, i| { 90 + const is_last = i == fields.len - 1; 91 + const value = @field(config, field.name); 92 + try printConfigLine(writer, field.name, value, .{ .new_line = !is_last }); 93 + } 94 + } 95 + 96 + /// Reads a yes/no answer from stdin using raw posix.read. 97 + /// Shows a prompt with optional message and (Y/n) or (y/N) hint. 98 + /// Returns true for yes, false for no. Falls back to `default_value` 99 + /// for empty input. 100 + pub fn confirm( 101 + writer: *std.Io.Writer, 102 + default_value: bool, 103 + msg: ?[]const u8, 104 + ) !bool { 105 + if (msg) |value| std.debug.assert(value.len > 0); 106 + 107 + try writeConfirmPrompt(writer, default_value, msg); 108 + 109 + var buf: [256]u8 = undefined; 110 + var byte_index: u32 = 0; 111 + while (byte_index < buf.len - 1) { 112 + const bytes_read = std.posix.read(0, buf[byte_index .. byte_index + 1]) catch |err| { 113 + if (err == error.WouldBlock) continue; 114 + return err; 115 + }; 116 + if (bytes_read == 0) return false; 117 + if (buf[byte_index] == '\n') { 118 + return parseConfirmResponse( 119 + buf[0..byte_index], 120 + default_value, 121 + ); 122 + } 123 + byte_index += 1; 124 + } 125 + return false; 126 + } 127 + 128 + /// Writes the confirm prompt with colored (Y/n) or (y/N) hint. 129 + fn writeConfirmPrompt( 130 + writer: *std.Io.Writer, 131 + default_value: bool, 132 + msg: ?[]const u8, 133 + ) !void { 134 + const hint = if (default_value) 135 + std.fmt.comptimePrint("{s}(Y/n){s}", .{ io.Green, io.Reset }) 136 + else 137 + std.fmt.comptimePrint("{s}(y/N){s}", .{ io.Red, io.Reset }); 138 + if (msg) |value| { 139 + try io.printTo(writer, "\n{s}{s}{s} {s}: ", .{ io.Yellow, value, io.Reset, hint }); 140 + } else { 141 + try io.printTo(writer, "\n\n{s}Proceed?{s} {s}: ", .{ io.Yellow, io.Reset, hint }); 142 + } 143 + try writer.flush(); 144 + } 145 + 146 + /// Parses a yes/no response string. "y"/"yes" โ†’ true, "n"/"no" โ†’ false. 147 + /// Empty input falls back to `default_value`. Unknown input defaults to false. 148 + fn parseConfirmResponse(line: []const u8, default_value: bool) bool { 149 + std.debug.assert(line.len <= 256); 150 + 151 + var lower_buf: [256]u8 = undefined; 152 + if (line.len > lower_buf.len) return false; 153 + const lower = std.ascii.lowerString(&lower_buf, line); 154 + 155 + if (equal(u8, lower, "y") or equal(u8, lower, "yes")) return true; 156 + if (equal(u8, lower, "n") or equal(u8, lower, "no")) return false; 157 + if (equal(u8, lower, "") or line.len == 0) return default_value; 158 + return false; 159 + } 160 + 161 + /// Allocating variant of confirm that reads from a `std.Io.Reader`. 162 + /// Used in tests where stdin is replaced with a fixed buffer. 163 + fn confirmAlloc( 164 + reader: *std.Io.Reader, 165 + writer: *std.Io.Writer, 166 + default_value: bool, 167 + msg: ?[]const u8, 168 + ) !bool { 169 + if (msg) |value| std.debug.assert(value.len > 0); 170 + 171 + try writeConfirmPrompt(writer, default_value, msg); 172 + 173 + const line = reader.takeDelimiterExclusive('\n') catch |err| { 174 + if (err == error.EndOfStream) return false; 175 + return err; 176 + }; 177 + return parseConfirmResponse(line, default_value); 178 + } 179 + 180 + // --- Tests --- 181 + 182 + test "printTitle border format" { 183 + var buf: [256]u8 = undefined; 184 + var writer = std.Io.Writer.fixed(&buf); 185 + try printTitle(&writer, "ZIX"); 186 + const s = std.mem.sliceTo(&buf, '\n'); 187 + try std.testing.expect(s.len > 0); 188 + } 189 + 190 + test "printHelp writes help text" { 191 + var buf: [2048]u8 = undefined; 192 + var writer = std.Io.Writer.fixed(&buf); 193 + try printHelp(&writer); 194 + const out = std.mem.sliceTo(&buf, 0); 195 + try std.testing.expect(std.mem.indexOf(u8, out, "ZIX") != null); 196 + } 197 + 198 + test "printVersion writes version" { 199 + var buf: [256]u8 = undefined; 200 + var writer = std.Io.Writer.fixed(&buf); 201 + try printVersion(&writer, "1.0.0"); 202 + const out = std.mem.sliceTo(&buf, 0); 203 + try std.testing.expect(std.mem.indexOf(u8, out, "1.0.0") != null); 204 + } 205 + 206 + test "configPrint renders all fields" { 207 + var buf: [1024]u8 = undefined; 208 + var writer = std.Io.Writer.fixed(&buf); 209 + const config = @import("../app/config.zig").Config{ 210 + .repo = "~/.dotfiles", 211 + .hostname = "nixos", 212 + .keep = 10, 213 + .update = false, 214 + .diff = true, 215 + }; 216 + try configPrint(&writer, config); 217 + try std.testing.expect(std.mem.indexOf(u8, &buf, "repo") != null); 218 + try std.testing.expect(std.mem.indexOf(u8, &buf, "hostname") != null); 219 + try std.testing.expect(std.mem.indexOf(u8, &buf, "keep") != null); 220 + try std.testing.expect(std.mem.indexOf(u8, &buf, "update") != null); 221 + try std.testing.expect(std.mem.indexOf(u8, &buf, "diff") != null); 222 + } 223 + 224 + test "confirm responses" { 225 + const TestCase = struct { 226 + input: []const u8, 227 + default_value: bool, 228 + expected: bool, 229 + msg: ?[]const u8 = null, 230 + }; 231 + const cases = &[_]TestCase{ 232 + .{ .input = "y\n", .default_value = false, .expected = true }, 233 + .{ .input = "yes\n", .default_value = false, .expected = true }, 234 + .{ .input = "n\n", .default_value = true, .expected = false }, 235 + .{ .input = "no\n", .default_value = true, .expected = false }, 236 + .{ .input = "\n", .default_value = true, .expected = true }, 237 + .{ .input = "\n", .default_value = false, .expected = false }, 238 + .{ .input = "maybe\n", .default_value = true, .expected = false }, 239 + .{ .input = "maybe\n", .default_value = false, .expected = false }, 240 + .{ .input = "", .default_value = true, .expected = false }, 241 + .{ .input = "y\n", .default_value = false, .expected = true, .msg = "Sure" }, 242 + }; 243 + inline for (cases) |tc| { 244 + var wbuf: [512]u8 = undefined; 245 + var writer = std.Io.Writer.fixed(&wbuf); 246 + var reader = std.Io.Reader.fixed(tc.input); 247 + const result = try confirmAlloc( 248 + &reader, 249 + &writer, 250 + tc.default_value, 251 + tc.msg, 252 + ); 253 + try std.testing.expectEqual(tc.expected, result); 254 + } 255 + } 256 + 257 + test "confirm empty input" { 258 + var wbuf: [512]u8 = undefined; 259 + var writer = std.Io.Writer.fixed(&wbuf); 260 + var reader = std.Io.Reader.fixed("y\n"); 261 + const result = try confirmAlloc(&reader, &writer, true, null); 262 + try std.testing.expectEqual(true, result); 263 + }
+72
src/main.zig
··· 1 + //! Entry point for zix, a NixOS system update CLI. 2 + //! 3 + //! Initializes the allocator system, parses CLI arguments, injects 4 + //! dependencies, and delegates to `app.run`. Uses a two-phase 5 + //! `StaticAllocator` to enforce zero allocation at runtime. 6 + 7 + const std = @import("std"); 8 + const app = @import("app/init.zig"); 9 + const cli = @import("app/cli.zig"); 10 + const ui = @import("core/ui.zig"); 11 + const process = @import("core/process.zig"); 12 + const StaticAllocator = @import("core/static_allocator.zig"); 13 + 14 + /// Application entry point. Sets up the arena + static allocator, 15 + /// collects args, wires dependency injection, runs app logic, 16 + /// and tears down in reverse order. 17 + pub fn main(init: std.process.Init) !void { 18 + const io = init.io; 19 + const arena = init.arena; 20 + const args = init.minimal.args; 21 + 22 + // StaticAllocator wraps process arena. All allocation happens during 23 + // init only; arena is cleaned up automatically on process exit. 24 + var static_allocator = StaticAllocator.init(arena.allocator()); 25 + 26 + const allocator = static_allocator.allocator(); 27 + 28 + var args_list: std.ArrayList([]const u8) = .empty; 29 + defer args_list.deinit(allocator); 30 + for (args.vector) |arg_z| { 31 + try args_list.append(allocator, std.mem.sliceTo(arg_z, 0)); 32 + } 33 + // Must have at least the program name. 34 + std.debug.assert(args_list.items.len >= 1); 35 + 36 + var stdout_buf: [4096]u8 = undefined; 37 + const stdout_file = std.Io.File.stdout(); 38 + var stdout_writer = stdout_file.writer(io, &stdout_buf); 39 + 40 + // Dependency injection: all side effects go through Deps for testability. 41 + const deps = cli.Deps{ 42 + .run = process.run, 43 + .confirm = ui.confirm, 44 + .printTitle = ui.printTitle, 45 + .configPrint = ui.configPrint, 46 + }; 47 + 48 + try app.run( 49 + io, 50 + &stdout_writer.interface, 51 + args_list.items, 52 + deps, 53 + &static_allocator, 54 + ); 55 + 56 + // Transition from static to deinit only if run() reached static phase. 57 + // Early returns (help, version, errors) never transition to static. 58 + static_allocator.transition_from_static_to_deinit_if_static(); 59 + } 60 + 61 + // Root test block โ€” imports all module tests for `zig build test`. 62 + test { 63 + _ = @import("app/init.zig"); 64 + _ = @import("app/config.zig"); 65 + _ = @import("app/cli.zig"); 66 + _ = @import("core/io.zig"); 67 + _ = @import("core/commands.zig"); 68 + _ = @import("core/ui.zig"); 69 + _ = @import("core/process.zig"); 70 + _ = @import("core/static_allocator.zig"); 71 + _ = @import("zon"); 72 + }
vhs/zix.gif

This is a binary file and will not be displayed.

+16
vhs/zix.tape
··· 1 + Output vhs/zix.gif 2 + 3 + Set Shell "zsh" 4 + Set FontSize 32 5 + Set Width 1200 6 + Set Height 800 7 + 8 + Sleep 1s 9 + 10 + Type "zix -du -n 'mars'" Enter 11 + 12 + Sleep 1s 13 + 14 + Type "n" Enter 15 + 16 + Sleep 5s