Skip to content
Make room for learningYour learning studio
Course outline

Capstone Project

Put everything together by building record-analyzer: a command-line utility that reads a pipe-delimited data file, parses it into structs, aggregates the results by category, and prints a report. This project uses allocators, error handling, slices, file I/O, data structures, and testing from throughout the course.

The tool comes together in four milestones, each one building on the last:

  1. Record parsing — read a real file and turn its lines into structs.
  2. Aggregation engine — group parsed records and compute statistics with a hash map.
  3. Report formatting — render the results through the writer interface in several formats.
  4. Error pipeline — survive malformed input, report what failed, and clean up correctly.

After the milestones, you will assemble the whole thing as a proper project with a build.zig and tests that run under zig build test.

Every runnable example below writes its own input file before reading it back. That keeps each snippet self-contained — and it exercises both directions of file I/O, which is exactly what a real tool does.

Milestone 1: Record Parsing

The input format is one record per line, three pipe-delimited fields: name, category, value. Parsing splits each line with std.mem.splitScalar, trims the surrounding whitespace, and converts the last field to an integer. Every failure mode gets its own error, so the caller can tell a missing field from a bad number.

This milestone also makes the program read a real file rather than a string baked into the binary. The program creates records.txt with createFile and writeStreamingAll, then reads it back with readFileAlloc — the same std.Io.Dir API you learned in File I/O, with the io value threaded through from std.process.Init.

zig
const std = @import("std");

/// One parsed line of input. The slices borrow from the file buffer, so this
/// struct is only valid as long as the buffer it was parsed from.
const Record = struct {
    name: []const u8,
    category: []const u8,
    value: i32,
};

const ParseError = error{ MissingField, InvalidNumber };

fn parseRecord(line: []const u8) ParseError!Record {
    var fields = std.mem.splitScalar(u8, line, '|');

    const name = fields.next() orelse return error.MissingField;
    const category = fields.next() orelse return error.MissingField;
    const value_str = fields.next() orelse return error.MissingField;

    const value = std.fmt.parseInt(i32, std.mem.trim(u8, value_str, " \t\r"), 10) catch
        return error.InvalidNumber;

    return .{
        .name = std.mem.trim(u8, name, " \t\r"),
        .category = std.mem.trim(u8, category, " \t\r"),
        .value = value,
    };
}

/// The sample data. Written to disk so the parser reads a real file.
const sample_input =
    \\Alice|sales|150
    \\Bob|engineering|200
    \\Charlie|sales|175
    \\Diana|engineering|225
    \\Eve|marketing|130
    \\Frank|sales|190
    \\Grace|engineering|210
    \\Henry|marketing|145
    \\
;

pub fn main(init: std.process.Init) !void {
    const io = init.io;
    const allocator = init.gpa;
    const cwd = std.Io.Dir.cwd();

    // Write the input file. The block scope closes the handle before we reopen
    // the path for reading.
    {
        const file = try cwd.createFile(io, "records.txt", .{});
        defer file.close(io);
        try file.writeStreamingAll(io, sample_input);
    }

    // Read it back. readFileAlloc opens, reads to EOF, and closes for us.
    const contents = try cwd.readFileAlloc(io, "records.txt", allocator, .limited(1024 * 1024));
    defer allocator.free(contents);

    std.debug.print("Read {d} bytes from records.txt\n\n", .{contents.len});

    var records: std.ArrayList(Record) = .empty;
    defer records.deinit(allocator);

    var lines = std.mem.splitScalar(u8, contents, '\n');
    while (lines.next()) |line| {
        if (line.len == 0) continue;
        if (parseRecord(line)) |record| {
            try records.append(allocator, record);
        } else |err| {
            std.debug.print("Skipping '{s}': {t}\n", .{ line, err });
        }
    }

    std.debug.print("Parsed {d} records:\n", .{records.items.len});
    for (records.items) |r| {
        std.debug.print("  {s:<10} {s:<12} {d}\n", .{ r.name, r.category, r.value });
    }
}

Note the ownership chain: readFileAlloc returns a buffer the caller owns, each Record's slices point into that buffer, and the ArrayList holds the records. One defer allocator.free(contents) and one defer records.deinit(allocator) release everything. If you freed the buffer while the records were still alive, every slice would dangle — which is why the buffer outlives the list here.

The {t} specifier prints an error's name (MissingField) rather than its numeric value. It works the same way on enums and tagged unions.

Milestone 2: Aggregation Engine

With records in memory, the next job is grouping. std.StringHashMap(Stats) maps each category name to a running summary, and getOrPut does the insert-or-update in a single lookup — the idiomatic pattern from Data Structures.

Stats accumulates rather than storing every value: a count, a running total, and a min/max pair. That keeps memory flat no matter how large the input grows.

zig
const std = @import("std");

const Record = struct {
    name: []const u8,
    category: []const u8,
    value: i32,
};

