Skip to content

Rust Files, Arguments and a Linux CLI

A useful Rust CLI distinguishes invalid arguments, failed file reads, invalid records and failed output. This lesson builds a read-only fixture reporter with an explicit command contract, testable I/O boundaries and predictable process exit codes.

Learning goals and prerequisites

Combine Result and errors, traits and closures and testing. After the Part III checkpoint, recognise that this single-file task needs neither a worker nor unsafe code.

Use stable Rust, Cargo and edition 2024. The program reads an ordinary UTF-8 text file: one integer reading per line, with an empty line representing missing data. Values and the threshold are teaching policy, not hardware limits. It writes a report to stdout and diagnostics to stderr; it does not modify the input.

Rust CLI contract before implementation

The command accepts a file path, optionally followed by --threshold and an integer. Default threshold is 60000 mC. Values and thresholds must be within -100000 through 150000 inclusive. An invalid record rejects the whole report, while zero remains a measurement and blank lines count as missing. No arguments is a usage error; --help alone is successful and reads no file.

The args_os API preserves OS arguments without requiring Unicode. A path becomes PathBuf directly; only option names and numeric threshold text require interpretation as strings. See Path for filesystem path semantics. Quote paths containing spaces in a shell. To read a file literally named --help, pass ./--help instead.

Create and run the complete program

cargo new rust_cli --edition 2024
cd rust_cli
# Replace src/main.rs with the program below.
# Save the following fixture as readings.txt.
cargo fmt --check
cargo check --offline
cargo test --offline
cargo test --offline --release
cargo run --offline --quiet -- readings.txt
cargo run --offline --quiet -- readings.txt --threshold 46700
cargo run --offline --quiet -- --help
# Build once before using the binary directly in shell scripts.
cargo build --offline
./target/debug/rust_cli readings.txt

The exact readings.txt fixture contains a blank second line:

1
2
3
4
46700

0
60000
#![forbid(unsafe_code)]

use std::error::Error;
use std::ffi::OsString;
use std::fmt;
use std::io::{self, Write};
use std::num::ParseIntError;
use std::path::{Path, PathBuf};
use std::process::ExitCode;

const USAGE: &str = "Usage: rust_cli FILE [--threshold MILLICELSIUS]";

#[derive(Debug)]
enum CliError {
    Argument(&'static str),
    Read(io::Error),
    Invalid { line: usize, source: ParseIntError },
    Range { line: usize },
    Overflow,
    Output(io::Error),
}

impl fmt::Display for CliError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Argument(reason) => write!(formatter, "{reason}; {USAGE}"),
            Self::Read(error) => write!(formatter, "cannot read input: {error}"),
            Self::Invalid { line, source } => write!(formatter, "line {line}: {source}"),
            Self::Range { line } => write!(formatter, "line {line}: outside fixture range"),
            Self::Overflow => write!(formatter, "report sum overflow"),
            Self::Output(error) => write!(formatter, "cannot write report: {error}"),
        }
    }
}

impl Error for CliError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            Self::Read(error) | Self::Output(error) => Some(error),
            Self::Invalid { source, .. } => Some(source),
            _ => None,
        }
    }
}

struct Config {
    path: PathBuf,
    threshold: i32,
}

fn parse_args(arguments: impl IntoIterator<Item = OsString>) -> Result<Option<Config>, CliError> {
    let mut arguments = arguments.into_iter();
    let first = arguments.next().ok_or(CliError::Argument("missing file"))?;
    if first == "--help" {
        return if arguments.next().is_none() {
            Ok(None)
        } else {
            Err(CliError::Argument("help takes no extra arguments"))
        };
    }
    let mut threshold = 60_000;
    if let Some(option) = arguments.next() {
        if option != "--threshold" {
            return Err(CliError::Argument("unknown option"));
        }
        let value = arguments
            .next()
            .ok_or(CliError::Argument("missing threshold"))?;
        threshold = value
            .to_str()
            .and_then(|text| text.parse::<i32>().ok())
            .ok_or(CliError::Argument("invalid threshold"))?;
    }
    if arguments.next().is_some() {
        return Err(CliError::Argument("unexpected extra argument"));
    }
    if !(-100_000..=150_000).contains(&threshold) {
        return Err(CliError::Argument("threshold outside fixture range"));
    }
    Ok(Some(Config {
        path: first.into(),
        threshold,
    }))
}

#[derive(Debug, Default, PartialEq)]
struct Report {
    measured: usize,
    missing: usize,
    matching: usize,
    sum: i64,
}

