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.splitScalarandstd.mem.trim - Use
StringHashMapfor grouping and aggregation - Tagged unions (
enum) model output format options cleanly - Chain operations with error unions and handle failures gracefully
deferensures cleanup even when errors occur in pipelinesDebugAllocatorwith leak detection catches memory bugs- Build output formatters with
std.debug.printformat 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