/// A running summary. Accumulating instead of storing every value keeps memory
/// constant per category regardless of input size.
const Stats = struct {
    count: u32,
    total: i64,
    min: i32,
    max: i32,

    fn init(value: i32) Stats {
        return .{ .count = 1, .total = value, .min = value, .max = value };
    }

    fn add(self: *Stats, value: i32) void {
        self.count += 1;
        self.total += value;
        if (value < self.min) self.min = value;
        if (value > self.max) self.max = value;
    }

    fn average(self: Stats) f64 {
        return @as(f64, @floatFromInt(self.total)) / @as(f64, @floatFromInt(self.count));
    }
};

fn parseRecord(line: []const u8) !Record {
    var fields = std.mem.splitScalar(u8, line, '|');
    const name = fields.next() orelse return error.MissingField;
    const category = fields.next() orelse return error.MissingField;
    const value_str = fields.next() orelse return error.MissingField;
    return .{
        .name = std.mem.trim(u8, name, " \t\r"),
        .category = std.mem.trim(u8, category, " \t\r"),
        .value = try std.fmt.parseInt(i32, std.mem.trim(u8, value_str, " \t\r"), 10),
    };
}

/// Groups records by category. errdefer releases the map if an insert fails
/// partway through, so the caller never receives a half-built map.
fn aggregate(allocator: std.mem.Allocator, records: []const Record) !std.StringHashMap(Stats) {
    var by_category = std.StringHashMap(Stats).init(allocator);
    errdefer by_category.deinit();

    for (records) |record| {
        const entry = try by_category.getOrPut(record.category);
        if (entry.found_existing) {
            entry.value_ptr.add(record.value);
        } else {
            entry.value_ptr.* = Stats.init(record.value);
        }
    }
    return by_category;
}

const sample_input =
    \\Alice|sales|150
    \\Bob|engineering|200
    \\Charlie|sales|175
    \\Diana|engineering|225
    \\Eve|marketing|130
    \\Frank|sales|190
    \\Grace|engineering|210
    \\Henry|marketing|145
    \\
;

pub fn main(init: std.process.Init) !void {
    const io = init.io;
    const allocator = init.gpa;
    const cwd = std.Io.Dir.cwd();

    {
        const file = try cwd.createFile(io, "records.txt", .{});
        defer file.close(io);
        try file.writeStreamingAll(io, sample_input);
    }

    const contents = try cwd.readFileAlloc(io, "records.txt", allocator, .limited(1024 * 1024));
    defer allocator.free(contents);

    var records: std.ArrayList(Record) = .empty;
    defer records.deinit(allocator);

    var lines = std.mem.splitScalar(u8, contents, '\n');
    while (lines.next()) |line| {
        if (line.len == 0) continue;
        try records.append(allocator, try parseRecord(line));
    }

    var by_category = try aggregate(allocator, records.items);
    defer by_category.deinit();

    std.debug.print("{s:<14} {s:<7} {s:<9} {s:<5} {s:<5}\n", .{ "Category", "Count", "Average", "Min", "Max" });
    std.debug.print("{s:-<14} {s:-<7} {s:-<9} {s:-<5} {s:-<5}\n", .{ "", "", "", "", "" });

    var it = by_category.iterator();
    while (it.next()) |entry| {
        const s = entry.value_ptr.*;
        std.debug.print("{s:<14} {d:<7} {d:<9.1} {d:<5} {d:<5}\n", .{
            entry.key_ptr.*,
            s.count,
            s.average(),
            s.min,
            s.max,
        });
    }
}

Two ownership details matter here. StringHashMap does not copy its keys — the category strings still borrow from the file buffer, so the buffer has to outlive the map. And errdefer by_category.deinit() (rather than defer) hands a working map to the caller on success while still cleaning up if an insert fails halfway through. That is the difference between the two: defer always runs, errdefer runs only on the error path.

Hash map iteration order is unspecified, so the category rows may appear in any order. If you need a stable report, collect the keys and sort them before printing.

That borrowing is worth pinning down before the whole pipeline depends on it. The helper below illustrates an invalid lifetime; it is not a runnable prediction of a particular output. Assume the map's keys are slices into contents and its sales entry currently contains 325.

Predict

The map borrows its keys from contents. If this helper were called, which claim about the lookup after allocator.free is justified? Diagnose the lifetime before trying to predict a value.

zig
const std = @import("std");

// Precondition for this example: map's keys borrow from contents.
// Deliberately invalid: do not call this helper.
fn lookupAfterFree(
  allocator: std.mem.Allocator,
  contents: []const u8,
  map: *const std.StringHashMap(i64),
) ?i64 {
  allocator.free(contents);
  return map.get("sales");
}

The aggregation is also where a whole class of wrong answers hides, because a summary is a number nobody can eyeball. Here is one.

Debug

This is the aggregation engine on five records, cut down to the part that summarises. It compiles, runs, exits cleanly, and reports counts and totals that are all correct — and two numbers that no input line could have produced. Commit a hypothesis about which of the four fields is wrong and where it got its value, then fix it so every category reports the real minimum and maximum of its own records.

zig
const std = @import("std");