fn report(input: &str, threshold: i32) -> Result<Report, CliError> {
    let mut result = Report::default();
    for (index, text) in input.lines().enumerate() {
        let text = text.trim();
        if text.is_empty() {
            result.missing += 1;
            continue;
        }
        let line = index + 1;
        let value = text
            .parse::<i32>()
            .map_err(|source| CliError::Invalid { line, source })?;
        if !(-100_000..=150_000).contains(&value) {
            return Err(CliError::Range { line });
        }
        result.sum = result
            .sum
            .checked_add(i64::from(value))
            .ok_or(CliError::Overflow)?;
        result.measured += 1;
        result.matching += usize::from(value >= threshold);
    }
    Ok(result)
}

fn execute(
    arguments: impl IntoIterator<Item = OsString>,
    read: impl FnOnce(&Path) -> io::Result<String>,
    output: &mut impl Write,
) -> Result<(), CliError> {
    let Some(config) = parse_args(arguments)? else {
        writeln!(output, "{USAGE}").map_err(CliError::Output)?;
        return output.flush().map_err(CliError::Output);
    };
    let input = read(&config.path).map_err(CliError::Read)?;
    let result = report(&input, config.threshold)?;
    writeln!(
        output,
        "measured={}, missing={}, matching={}, sum={}",
        result.measured, result.missing, result.matching, result.sum
    )
    .map_err(CliError::Output)?;
    output.flush().map_err(CliError::Output)
}

