Skip to main contentCLI Output and Exit Status | IoT Worker

CLI Output and Exit Status

Command-line output is not just printing text. For shells, CI, and other programs, the interface is often:

stdout   -> normal data
stderr   -> diagnostics and errors
exit code -> success or failure

C code commonly writes:

printf("%s\n", result);
fprintf(stderr, "tool: %s\n", error);
return 1;

Rust has the same boundaries, with a few ways to express them.

stdout Is for Normal Results

Simple output uses println!:

println!("{count}\t{path}");

It writes to stdout, which is what users and pipelines consume:

tool input.txt > result.txt

For many lines, or when write errors matter, use std::io::Write:

use std::io::{self, Write};

fn write_report(rows: &[String]) -> io::Result<()> {
    let stdout = io::stdout();
    let mut out = stdout.lock();

    for row in rows {
        writeln!(out, "{row}")?;
    }

    Ok(())
}

This is better for library-like output logic and can handle pipe errors.

stderr Is for Errors and Diagnostics

Simple error output uses eprintln!:

eprintln!("tool: failed to read {path}: {err}");

It writes to stderr. Users can redirect normal output to a file and still see the error:

tool input.txt > result.txt

Common stderr content:

  • usage and argument errors
  • file I/O failures
  • debug, warning, and progress messages
  • human-readable error messages

Do not mix machine-readable normal data into stderr, and do not mix errors into stdout. Once scripts call the tool, these streams are a protocol.

main Can Return ExitCode

C main returns an integer:

int main(void)
{
    if (failed) {
        return 1;
    }
    return 0;
}

Rust can return ExitCode:

use std::process::ExitCode;

fn main() -> ExitCode {
    if let Err(err) = run() {
        eprintln!("tool: {err}");
        return ExitCode::FAILURE;
    }

    ExitCode::SUCCESS
}

fn run() -> Result<(), Box<dyn std::error::Error>> {
    Ok(())
}

This is often easier to maintain than calling process::exit(1) at every error point. run remains an ordinary function that can be tested and reused; main converts the final result into a process exit status.

process::exit Ends the Process Directly

The first article used:

std::process::exit(1);

It works, but it means: end the current process now.

That is acceptable at a thin main boundary, but not inside library functions:

fn parse_config(path: &str) -> Config {
    // Do not call process::exit(1) inside a library function
    todo!()
}

Library functions should return errors:

fn parse_config(path: &str) -> Result<Config, ConfigError> {
    let text = std::fs::read_to_string(path)?;
    Config::parse(&text)
}

The CLI layer decides how to print and which exit code to use:

fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(err) => {
            eprintln!("tool: {err}");
            ExitCode::FAILURE
        }
    }
}

Usage Errors and Runtime Errors Can Differ

C tools often use different exit codes:

if (argc != 2) {
    fprintf(stderr, "usage: tool <file>\n");
    return 2;
}

if (read_failed) {
    return 1;
}

Rust can do the same:

use std::process::ExitCode;

fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(AppError::Usage(msg)) => {
            eprintln!("{msg}");
            ExitCode::from(2)
        }
        Err(err) => {
            eprintln!("tool: {err}");
            ExitCode::FAILURE
        }
    }
}

AppError can be your own error type:

#[derive(Debug)]
enum AppError {
    Usage(String),
    Io(std::io::Error),
}

impl std::fmt::Display for AppError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            AppError::Usage(msg) => write!(f, "{msg}"),
            AppError::Io(err) => write!(f, "{err}"),
        }
    }
}

impl std::error::Error for AppError {}

At the first layer, the important split is usage error versus runtime failure.

Check Child Process Exit Status

When using Command::status(), a successful API call means the child process was started and waited for. It does not mean the child command succeeded:

use std::process::Command;

let status = Command::new("gzip")
    .arg("file.log")
    .status()?;

if !status.success() {
    eprintln!("gzip failed: {status}");
    return ExitCode::FAILURE;
}

If you capture output:

let output = Command::new("git")
    .args(["rev-parse", "--short", "HEAD"])
    .output()?;

if !output.status.success() {
    eprintln!("git failed: {}", output.status);
    return ExitCode::FAILURE;
}

This matches the C habit of checking system() or waitpid() status. Do not only check whether the Rust API returned Err; also check the child process exit status.

Broken Pipe May Be Normal

CLI tools often get their output cut short:

tool huge-file | head

After head exits, tool may get a broken pipe when it continues writing stdout. With println!, the default behavior may not be what every tool wants. For control, write stdout explicitly:

use std::io::{self, Write};

fn run() -> io::Result<()> {
    let stdout = io::stdout();
    let mut out = stdout.lock();

    for i in 0..1_000_000 {
        writeln!(out, "{i}")?;
    }

    Ok(())
}

Then main can decide whether a particular write error should be noisy. First rule: output-heavy tools should not ignore stdout write errors completely.

What to Keep

The CLI boundary mapping:

printf normal output        -> println! / stdout + Write
fprintf(stderr, ...)        -> eprintln! / stderr + Write
return 0                    -> ExitCode::SUCCESS
return 1                    -> ExitCode::FAILURE / ExitCode::from(n)
main handles final error     -> run() -> Result + main maps to ExitCode
library function failure     -> return Result, do not exit the process
system/wait status           -> Command status / output.status

The outer CLI layer can turn errors into text and exit codes. Library functions should not decide when the process ends. stdout, stderr, and exit code are the tool’s external contract and should be maintained like an API.

Continue Reading