const Record = struct { category: []const u8, value: i32 };

const Stats = struct {
  count: u32,
  total: i64,
  min: i32,
  max: i32,

  fn init(value: i32) Stats {
      return .{ .count = 1, .total = value, .min = 0, .max = 0 };
  }

  fn add(self: *Stats, value: i32) void {
      self.count += 1;
      self.total += value;
      if (value < self.min) self.min = value;
      if (value > self.max) self.max = value;
  }
};

pub fn main() !void {
  var debug_allocator: std.heap.DebugAllocator(.{}) = .init;
  defer _ = debug_allocator.deinit();
  const allocator = debug_allocator.allocator();

  const records = [_]Record{
      .{ .category = "sales", .value = 150 },
      .{ .category = "sales", .value = 175 },
      .{ .category = "sales", .value = 190 },
      .{ .category = "marketing", .value = 130 },
      .{ .category = "marketing", .value = 145 },
  };

  var by_category = std.StringHashMap(Stats).init(allocator);
  defer by_category.deinit();

  for (records) |record| {
      const entry = try by_category.getOrPut(record.category);
      if (entry.found_existing) {
          entry.value_ptr.add(record.value);
      } else {
          entry.value_ptr.* = Stats.init(record.value);
      }
  }

  const sales = by_category.get("sales").?;
  const marketing = by_category.get("marketing").?;
  std.debug.print("sales:     count={d} total={d} min={d} max={d}\n", .{ sales.count, sales.total, sales.min, sales.max });
  std.debug.print("marketing: count={d} total={d} min={d} max={d}\n", .{ marketing.count, marketing.total, marketing.min, marketing.max });
}

Expected output:

sales:     count=3 total=515 min=150 max=190
marketing: count=2 total=275 min=130 max=145

Milestone 3: Report Formatting

Printing with std.debug.print is fine for diagnostics, but it always writes to stderr and cannot be captured. A real tool writes to a writer, so the same rendering code can target stdout, a file, or an in-memory buffer in a test.

render takes a *std.Io.Writer and switches on an OutputFormat enum. Because the writer is a parameter rather than a hardcoded destination, this function is directly testable — which Milestone 4's tests rely on.

zig
const std = @import("std");

const Record = struct {
    name: []const u8,
    category: []const u8,
    value: i32,
};

const OutputFormat = enum { table, csv, json };

/// Renders records into a caller-supplied writer, so the same code targets
/// stdout, a file, or an in-memory buffer in tests.
fn render(w: *std.Io.Writer, format: OutputFormat, records: []const Record) !void {
    switch (format) {
        .table => {
            try w.print("{s:<10} {s:<12} {s:<6}\n", .{ "Name", "Category", "Value" });
            try w.print("{s:-<10} {s:-<12} {s:-<6}\n", .{ "", "", "" });
            for (records) |r| {
                try w.print("{s:<10} {s:<12} {d}\n", .{ r.name, r.category, r.value });
            }
        },
        .csv => {
            try w.print("name,category,value\n", .{});
            for (records) |r| {
                try w.print("{s},{s},{d}\n", .{ r.name, r.category, r.value });
            }
        },
        .json => {
            try w.print("[\n", .{});
            for (records, 0..) |r, i| {
                try w.print("  {{\"name\":\"{s}\",\"category\":\"{s}\",\"value\":{d}}}", .{
                    r.name, r.category, r.value,
                });
                if (i + 1 < records.len) try w.print(",", .{});
                try w.print("\n", .{});
            }
            try w.print("]\n", .{});
        },
    }
}

pub fn main(init: std.process.Init) !void {
    const io = init.io;

    const records = [_]Record{
        .{ .name = "Alice", .category = "sales", .value = 150 },
        .{ .name = "Bob", .category = "engineering", .value = 200 },
        .{ .name = "Charlie", .category = "sales", .value = 175 },
    };

    // A buffered writer over stdout. Everything goes through .interface.
    var buffer: [4096]u8 = undefined;
    var file_writer = std.Io.File.stdout().writer(io, &buffer);
    const stdout = &file_writer.interface;

    for ([_]OutputFormat{ .table, .csv, .json }) |format| {
        try stdout.print("--- {t} ---\n", .{format});
        try render(stdout, format, &records);
        try stdout.print("\n", .{});
    }

    try stdout.flush(); // Unflushed output is silently discarded.
}

The switch is exhaustive over OutputFormat, so adding a fourth format is a compile error until you handle it — the compiler keeps the formatter honest as the enum grows. The literal {{ and }} in the JSON branch escape the braces that print would otherwise read as format placeholders.

Note the flush() at the end. Buffered output sits in buffer until you flush it or the buffer fills; forget the flush and the report silently truncates.

One formatting caveat specific to Zig 0.16: a signed integer printed with a width specifier gains a leading + ({d:>5} on 150 gives +150). The columns above use {d} without a width for values, and pad the label columns instead. The aggregation reports in Milestone 2 and in Try It Yourself do use {d:<5} on the signed min/max, which is why their output shows +150 rather than 150. If you want fixed-width numeric columns and your values are known non-negative, cast them with @as(u32, @intCast(v)) — but that panics on a negative, so only do it where the range is genuinely guaranteed.

