Skip to editor content
learningzig.orglesson 15 of 25

Capstone Project

Put everything together by building a command-line data processing utility. This project uses allocators, error handling, slices, testing concepts, and data structures from throughout the course.

Data Parser

Parse structured text data using slices and memory utilities.

const std = @import("std");

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

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;

    // Trim whitespace
    const trimmed = std.mem.trim(u8, value_str, " ");

    var result: i32 = 0;
    var negative = false;
    var i: usize = 0;

    if (i < trimmed.len and trimmed[i] == '-') {
        negative = true;
        i += 1;
    }

    while (i < trimmed.len) : (i += 1) {
        if (trimmed[i] < '0' or trimmed[i] > '9') return error.InvalidNumber;
        result = result * 10 + @as(i32, trimmed[i] - '0');
    }

    return Record{
        .name = std.mem.trim(u8, name, " "),
        .category = std.mem.trim(u8, category, " "),
        .value = if (negative) -result else result,
    };
}

pub fn main() !void {
    const data =
        \\Alice|sales|150
        \\Bob|engineering|200
        \\Charlie|sales|175
        \\Diana|engineering|225
        \\Eve|marketing|130
        \\Frank|sales|190
        \\Grace|engineering|210
        \\Henry|marketing|145
    ;

    var records: [20]Record = undefined;
    var count: usize = 0;

    var lines = std.mem.splitScalar(u8, data, '\n');
    while (lines.next()) |line| {
        if (line.len == 0) continue;
        if (parseRecord(line)) |record| {
            records[count] = record;
            count += 1;
        } else |err| {
            std.debug.print("Parse error: {}\n", .{err});
        }
    }

    std.debug.print("Parsed {} records:\n\n", .{count});
    for (records[0..count]) |r| {
        std.debug.print("  {s:<10} | {s:<12} | {}\n", .{ r.name, r.category, r.value });
    }
}

Aggregation Engine

Group and aggregate data using hash maps and allocators.

const std = @import("std");

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));
    }
};

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

    // Sample data: category -> values
    const categories = [_][]const u8{ "sales", "engineering", "sales", "engineering", "marketing", "sales", "engineering", "marketing" };
    const values = [_]i32{ 150, 200, 175, 225, 130, 190, 210, 145 };
    const names = [_][]const u8{ "Alice", "Bob", "Charlie", "Diana", "Eve", "Frank", "Grace", "Henry" };

    // Aggregate by category
    var stats_map = std.StringHashMap(Stats).init(allocator);
    defer stats_map.deinit();

    for (categories, values) |cat, val| {
        if (stats_map.getPtr(cat)) |existing| {
            existing.add(val);
        } else {
            try stats_map.put(cat, Stats.init(val));
        }
    }

    // Print grouped results
    std.debug.print("Category Report:\n\n", .{});
    std.debug.print("{s:<14} {s:>6} {s:>8} {s:>6} {s:>6}\n", .{ "Category", "Count", "Average", "Min", "Max" });
    std.debug.print("{s:-<14} {s:->6} {s:->8} {s:->6} {s:->6}\n", .{ "", "", "", "", "" });

    var iter = stats_map.iterator();
    var grand_total: i64 = 0;
    var grand_count: u32 = 0;
    while (iter.next()) |entry| {
        const s = entry.value_ptr.*;
        grand_total += s.total;
        grand_count += s.count;
        // Cast the non-negative signed values to unsigned so the width spec
        // right-aligns without a leading '+' (Zig 0.16 sign behavior).
        // Note: @intCast panics on negatives — fine here because scores in
        // this data set are all >= 0; drop the casts if yours can go negative.
        std.debug.print("{s:<14} {d:>6} {d:>8.1} {d:>6} {d:>6}\n", .{
            entry.key_ptr.*,
            s.count,
            s.average(),
            @as(u32, @intCast(s.min)),
            @as(u32, @intCast(s.max)),
        });
    }

    const grand_avg = @as(f64, @floatFromInt(grand_total)) / @as(f64, @floatFromInt(grand_count));
    std.debug.print("\nOverall: {} records, avg {d:.1}\n", .{ grand_count, grand_avg });

    // Individual records
    std.debug.print("\nAll records:\n", .{});
    for (names, categories, values) |name, cat, val| {
        std.debug.print("  {s:<10} {s:<12} {}\n", .{ name, cat, val });
    }
}

