Overview
Our first project turns the language fundamentals from Chapters 1–4 into a handheld command-line utility that converts temperatures between Celsius, Fahrenheit, and Kelvin. We compose argument parsing, enums, and floating-point math into a single program while keeping diagnostics friendly for end users, as described in #Command-line-flags and #Floats.
Along the way, we reinforce the error-handling philosophy from the previous chapter: validation produces human-readable hints, and the process exits with intent instead of a stack trace; see #Error-Handling.
Learning Goals
- Build a minimal CLI harness that reads arguments, handles
--help, and emits usage guidance. - Represent temperature units with enums and use
switchto normalise conversions, as described in #switch. - Present conversion results while surfacing validation failures through concise diagnostics instead of unwinding traces.
Shaping the Command Interface
Before touching any math, we need a predictable contract: three arguments (value, from-unit, to-unit) plus --help for documentation. The program should explain mistakes up front so callers never see a panic.
How CLI Arguments Reach Your Program
When you run your program from the command line, the operating system passes arguments through a well-defined startup sequence before your main() function ever runs. Understanding this flow clarifies where std.process.args() gets its data:
Key points:
- OS Preparation: The operating system places
argc(argument count) andargv(argument array) on the stack before transferring control to your program. - Assembly Entry: The
_startsymbol (written in inline assembly) is the true entry point, notmain(). - Stack Parsing:
posixCallMainAndExitreads the stack layout to extractargc,argv, and environment variables. - Global State: Before calling your
main(), the runtime populatesstd.os.argvandstd.os.environwith the parsed data. - User Access: When you call
std.process.args(), it simply returns an iterator over the already-populatedstd.os.argvslice.
Why this matters for CLI programs:
- Arguments are available from the moment
main()runs—no separate initialization needed. - The first argument (
argv[0]) is always the program name. - Argument parsing happens once during startup, not per access.
- This sequence is the same whether you use
zig runor a compiled binary.
This infrastructure means your TempConv CLI can immediately start parsing arguments without worrying about the low-level details of how they arrived.
Parsing Arguments with Guard Rails
The entry point allocates the full argument vector, checks for --help, and verifies the arity. When a rule is violated we print the usage banner and exit with a failure code, relying on std.process.exit to avoid noisy stack traces.
Units and Validation Helpers
We describe the supported units with an enum and a parseUnit helper that accepts either uppercase or lowercase tokens. Invalid tokens trigger a friendly diagnostic and immediate exit, keeping the CLI resilient when embedded in scripts, as described in #enum.
Converting and Reporting Results
With the interface in place, the rest of the program leans on deterministic conversions: every value is normalised to Kelvin and then projected into the requested unit, guaranteeing consistent results regardless of the input combination.
Complete TempConv Listing
The listing below includes argument parsing, unit helpers, and the conversion logic. Focus on how the CLI structure keeps every failure path obvious while keeping the happy path concise.
const std = @import("std");
// Chapter 5 – TempConv CLI: walk from parsing arguments through producing a
// formatted result, exercising everything we have learned about errors and
// deterministic cleanup along the way.
const CliError = error{ MissingArgs, BadNumber, BadUnit };
const Unit = enum { c, f, k };
fn printUsage() void {
std.debug.print("usage: tempconv <value> <from-unit> <to-unit>\n", .{});
std.debug.print("units: C (celsius), F (fahrenheit), K (kelvin)\n", .{});
}
fn parseUnit(token: []const u8) CliError!Unit {
// Section 1: we accept a single-letter token and normalise it so the CLI
// remains forgiving about casing.
if (token.len != 1) return CliError.BadUnit;
const ascii = std.ascii;
const lower = ascii.toLower(token[0]);
return switch (lower) {
'c' => .c,
'f' => .f,
'k' => .k,
else => CliError.BadUnit,
};
}
fn toKelvin(value: f64, unit: Unit) f64 {
return switch (unit) {
.c => value + 273.15,
.f => (value + 459.67) * 5.0 / 9.0,
.k => value,
};
}
fn fromKelvin(value: f64, unit: Unit) f64 {
return switch (unit) {
.c => value - 273.15,
.f => (value * 9.0 / 5.0) - 459.67,
.k => value,
};
}
fn convert(value: f64, from: Unit, to: Unit) f64 {
// Section 2: normalise through Kelvin so every pair of units reuses the
// same formulas, keeping the CLI easy to extend.
if (from == to) return value;
const kelvin = toKelvin(value, from);
return fromKelvin(kelvin, to);
}
pub fn main() !void {
const allocator = std.heap.page_allocator;
const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args);
if (args.len == 1 or (args.len == 2 and std.mem.eql(u8, args[1], "--help"))) {
printUsage();
return;
}
if (args.len != 4) {
std.debug.print("error: expected three arguments\n", .{});
printUsage();
std.process.exit(1);
}
const raw_value = args[1];
const value = std.fmt.parseFloat(f64, raw_value) catch {
// Section 1 also highlights how parsing failures become user-facing
// diagnostics rather than backtraces.
std.debug.print("error: '{s}' is not a floating-point value\n", .{raw_value});
std.process.exit(1);
};
const from = parseUnit(args[2]) catch {
std.debug.print("error: unknown unit '{s}'\n", .{args[2]});
std.process.exit(1);
};
const to = parseUnit(args[3]) catch {
std.debug.print("error: unknown unit '{s}'\n", .{args[3]});
std.process.exit(1);
};
const result = convert(value, from, to);
std.debug.print(
"{d:.2} {s} -> {d:.2} {s}\n",
.{ value, @tagName(from), result, @tagName(to) },
);
}
$ zig run tempconv_cli.zig -- 32 F C32.00 f -> 0.00 cThe program prints diagnostics before exiting whenever it spots an invalid value or unit, so scripts can rely on a non-zero exit status without parsing stack traces.
Exercising Additional Conversions
You can run the same binary for Kelvin or Celsius inputs—the shared conversion helpers guarantee symmetry because everything flows through Kelvin.
$ zig run tempconv_cli.zig -- 273.15 K C273.15 k -> 0.00 cNotes & Caveats
- Argument parsing remains minimal by design; production tools might add long-form flags or richer help text using the same guard patterns.
- Temperature conversions are linear, so double-precision floats suffice; adapt the formulas carefully if you add niche scales such as Rankine.
std.debug.printwrites to stderr, which keeps scripted pipelines safe—swap to buffered stdout writers if you need clean stdout output; see #Debug.
Exercises
- Expand
parseUnitto recognise the full wordscelsius,fahrenheit, andkelvinalongside their single-letter abbreviations. - Add a flag that toggles between rounded output (
{d:.2}) and full precision using Zig’s formatting verbs; see fmt.zig. - Introduce a
--tablemode that prints conversions for a range of values, reinforcing slice iteration withfor, as described in #for.
Alternatives & Edge Cases:
- Kelvin never drops below zero; attach a guard if your CLI should reject negative Kelvin inputs instead of accepting the mathematical value.
- International audiences sometimes expect comma decimals; connect
std.fmt.formatFloatwith locale-aware post-processing if you need that behaviour. - To support scripted usage without invoking
zig run, package the program withzig build-exeand place the binary on yourPATH.