Milestone 4: Error Pipeline

Real input is messy. A tool that aborts on the first bad line is useless for a 10,000-line file — it should skip what it cannot parse, count the failures, and report them at the end.

loadRecords does exactly that: each line either becomes a record or increments a rejection count. The if (parseRecord(line)) |record| ... else |err| ... form captures both branches of the error union without unwinding the loop, and errdefer releases the partially built list if an append fails.

zig
const std = @import("std");

const Record = struct {
    name: []const u8,
    category: []const u8,
    value: i32,
};

/// One error per distinct failure mode, so the caller can tell them apart.
const ParseError = error{ MissingField, InvalidNumber, ValueOutOfRange };

fn parseRecord(line: []const u8) ParseError!Record {
    var fields = std.mem.splitScalar(u8, line, '|');
    const name = fields.next() orelse return error.MissingField;
    const category = fields.next() orelse return error.MissingField;
    const value_str = fields.next() orelse return error.MissingField;

    // parseInt has its own error set; translate it at this boundary so callers
    // only ever see a ParseError.
    const value = std.fmt.parseInt(i32, std.mem.trim(u8, value_str, " \t\r"), 10) catch
        return error.InvalidNumber;
    if (value < 0 or value > 1000) return error.ValueOutOfRange;

    return .{
        .name = std.mem.trim(u8, name, " \t\r"),
        .category = std.mem.trim(u8, category, " \t\r"),
        .value = value,
    };
}

const Report = struct {
    accepted: u32 = 0,
    rejected: u32 = 0,
    blank: u32 = 0,
};

/// Collects every valid record and counts the failures instead of aborting on
/// the first one. errdefer releases the partial list if an append fails.
fn loadRecords(
    allocator: std.mem.Allocator,
    contents: []const u8,
    report: *Report,
) !std.ArrayList(Record) {
    var records: std.ArrayList(Record) = .empty;
    errdefer records.deinit(allocator);

    var lines = std.mem.splitScalar(u8, contents, '\n');
    while (lines.next()) |line| {
        if (std.mem.trim(u8, line, " \t\r").len == 0) {
            report.blank += 1;
            continue;
        }
        if (parseRecord(line)) |record| {
            try records.append(allocator, record);
            report.accepted += 1;
        } else |err| {
            report.rejected += 1;
            std.debug.print("  line rejected ({t}): {s}\n", .{ err, line });
        }
    }
    return records;
}

const messy_input =
    \\Alice|sales|150
    \\Bob|engineering|200
    \\
    \\Charlie|sales
    \\Diana|engineering|2000
    \\Eve|marketing|abc
    \\Frank|sales|190
    \\
;

pub fn main(init: std.process.Init) !void {
    const io = init.io;
    const allocator = init.gpa;
    const cwd = std.Io.Dir.cwd();

    {
        const file = try cwd.createFile(io, "messy.txt", .{});
        defer file.close(io);
        try file.writeStreamingAll(io, messy_input);
    }

    const contents = try cwd.readFileAlloc(io, "messy.txt", allocator, .limited(1024 * 1024));
    defer allocator.free(contents);

    std.debug.print("Loading messy.txt...\n", .{});
    var report: Report = .{};
    var records = try loadRecords(allocator, contents, &report);
    defer records.deinit(allocator);

    std.debug.print("\naccepted={d} rejected={d} blank={d}\n", .{
        report.accepted,
        report.rejected,
        report.blank,
    });
}

Three distinct failures show up in the output, each with its own error name: Charlie|sales is missing a field, Diana|engineering|2000 is out of range, and Eve|marketing|abc has an unparseable number. The catch return error.InvalidNumber line is doing real work — std.fmt.parseInt returns its own error set (InvalidCharacter, Overflow), and translating it at the function boundary keeps ParseError the single contract callers depend on.

The error pipeline and the cleanup discipline meet at the boundary where a function hands an owned collection back. Assemble the caller's side of that boundary yourself.

Arrange the code

The aggregate function above is already written, errdefer and all. The six lines below are its caller: they take the finished map, read one category out of it, report the figures, and then release the map and the backing allocator in turn — the releases written out explicitly rather than deferred, so their positions are part of the puzzle. Reorder them so the program prints the sales total, the category count, and a clean leak check. Then answer what the order is really testing: two adjacent swaps compile without complaint, and neither of them fails at the line that is actually wrong. Which two, and what does the program print instead?

  1. std.debug.print("leak check: {t}\n", .{leaked});
  2. const sales = by_category.get("sales").?;
  3. std.debug.print("sales={d} over {d} categories\n", .{ sales.total, by_category.count() });
  4. var by_category = try aggregate(allocator, &records);
  5. const leaked = debug_allocator.deinit();
  6. by_category.deinit();

Testing the Pipeline