Formatter and Output

Format data into different output formats.

const std = @import("std");

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

const Row = struct {
    name: []const u8,
    department: []const u8,
    score: i32,
};

fn formatTable(rows: []const Row) void {
    std.debug.print("{s:<12} {s:<14} {s:>6}\n", .{ "Name", "Department", "Score" });
    std.debug.print("{s:-<12} {s:-<14} {s:->6}\n", .{ "", "", "" });
    for (rows) |row| {
        // Cast to unsigned so {d:>6} right-aligns without a leading '+'.
        std.debug.print("{s:<12} {s:<14} {d:>6}\n", .{ row.name, row.department, @as(u32, @intCast(row.score)) });
    }
}

fn formatCsv(rows: []const Row) void {
    std.debug.print("name,department,score\n", .{});
    for (rows) |row| {
        std.debug.print("{s},{s},{}\n", .{ row.name, row.department, row.score });
    }
}

fn formatJson(rows: []const Row) void {
    std.debug.print("[\n", .{});
    for (rows, 0..) |row, i| {
        std.debug.print("  {{\"name\":\"{s}\",\"department\":\"{s}\",\"score\":{}}}", .{
            row.name, row.department, row.score,
        });
        if (i < rows.len - 1) std.debug.print(",", .{});
        std.debug.print("\n", .{});
    }
    std.debug.print("]\n", .{});
}

pub fn main() !void {
    const data = [_]Row{
        .{ .name = "Alice", .department = "Engineering", .score = 95 },
        .{ .name = "Bob", .department = "Sales", .score = 87 },
        .{ .name = "Charlie", .department = "Engineering", .score = 92 },
        .{ .name = "Diana", .department = "Marketing", .score = 88 },
    };

    const formats = [_]OutputFormat{ .table, .csv, .json };
    const format_names = [_][]const u8{ "TABLE", "CSV", "JSON" };

    for (formats, format_names) |fmt, name| {
        std.debug.print("--- {s} ---\n", .{name});
        switch (fmt) {
            .table => formatTable(&data),
            .csv => formatCsv(&data),
            .json => formatJson(&data),
        }
        std.debug.print("\n", .{});
    }
}

Error Handling Pipeline

Chain operations with proper error handling and cleanup.

const std = @import("std");

const ProcessError = error{
    EmptyInput,
    InvalidFormat,
    ValueOutOfRange,
};

const ProcessResult = struct {
    processed: u32,
    skipped: u32,
    errors: u32,
    total_value: i64,
};

fn validateValue(value: i32) ProcessError!i32 {
    if (value < 0 or value > 1000) return ProcessError.ValueOutOfRange;
    return value;
}

fn processLine(line: []const u8) ProcessError!i32 {
    if (line.len == 0) return ProcessError.EmptyInput;

    // Find the last field (value)
    var last_pipe: ?usize = null;
    for (line, 0..) |c, i| {
        if (c == '|') last_pipe = i;
    }

    const value_str = if (last_pipe) |pos|
        std.mem.trim(u8, line[pos + 1 ..], " ")
    else
        return ProcessError.InvalidFormat;

    // Parse number
    var result: i32 = 0;
    for (value_str) |c| {
        if (c < '0' or c > '9') return ProcessError.InvalidFormat;
        result = result * 10 + @as(i32, c - '0');
    }

    return validateValue(result);
}

fn processData(data: []const u8) ProcessResult {
    var result = ProcessResult{ .processed = 0, .skipped = 0, .errors = 0, .total_value = 0 };

    var lines = std.mem.splitScalar(u8, data, '\n');
    while (lines.next()) |line| {
        if (line.len == 0) {
            result.skipped += 1;
            continue;
        }

        if (processLine(line)) |value| {
            result.processed += 1;
            result.total_value += value;
        } else |err| {
            result.errors += 1;
            std.debug.print("  Error on line: {s} -> {}\n", .{ line, err });
        }
    }

    return result;
}

