Chapter 05Project Tempconv Cli

Project

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 switch to 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:

graph TB OS["Operating System"] EXEC["execve() system call"] KERNEL["Kernel loads ELF"] STACK["Stack setup:<br/>argc, argv[], envp[]"] START["_start entry point<br/>(naked assembly)"] POSIX["posixCallMainAndExit<br/>(argc_argv_ptr)"] PARSE["Parse stack layout:<br/>argc at [0]<br/>argv at [1..argc+1]<br/>envp after NULL"] GLOBALS["Set global state:<br/>std.os.argv = argv[0..argc]<br/>std.os.environ = envp"] CALLMAIN["callMainWithArgs<br/>(argc, argv, envp)"] USERMAIN["Your main() function"] ARGS["std.process.args()<br/>reads std.os.argv"] OS --> EXEC EXEC --> KERNEL KERNEL --> STACK STACK --> START START --> POSIX POSIX --> PARSE PARSE --> GLOBALS GLOBALS --> CALLMAIN CALLMAIN --> USERMAIN USERMAIN --> ARGS

Key points:

  • OS Preparation: The operating system places argc (argument count) and argv (argument array) on the stack before transferring control to your program.
  • Assembly Entry: The _start symbol (written in inline assembly) is the true entry point, not main().
  • Stack Parsing: posixCallMainAndExit reads the stack layout to extract argc, argv, and environment variables.
  • Global State: Before calling your main(), the runtime populates std.os.argv and std.os.environ with the parsed data.
  • User Access: When you call std.process.args(), it simply returns an iterator over the already-populated std.os.argv slice.

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 run or 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.

Zig
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) },
    );
}
Run
Shell
$ zig run tempconv_cli.zig -- 32 F C
Output
Shell
32.00 f -> 0.00 c

The 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.

Shell
$ zig run tempconv_cli.zig -- 273.15 K C
Output
Shell
273.15 k -> 0.00 c

Notes & 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.print writes to stderr, which keeps scripted pipelines safe—swap to buffered stdout writers if you need clean stdout output; see #Debug.

Exercises

  • Expand parseUnit to recognise the full words celsius, fahrenheit, and kelvin alongside 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 --table mode that prints conversions for a range of values, reinforcing slice iteration with for, 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.formatFloat with locale-aware post-processing if you need that behaviour.
  • To support scripted usage without invoking zig run, package the program with zig build-exe and place the binary on your PATH.

Help make this chapter better.

Found a typo, rough edge, or missing explanation? Open an issue or propose a small improvement on GitHub.