Every function above takes its inputs as parameters — an allocator, a byte slice, a writer — and none of them talks directly to the filesystem or stdout. That is what makes them testable. Test blocks live in the same file as the code they cover and run with zig build test (or zig test on a single file).

Two habits carry the most weight. Use std.testing.allocator, which fails the test if anything you allocated is not freed — a leak becomes a test failure rather than an invisible bug. And use std.Io.Writer.fixed to render into a stack buffer, so you can assert on exact output without touching a real file.

zig
const std = @import("std");

const Record = struct {
    name: []const u8,
    category: []const u8,
    value: i32,
};

const ParseError = error{ MissingField, InvalidNumber, ValueOutOfRange };

fn parseRecord(line: []const u8) ParseError!Record {
    var fields = std.mem.splitScalar(u8, line, '|');
    const name = fields.next() orelse return error.MissingField;
    const category = fields.next() orelse return error.MissingField;
    const value_str = fields.next() orelse return error.MissingField;

    const value = std.fmt.parseInt(i32, std.mem.trim(u8, value_str, " \t\r"), 10) catch
        return error.InvalidNumber;
    if (value < 0 or value > 1000) return error.ValueOutOfRange;

    return .{
        .name = std.mem.trim(u8, name, " \t\r"),
        .category = std.mem.trim(u8, category, " \t\r"),
        .value = value,
    };
}

fn renderCsv(w: *std.Io.Writer, records: []const Record) !void {
    try w.print("name,category,value\n", .{});
    for (records) |r| {
        try w.print("{s},{s},{d}\n", .{ r.name, r.category, r.value });
    }
}

// --- Tests. `zig run` compiles these but does not execute them; `zig build
// --- test` (see the build.zig below) is what actually runs them.

test "parseRecord splits and trims fields" {
    const record = try parseRecord("  Alice | sales | 150 ");
    try std.testing.expectEqualStrings("Alice", record.name);
    try std.testing.expectEqualStrings("sales", record.category);
    try std.testing.expectEqual(@as(i32, 150), record.value);
}

test "parseRecord maps each failure to a distinct error" {
    try std.testing.expectError(error.MissingField, parseRecord("Alice|sales"));
    try std.testing.expectError(error.InvalidNumber, parseRecord("Alice|sales|abc"));
    try std.testing.expectError(error.ValueOutOfRange, parseRecord("Alice|sales|2000"));
}

test "renderCsv output is exact" {
    const records = [_]Record{
        .{ .name = "Alice", .category = "sales", .value = 150 },
    };

    // An in-memory writer — no temp files, no system calls.
    var buf: [256]u8 = undefined;
    var w = std.Io.Writer.fixed(&buf);
    try renderCsv(&w, &records);

    try std.testing.expectEqualStrings(
        "name,category,value\nAlice,sales,150\n",
        w.buffered(),
    );
}

/// {t} formats an error, not an error *union* — so unwrap before printing.
fn describe(label: []const u8, result: ParseError!Record) void {
    if (result) |record| {
        std.debug.print("{s} -> ok ({s})\n", .{ label, record.name });
    } else |err| {
        std.debug.print("{s} -> {t}\n", .{ label, err });
    }
}

pub fn main() !void {
    // The playground runs main, not the tests above — so demonstrate the same
    // assertions here to show what the test blocks verify.
    const record = try parseRecord("  Alice | sales | 150 ");
    std.debug.print("parsed: {s} / {s} / {d}\n", .{ record.name, record.category, record.value });

    describe("missing field", parseRecord("Alice|sales"));
    describe("bad number   ", parseRecord("Alice|sales|abc"));
    describe("out of range ", parseRecord("Alice|sales|2000"));

    var buf: [256]u8 = undefined;
    var w = std.Io.Writer.fixed(&buf);
    try renderCsv(&w, &[_]Record{.{ .name = "Alice", .category = "sales", .value = 150 }});
    std.debug.print("\ncsv output:\n{s}", .{w.buffered()});
}

Be aware of what the playground does and does not do here: zig run compiles the test blocks but never executes them, which is why main repeats the assertions so you can see the results. On your own machine, zig build test runs the test blocks; it prints nothing when they all pass, and reports the failure when one does not. Add --summary all to see the 3/3 tests passed line explicitly.

expectError asserts that a call fails with a specific error — a test that merely checked "it failed" would still pass if the wrong failure occurred, which defeats the purpose of having distinct error names.

Before assembling the whole thing, check that the two lessons this pipeline leans on hardest are still to hand.

Recall

Without scrolling up: the analyzer reads its input with readFileAlloc, groups it in a StringHashMap, and renders through a writer. File I/O taught who owns the bytes a read hands back, and Data Structures taught what an unmanaged collection does and does not hold on your behalf. Which row gets all three lifetimes right?

Assembling the Project

The milestones ran as single files. A real deliverable is a project: source in src/, a build.zig that describes how to compile and test it, and a build.zig.zon manifest naming and versioning the package.

record-analyzer/
├── build.zig
├── build.zig.zon
└── src/
    └── main.zig