pub fn main() !void {
    const data =
        \\Alice|sales|150
        \\Bob|engineering|200
        \\
        \\Charlie|sales|175
        \\bad line
        \\Diana|engineering|2000
        \\Eve|marketing|130
    ;

    std.debug.print("Processing data...\n\n", .{});
    const result = processData(data);

    std.debug.print("\nResults:\n", .{});
    std.debug.print("  Processed: {}\n", .{result.processed});
    std.debug.print("  Skipped:   {}\n", .{result.skipped});
    std.debug.print("  Errors:    {}\n", .{result.errors});
    std.debug.print("  Total:     {}\n", .{result.total_value});

    if (result.processed > 0) {
        const avg = @as(f64, @floatFromInt(result.total_value)) / @as(f64, @floatFromInt(result.processed));
        std.debug.print("  Average:   {d:.1}\n", .{avg});
    }
}

Try It Yourself

const std = @import("std");

// Complete data processing pipeline

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

    // Input data
    const data =
        \\product|category|price|quantity
        \\Laptop|electronics|999|5
        \\Mouse|electronics|29|50
        \\Desk|furniture|299|10
        \\Chair|furniture|199|15
        \\Monitor|electronics|449|8
        \\Lamp|furniture|79|20
        \\Keyboard|electronics|69|30
        \\Bookshelf|furniture|149|12
    ;

    var lines = std.mem.splitScalar(u8, data, '\n');
    _ = lines.next(); // Skip header

    // Collect products
    const Product = struct {
        name: []const u8,
        category: []const u8,
        price: i32,
        quantity: i32,
    };
    var products: std.ArrayList(Product) = .empty;
    defer products.deinit(allocator);

    while (lines.next()) |line| {
        if (line.len == 0) continue;
        var fields = std.mem.splitScalar(u8, line, '|');
        const name = fields.next() orelse continue;
        const category = fields.next() orelse continue;
        const price_str = fields.next() orelse continue;
        const qty_str = fields.next() orelse continue;

        var price: i32 = 0;
        for (price_str) |c| {
            if (c >= '0' and c <= '9') price = price * 10 + @as(i32, c - '0');
        }
        var qty: i32 = 0;
        for (qty_str) |c| {
            if (c >= '0' and c <= '9') qty = qty * 10 + @as(i32, c - '0');
        }

        try products.append(allocator, .{ .name = name, .category = category, .price = price, .quantity = qty });
    }

    // Report
    std.debug.print("Inventory Report\n", .{});
    std.debug.print("{s}\n\n", .{"=" ** 50});

    std.debug.print("{s:<12} {s:<14} {s:>8} {s:>6} {s:>10}\n", .{
        "Product", "Category", "Price", "Qty", "Revenue",
    });
    std.debug.print("{s:-<12} {s:-<14} {s:->8} {s:->6} {s:->10}\n", .{ "", "", "", "", "" });

    var total_revenue: i64 = 0;
    var total_items: i32 = 0;

    for (products.items) |p| {
        const revenue = @as(i64, p.price) * @as(i64, p.quantity);
        total_revenue += revenue;
        total_items += p.quantity;
        // Width-formatted signed ints print a leading '+' in Zig 0.16, so cast
        // these non-negative counts to unsigned for clean right-aligned columns.
        std.debug.print("{s:<12} {s:<14} ${d:>7} {d:>6} ${d:>9}\n", .{
            p.name,
            p.category,
            @as(u32, @intCast(p.price)),
            @as(u32, @intCast(p.quantity)),
            @as(u64, @intCast(revenue)),
        });
    }

    std.debug.print("\n{} products, {} total items\n", .{ products.items.len, total_items });
    std.debug.print("Total revenue: ${}\n", .{total_revenue});
}

Key Takeaways

  • Parse text data with std.mem.splitScalar and std.mem.trim
  • Use StringHashMap for grouping and aggregation
  • Tagged unions (enum) model output format options cleanly
  • Chain operations with error unions and handle failures gracefully
  • defer ensures cleanup even when errors occur in pipelines
  • DebugAllocator with leak detection catches memory bugs
  • Build output formatters with std.debug.print format strings

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 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! From here, the curriculum dives deeper into Zig's type system. Next, you'll explore enums and tagged unions -- powerful tools for modeling state machines and variant data with compile-time safety.

Next lesson

Enums And Tagged Unions

Learn Zig enums and tagged unions — model state with named values, attach data to variants, and use exhaustive switch for type-safe dispatch.

25 min