fn main() -> ExitCode {
    let result = execute(
        std::env::args_os().skip(1),
        |path| std::fs::read_to_string(path),
        &mut io::stdout().lock(),
    );
    match result {
        Ok(()) => ExitCode::SUCCESS,
        Err(error) => {
            let _ = writeln!(io::stderr().lock(), "{error}");
            if matches!(error, CliError::Argument(_)) {
                ExitCode::from(2)
            } else {
                ExitCode::FAILURE
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn args(values: &[&str]) -> Vec<OsString> {
        values.iter().map(OsString::from).collect()
    }

    #[test]
    fn default_and_explicit_thresholds_are_distinct() {
        assert_eq!(
            parse_args(args(&["fixture"])).unwrap().unwrap().threshold,
            60_000
        );
        assert_eq!(
            parse_args(args(&["fixture", "--threshold", "0"]))
                .unwrap()
                .unwrap()
                .threshold,
            0
        );
    }

    #[test]
    fn malformed_arguments_do_not_reach_the_reader() {
        for input in [
            vec![],
            vec!["file", "--wrong"],
            vec!["file", "--threshold"],
            vec!["file", "--threshold", "NaN"],
            vec!["--help", "extra"],
        ] {
            let result = execute(args(&input), |_| panic!("must not read"), &mut Vec::new());
            assert!(matches!(result, Err(CliError::Argument(_))));
        }
    }

    #[test]
    fn help_is_successful_without_reading_input() {
        let mut output = Vec::new();
        execute(args(&["--help"]), |_| panic!("must not read"), &mut output).unwrap();
        assert_eq!(String::from_utf8(output).unwrap(), format!("{USAGE}\n"));
    }

    #[test]
    fn zero_blank_negative_and_boundary_are_not_conflated() {
        let result = report("0\n\n-500\n60000\n", 60_000).unwrap();
        assert_eq!(
            result,
            Report {
                measured: 3,
                missing: 1,
                matching: 1,
                sum: 59_500
            }
        );
        assert_eq!(report("", 0).unwrap(), Report::default());
    }

    #[test]
    fn invalid_record_has_a_line_and_error_source() {
        let error = report("46700\ninvalid\n", 0).unwrap_err();
        assert!(matches!(error, CliError::Invalid { line: 2, .. }));
        assert!(error.source().is_some());
    }

    #[test]
    fn out_of_range_record_rejects_the_report() {
        assert!(matches!(
            report("150001\n", 0),
            Err(CliError::Range { line: 1 })
        ));
    }

    #[test]
    fn read_failures_and_invalid_records_emit_no_success_report() {
        let mut output = Vec::new();
        let denied = execute(
            args(&["file"]),
            |_| Err(io::ErrorKind::PermissionDenied.into()),
            &mut output,
        );
        assert!(matches!(denied, Err(CliError::Read(_))));
        assert!(output.is_empty());
        assert!(execute(args(&["file"]), |_| Ok("invalid".into()), &mut output).is_err());
        assert!(output.is_empty());
    }

    #[test]
    fn successful_report_uses_the_injected_reader_and_writer() {
        let mut output = Vec::new();
        execute(
            args(&["file"]),
            |path| {
                assert_eq!(path, Path::new("file"));
                Ok("46700\n\n0\n60000\n".into())
            },
            &mut output,
        )
        .unwrap();
        assert_eq!(
            String::from_utf8(output).unwrap(),
            "measured=3, missing=1, matching=1, sum=106700\n"
        );
    }

    #[test]
    fn output_failure_is_an_error_not_success() {
        struct Broken;
        impl Write for Broken {
            fn write(&mut self, _: &[u8]) -> io::Result<usize> {
                Err(io::ErrorKind::BrokenPipe.into())
            }
            fn flush(&mut self) -> io::Result<()> {
                Ok(())
            }
        }
        assert!(matches!(
            execute(args(&["file"]), |_| Ok("0".into()), &mut Broken),
            Err(CliError::Output(_))
        ));
    }

    #[cfg(unix)]
    #[test]
    fn non_unicode_path_remains_an_os_path() {
        use std::os::unix::ffi::{OsStrExt, OsStringExt};
        let path = OsString::from_vec(vec![b'p', 0xff]);
        let config = parse_args([path]).unwrap().unwrap();
        assert_eq!(config.path.as_os_str().as_bytes(), &[b'p', 0xff]);
    }
}

Default fixture output:

measured=3, missing=1, matching=1, sum=106700

Changing the threshold to 46700 produces matching=2 with the same counts and sum. Every blank record matters, but a trailing newline is a line terminator, not an extra invented record; see str::Lines.

Keep parsing, I/O and presentation separate

parse_args interprets command syntax before reading. report processes borrowed text and returns owned counts. execute accepts a reader and a Write sink, so tests can provide fixtures, denied reads and broken output without changing filesystem permissions. The tests exercise the same implementation as main, not a separate simulated parser.

read_to_string can fail because of permissions, missing files or invalid UTF-8 contents. Accepting a non-Unicode path does not mean accepting arbitrary binary file contents. The program reads the entire file into memory; use buffered/streaming parsing with an explicit size policy for untrusted large inputs. A successful open is not a guarantee that all later reads succeed.

Our Error implementation retains sources for parsing and I/O failures. Report line numbers start at one. Nothing is printed before the complete report validates, though an output failure can occur after some bytes have been written: I/O is not atomic rollback. flush errors are also returned. Diagnostic output is best-effort if stderr itself is broken.

Exit status is part of the CLI API

The binary returns ExitCode: success for a report/help, 2 for argument mistakes, and the platform's ordinary failure code for input or output errors. On the verified Linux target the latter is 1. This distinction is our application policy, not a universal Rust CLI convention. Returning from main permits ordinary destruction; immediately calling process::exit would not run stack destructors.

Check a binary's status immediately after it runs:

./target/debug/rust_cli missing.txt
echo "$?"

Expect a stderr diagnostic and status 1, not a fabricated report. Cargo's own diagnostic/status is separate; scripts that need the application's contract should build once and invoke the binary. PermissionDenied is tested through injected I/O rather than sudo or changing an arbitrary user's files.

Deliberate process failure and exercises

Unlike a compile-failure lesson, these are valid programs reporting invalid input. In a scratch file put 46700 on line one and invalid on line two. Expect no success report, a line-2 diagnostic on stderr and status 1. Neither a ParseIntError nor a missing file should cause an unwrap panic.

  1. Test no arguments, a missing threshold, an unknown trailing option, extra arguments and --help. State which cases should read a file.
  2. Lower the threshold to zero. Zero must count as measured and matching, not missing. Negative values remain valid within this fixture policy.
  3. Test an empty file and CRLF input. Explain why a terminal newline does not add a missing record.
  4. Preserve the read error's source and verify PermissionDenied through the existing reader boundary. Do not run the tool as root to conceal access-policy problems.
  5. Add a writer that succeeds at write but fails on flush. Expect CliError::Output as well.
  6. Change >= to > in a scratch copy. The boundary test must detect the altered policy; changing business semantics is not a formatting repair.
  7. Decide whether a streaming version should report partial progress after a later failure. That is a different contract, not a free consequence of using an iterator.

Verification and next step

On October 10, 2026, this lesson was verified on the authorised Raspberry Pi 4B with 64-bit user space, kernel 6.18.50+rpt-rpi-v8, Rust and Cargo 1.99.0, and edition 2024. Checks, ten debug/release tests, formatting and sixteen binary process contracts per profile passed, including argument errors, help, missing/invalid-UTF-8 files, invalid records, range failures, empty files and CRLF input. The flush-failure variant passed an additional test, and a strict-greater-than variant compiled but failed its boundary test as intended. PermissionDenied and BrokenPipe were injected, not caused by changing user files. These tests do not establish live sensor accuracy or a permission policy for every machine.

Continue with the Raspberry Pi system-status project: test captured Linux inputs before reading the live Pi, preserving unavailable data explicitly.

Previous: unsafe boundaries and checkpoint · Course overview

Donate