The key structural decision is that the executable and the test step share one module. Both compile the same source with the same target and optimize settings, so zig build test tests exactly the code zig build ships — not a separate configuration that might drift.

zig
// build.zig — describes the project. Not runnable in the playground.
const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    // One module, shared by the executable and the test step, so both compile
    // the exact same source with the exact same settings.
    const analyzer_mod = b.createModule(.{
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });

    const exe = b.addExecutable(.{
        .name = "record-analyzer",
        .root_module = analyzer_mod,
    });
    b.installArtifact(exe);

    // zig build run — build, then execute, forwarding any CLI arguments.
    const run_cmd = b.addRunArtifact(exe);
    if (b.args) |args| run_cmd.addArgs(args);
    const run_step = b.step("run", "Run the analyzer");
    run_step.dependOn(&run_cmd.step);

    // zig build test — compile the same module in test mode and run its tests.
    const tests = b.addTest(.{ .root_module = analyzer_mod });
    const run_tests = b.addRunArtifact(tests);
    const test_step = b.step("test", "Run unit tests");
    test_step.dependOn(&run_tests.step);
}

Both addExecutable and addTest take a .root_module rather than a source file — Zig 0.15 removed the implicit root module, so target and optimize are properties of the module. addTest only builds the test binary; wrapping it in addRunArtifact is what makes zig build test actually execute the tests.

The manifest sits alongside it:

// build.zig.zon
.{
    .name = .record_analyzer,
    .version = "0.1.0",
    .minimum_zig_version = "0.16.0",
    .fingerprint = 0x0,
    .dependencies = .{},
    .paths = .{ "build.zig", "build.zig.zon", "src" },
}

.name is an enum literal (note the leading dot), not a string. Leave .fingerprint at 0x0 on a new package and run zig build once — the compiler rejects the placeholder and prints the correct value to paste in. .minimum_zig_version is worth setting explicitly here: this project's I/O code targets 0.16.0, and Zig's I/O surface changed substantially in both 0.15.1 and 0.16.0.

With those two files in place:

bash
zig build            # compile to zig-out/bin/record-analyzer
zig build run        # compile and run
zig build test       # run every test block in the module

Try It Yourself

Everything above you have read. This last part you assemble.

Below is the whole analyzer with its three working functions removed and its test battery left intact — the same battery from Testing the Pipeline, asserting the same seven-accepted, one-rejected, three-category result on the same sample input. Three functions are stubbed out. Run it as-is and it fails immediately, naming the first stub that is still empty. Implement each until it prints All checks passed.

What "finished" means here, precisely: every check passes and the program prints its two closing lines — All checks passed. followed by 7 accepted, 1 rejected, 3 categories. That is the completion signal, and it is a stronger one than a program that merely runs, because the checks pin the exact values: seven records accepted and one rejected from an input containing one malformed line, three categories, a sales total of 515, a marketing minimum of 130, an engineering maximum of 225, and a report carrying both its title and its final tally. A pipeline that gets any of those wrong stops on the check that names it. The three stubs are the three milestones: loadRecords is Milestone 1 and 4 together (parse, tolerate bad lines, count them), aggregate is Milestone 2, and writeReport is Milestone 3.

Build

Assemble the analyzer. Three functions are stubbed out and the checks below them fail until each one works. Run it as-is to see which check fails first, decide what that function is missing, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down. The complete programs earlier in this lesson are the reference; the point is to write the pipeline rather than read it.

zig
const std = @import("std");

const Record = struct {
  name: []const u8,
  category: []const u8,
  value: i32,
};

const ParseError = error{ MissingField, InvalidNumber, ValueOutOfRange };

const Stats = struct {
  count: u32,
  total: i64,
  min: i32,
  max: i32,

  fn init(value: i32) Stats {
      return .{ .count = 1, .total = value, .min = value, .max = value };
  }

  fn add(self: *Stats, value: i32) void {
      self.count += 1;
      self.total += value;
      if (value < self.min) self.min = value;
      if (value > self.max) self.max = value;
  }

  fn average(self: Stats) f64 {
      return @as(f64, @floatFromInt(self.total)) / @as(f64, @floatFromInt(self.count));
  }
};

fn parseRecord(line: []const u8) ParseError!Record {
  var fields = std.mem.splitScalar(u8, line, '|');
  const name = fields.next() orelse return error.MissingField;
  const category = fields.next() orelse return error.MissingField;
  const value_str = fields.next() orelse return error.MissingField;

  const value = std.fmt.parseInt(i32, std.mem.trim(u8, value_str, " \t\r"), 10) catch
      return error.InvalidNumber;
  if (value < 0 or value > 1000) return error.ValueOutOfRange;

  return .{
      .name = std.mem.trim(u8, name, " \t\r"),
      .category = std.mem.trim(u8, category, " \t\r"),
      .value = value,
  };
}

// TODO 1: split `contents` on newlines and turn every line into a Record,
//   tolerating the bad ones. Skip lines that are empty or all whitespace.
//   A line parseRecord accepts is appended to the list; a line it rejects
//   increments `rejected` and is otherwise ignored — the tool reports bad
//   input rather than dying on it.
//   The list is returned to the caller, so use errdefer, not defer: it must be
//   torn down only if this function fails partway through.
//   loadRecords(a, "Alice|sales|150\nbroken\n", &n) -> 1 record, n == 1
fn loadRecords(
  allocator: std.mem.Allocator,
  contents: []const u8,
  rejected: *u32,
) !std.ArrayList(Record) {
  // your code here
  _ = allocator;
  _ = contents;
  _ = rejected;
  return .empty;
}

// TODO 2: group the records by category into a StringHashMap(Stats).
//   Use getOrPut so the insert-or-update is a single lookup: when the entry is
//   already there call add on it, and when it is new initialise it with
//   Stats.init(record.value).
//   errdefer again — the map is handed to the caller on success.
//   Note the map borrows its keys from the record slices; it does not copy them.
fn aggregate(allocator: std.mem.Allocator, records: []const Record) !std.StringHashMap(Stats) {
  // your code here
  _ = records;
  return std.StringHashMap(Stats).init(allocator);
}

// TODO 3: render the report into `w`. The checks assert on two fragments:
//   the title line "Record Analyzer", and a final tally line reading
//   "<n> records accepted, <m> rejected".
//   Write through the *std.Io.Writer you are given rather than printing, so the
//   same function serves stdout, a file, and the in-memory writer the test uses.
//   A table of one row per category — key, count, average, min, max — goes
//   between them; iterate the map with by_category.iterator().
fn writeReport(
  w: *std.Io.Writer,
  records: []const Record,
  by_category: *std.StringHashMap(Stats),
  rejected: u32,
) !void {
  // your code here
  _ = w;
  _ = records;
  _ = by_category;
  _ = rejected;
}

const sample_input =
  \\Alice|sales|150
  \\Bob|engineering|200
  \\Charlie|sales|175
  \\Diana|engineering|225
  \\Eve|marketing|130
  \\Frank|sales|190
  \\bad line with no value
  \\Henry|marketing|145
  \\
;

// --- Build checks: Do not edit below this line. ---
// These are the assertions from the "whole pipeline" test block, run directly so
// the playground can execute them. `zig build test` runs the test block itself.
fn expect(ok: bool, comptime msg: []const u8) !void {
  if (!ok) {
      std.debug.print("CHECK FAILED: {s}\n", .{msg});
      return error.CheckFailed;
  }
}

test "the whole pipeline runs on in-memory input" {
  const allocator = std.testing.allocator;

  var rejected: u32 = 0;
  var records = try loadRecords(allocator, sample_input, &rejected);
  defer records.deinit(allocator);

  try std.testing.expectEqual(@as(usize, 7), records.items.len);
  try std.testing.expectEqual(@as(u32, 1), rejected);

  var by_category = try aggregate(allocator, records.items);
  defer by_category.deinit();

  try std.testing.expectEqual(@as(u32, 3), by_category.count());
  try std.testing.expectEqual(@as(u32, 3), by_category.get("sales").?.count);

  var buf: [1024]u8 = undefined;
  var w = std.Io.Writer.fixed(&buf);
  try writeReport(&w, records.items, &by_category, rejected);
  try std.testing.expect(std.mem.indexOf(u8, w.buffered(), "7 records accepted, 1 rejected") != null);
}

pub fn main() !void {
  var debug_allocator: std.heap.DebugAllocator(.{}) = .init;
  defer _ = debug_allocator.deinit();
  const allocator = debug_allocator.allocator();

  var rejected: u32 = 0;
  var records = try loadRecords(allocator, sample_input, &rejected);
  defer records.deinit(allocator);

  try expect(records.items.len == 7, "loadRecords should accept the 7 well-formed lines");
  try expect(rejected == 1, "loadRecords should count the 1 malformed line as rejected");

  var by_category = try aggregate(allocator, records.items);
  defer by_category.deinit();

  try expect(by_category.count() == 3, "aggregate should produce 3 categories");
  try expect(by_category.get("sales").?.count == 3, "sales should have 3 records");
  try expect(by_category.get("sales").?.total == 515, "sales total should be 150 + 175 + 190 = 515");
  try expect(by_category.get("marketing").?.min == 130, "marketing min should be 130");
  try expect(by_category.get("engineering").?.max == 225, "engineering max should be 225");

  var buf: [1024]u8 = undefined;
  var w = std.Io.Writer.fixed(&buf);
  try writeReport(&w, records.items, &by_category, rejected);
  const out = w.buffered();
  try expect(std.mem.indexOf(u8, out, "7 records accepted, 1 rejected") != null, "the report should end with the accepted/rejected tally");
  try expect(std.mem.indexOf(u8, out, "Record Analyzer") != null, "the report should carry its title");

  std.debug.print("All checks passed.\n", .{});
  std.debug.print("{d} accepted, {d} rejected, {d} categories\n", .{ records.items.len, rejected, by_category.count() });
}

Expected output:

All checks passed.
7 accepted, 1 rejected, 3 categories

Once it passes, try two variations and predict each before running:

  1. Make a rejected line valid. Change bad line with no value in sample_input to Grace|engineering|210 and predict which checks fire. Two do: the accepted count becomes 8 and the rejected count 0, so loadRecords should accept the 7 well-formed lines fails first. Nothing about your code is wrong — the checks are pinned to this exact input, which is what makes them able to catch a real regression rather than just confirming the program ran.
  2. Drop the error tolerance. In loadRecords, replace the if (parseRecord(line)) |record| ... else |_| ... form with try records.append(allocator, try parseRecord(line)); and predict what happens. The whole program now exits with error.MissingField on the malformed line and no check runs at all — one bad row in an eight-row file destroys the entire report. That single structural difference is the whole of Milestone 4: a tool that survives its input is not the same program as one that merely parses it.

One last thing, before you call it done. The analyzer is built around one input format: three pipe-delimited fields, name then category then value. Suppose the format changed — a fourth field, a different delimiter, quoted fields containing the delimiter, or a header row to skip. Which functions would you have to touch, and which would not notice at all? Work through it honestly, because the answer is a fair audit of the design you just assembled: writeReport and aggregate never see the raw text and so should not care, while parseRecord should absorb the whole change — and anywhere that turns out not to be true is a seam that leaked. That question, asked of your own code before someone else asks it of you, is most of what separates a program that works from one that can be changed.

Where To Take It Next

Extensions worth building, roughly in order of difficulty. The first two carry a stated outcome, so predict before you run.

  1. Sort the category rows before printing. Collect the keys into an ArrayList([]const u8), sort them with std.mem.sort and a std.mem.lessThan(u8, ...) comparator, then iterate that list instead of the map's iterator. Predict what changes in the checks before you run it: nothing does — every check still passes, because they assert on counts, totals and the tally line rather than on row order. What changes is that two runs now produce byte-identical reports, which they were never guaranteed to do before, since hash map iteration order is unspecified. That is the difference between a report that is correct and one you can diff against yesterday's.
  2. Take the input path from the command line with std.process.Args instead of hardcoding records.txt. Predict the failure mode you now have to design for: the path comes from outside the program, so the file may not exist, and readFileAlloc returns error.FileNotFound rather than any error your pipeline produces. A tool that answers that with a raw error name and a stack trace is worse than one that says which path it tried and exits, so this extension is really about catching one specific error and reporting it in the user's terms.
  3. Add a --json output mode that reuses the render function from Milestone 3.
  4. Read the file line by line with a buffered file.reader(io, &buf) and takeDelimiter instead of loading it whole, so the tool handles files larger than memory. The record slices then borrow the reader's buffer and are only valid until the next read — you would need to copy the strings you keep.
  5. Split the program into src/parse.zig, src/stats.zig, and src/main.zig, wiring them together with @import and the .imports list in build.zig.

One last check, on the shape of the thing you built rather than on any single line of it.

Transfer

Every function in this analyzer takes what it needs as a parameter: an allocator, a byte slice, a pointer to a std.Io.Writer. None of them opens a file or writes to stdout on its own, which is why the test can run the whole pipeline against an in-memory buffer. A Rust or Go version of the same tool would take a writer for the same reason, with no allocator parameter at all. Which statement names what genuinely transfers?

Key Takeaways

  • Read real input with std.Io.Dir.cwd(), createFile/writeStreamingAll to write and readFileAlloc to read — threading the io from std.process.Init
  • Parse text with std.mem.splitScalar, std.mem.trim, and std.fmt.parseInt, translating foreign error sets at the function boundary
  • StringHashMap plus getOrPut groups and aggregates in a single lookup — it borrows its keys, so the source buffer must outlive the map
  • errdefer releases a partially built result on the error path only, which is what lets a function return an owned collection safely
  • Take a *std.Io.Writer instead of printing directly, and the same code serves stdout, a file, and Writer.fixed in tests
  • std.testing.allocator turns leaks into test failures; expectError pins each failure to a specific error
  • One shared module in build.zig keeps zig build and zig build test compiling identical code
  • Buffered writers need an explicit flush() — unflushed output is silently lost

Pro Tip: When building CLI tools in Zig, structure your code as a library with a thin CLI wrapper. Put parsing, processing, and formatting in separate functions that take allocators and writers and return errors. This makes every component independently testable and lets you reuse the logic in other contexts — a web server, a library, or a different CLI.

Next Steps

Great work completing the capstone -- that is the core track done. What follows is optional depth rather than required curriculum: five extension lessons you can take in any order, or leave for when a real project demands them. First up is advanced error handling -- composing error sets, translating them at module boundaries, and using errdefer for rollback. After that: vtable polymorphism, C interop, async I/O, and SIMD.

Next lesson

Error Handling Patterns

Go beyond basic Zig error handling — define custom error sets, use errdefer for cleanup, merge error sets, and switch on specific errors for recovery.

25 min