Skip to content

Rust Raspberry Pi System-Status Project

This Rust project reads Linux memory information and one thermal zone, preserving unavailable readings as errors rather than inventing a zero. Build and test it with fixtures first, then run the same binary against the Raspberry Pi's live read-only interfaces.

Learning goals and project contract

Combine modules, data modelling, error policy, tests and CLI I/O. The library owns parsers, configuration and rendering; the binary supplies OS arguments, filesystem reads and exit status. No inheritance hierarchy, unsafe code, worker pool or async runtime is required for two small sequential reads.

Use stable Rust, Cargo and edition 2024 on Linux. --root selects a filesystem prefix, defaulting to /. --strict returns failure when any reading is unavailable; ordinary mode prints partial results and warnings successfully. Argument or output errors still fail. --help alone reads nothing. This is an observation tool, not a cooling controller or benchmark.

Choose Linux sources and units explicitly

The Linux proc documentation describes MemTotal as usable RAM and MemAvailable as an estimate of memory available for new applications without swapping. The kernel's meminfo formatter labels 1024-byte units kB; this project reports them explicitly as KiB. total minus available is an estimate, not a precise sum of application allocations. We reject malformed, duplicate or inconsistent required fields rather than adding overlapping counters.

The thermal sysfs interface supplies a zone's type and temperature. Its documented temperature ABI uses millidegrees Celsius. Zone zero is not universally the CPU: display its type and do not infer board identity from its number. The verified Pi exposes cpu-thermal; other Linux installations may have different zones or none.

Create the four-file Cargo project

1
2
3
4
5
6
7
8
mkdir -p pi_status/src pi_status/tests
cd pi_status
# Create the four project files below.
cargo fmt --check
cargo check --offline
cargo test --offline
cargo test --offline --release
cargo doc --offline --no-deps

Cargo.toml

1
2
3
4
[package]
name = "pi_status"
version = "0.1.0"
edition = "2024"

src/lib.rs

#![forbid(unsafe_code)]

use std::ffi::OsString;
use std::io::{self, Write};
use std::path::{Path, PathBuf};

pub const USAGE: &str = "Usage: pi_status [--root ROOT] [--strict]";

pub struct Config {
    pub root: PathBuf,
    pub strict: bool,
}

pub fn parse_args(
    arguments: impl IntoIterator<Item = OsString>,
) -> Result<Option<Config>, &'static str> {
    let mut arguments = arguments.into_iter();
    let mut root = None;
    let mut strict = false;
    while let Some(option) = arguments.next() {
        if option == "--root" && root.is_none() {
            root = Some(PathBuf::from(arguments.next().ok_or("missing root")?));
        } else if option == "--strict" && !strict {
            strict = true;
        } else if option == "--help" && root.is_none() && !strict && arguments.next().is_none() {
            return Ok(None);
        } else {
            return Err("unknown, duplicate or misplaced argument");
        }
    }
    Ok(Some(Config {
        root: root.unwrap_or_else(|| PathBuf::from("/")),
        strict,
    }))
}

#[derive(Debug, PartialEq)]
pub struct Memory {
    total_kib: u64,
    available_kib: u64,
}

/// Parse only the required memory fields, without guessing missing values.
///
/// ```
/// let memory = pi_status::parse_memory("MemTotal: 100 kB\nMemAvailable: 40 kB\n").unwrap();
/// assert_eq!(memory.used_estimate_kib(), 60);
/// ```
pub fn parse_memory(input: &str) -> Result<Memory, &'static str> {
    let mut total = None;
    let mut available = None;
    for line in input.lines() {
        let Some((key, value)) = line.split_once(':') else {
            continue;
        };
        let slot = match key {
            "MemTotal" => &mut total,
            "MemAvailable" => &mut available,
            _ => continue,
        };
        if slot.is_some() {
            return Err("duplicate memory field");
        }
        let mut parts = value.split_whitespace();
        let number = parts
            .next()
            .ok_or("missing memory value")?
            .parse::<u64>()
            .map_err(|_| "invalid memory value")?;
        if parts.next() != Some("kB") || parts.next().is_some() {
            return Err("invalid memory unit or extra tokens");
        }
        *slot = Some(number);
    }
    let total = total.ok_or("missing MemTotal")?;
    let available = available.ok_or("missing MemAvailable")?;
    if total == 0 || available > total {
        return Err("inconsistent memory values");
    }
    Ok(Memory {
        total_kib: total,
        available_kib: available,
    })
}

impl Memory {
    pub fn used_estimate_kib(&self) -> u64 {
        self.total_kib - self.available_kib
    }
}

#[derive(Debug, PartialEq)]
pub struct Thermal {
    zone: String,
    millidegrees: i32,
}

pub fn parse_thermal(zone: &str, temperature: &str) -> Result<Thermal, &'static str> {
    let zone = zone.trim();
    if zone.is_empty() || zone.chars().any(char::is_control) {
        return Err("invalid thermal zone name");
    }
    let millidegrees = temperature
        .trim()
        .parse::<i32>()
        .map_err(|_| "invalid temperature")?;
    Ok(Thermal {
        zone: zone.to_owned(),
        millidegrees,
    })
}

pub struct Snapshot {
    pub memory: Result<Memory, String>,
    pub thermal: Result<Thermal, String>,
}

impl Snapshot {
    pub fn complete(&self) -> bool {
        self.memory.is_ok() && self.thermal.is_ok()
    }
}

pub fn collect(root: &Path, read: impl Fn(&Path) -> io::Result<String>) -> Snapshot {
    let load = |relative: &str| {
        let path = root.join(relative);
        read(&path).map_err(|error| format!("{path:?}: {error}"))
    };
    let memory = load("proc/meminfo").and_then(|text| parse_memory(&text).map_err(str::to_owned));
    let thermal = (|| {
        let zone = load("sys/class/thermal/thermal_zone0/type")?;
        let temperature = load("sys/class/thermal/thermal_zone0/temp")?;
        parse_thermal(&zone, &temperature).map_err(str::to_owned)
    })();
    Snapshot { memory, thermal }
}

pub fn write_snapshot(
    snapshot: &Snapshot,
    output: &mut impl Write,
    diagnostics: &mut impl Write,
) -> io::Result<()> {
    match &snapshot.memory {
        Ok(memory) => writeln!(
            output,
            "memory_total_kib={}, memory_available_kib={}, memory_used_estimate_kib={}",
            memory.total_kib,
            memory.available_kib,
            memory.used_estimate_kib()
        )?,
        Err(reason) => {
            writeln!(output, "memory=unavailable")?;
            writeln!(diagnostics, "memory: {reason}")?;
        }
    }
    match &snapshot.thermal {
        Ok(thermal) => writeln!(
            output,
            "thermal_zone={:?}, temperature_mC={}",
            thermal.zone, thermal.millidegrees
        )?,
        Err(reason) => {
            writeln!(output, "thermal=unavailable")?;
            writeln!(diagnostics, "thermal: {reason}")?;
        }
    }
    output.flush()?;
    diagnostics.flush()
}

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

    #[test]
    fn memory_units_and_estimate_are_explicit() {
        let memory =
            parse_memory("MemAvailable: 1024 kB\nOther: 123\nMemTotal: 4096 kB\n").unwrap();
        assert_eq!(memory.used_estimate_kib(), 3072);
    }

    #[test]
    fn missing_duplicate_units_and_inconsistent_memory_are_errors() {
        for input in [
            "MemTotal: 4096 kB\n",
            "MemTotal: 4096 kB\nMemTotal: 4096 kB\nMemAvailable: 0 kB\n",
            "MemTotal: 4096 MB\nMemAvailable: 0 kB\n",
            "MemTotal: 1 kB\nMemAvailable: 2 kB\n",
            "MemTotal: 0 kB\nMemAvailable: 0 kB\n",
            "MemTotal: -1 kB\nMemAvailable: 0 kB\n",
        ] {
            assert!(parse_memory(input).is_err(), "{input}");
        }
    }

    #[test]
    fn zero_negative_and_trimmed_temperature_are_values() {
        assert_eq!(
            parse_thermal("cpu-thermal\n", "0\n").unwrap().millidegrees,
            0
        );
        assert_eq!(
            parse_thermal("fixture", " -500 ").unwrap().millidegrees,
            -500
        );
        assert!(parse_thermal("", "46700").is_err());
        assert!(parse_thermal("cpu-thermal", "invalid").is_err());
    }

    #[test]
    fn fixture_collection_uses_relative_paths_under_root() {
        let root = Path::new("fixture");
        let snapshot = collect(root, |path| {
            match path.strip_prefix(root).unwrap().to_str().unwrap() {
                "proc/meminfo" => Ok("MemTotal: 4096 kB\nMemAvailable: 1024 kB\n".into()),
                "sys/class/thermal/thermal_zone0/type" => Ok("cpu-thermal\n".into()),
                "sys/class/thermal/thermal_zone0/temp" => Ok("46700\n".into()),
                _ => panic!("unexpected path"),
            }
        });
        assert!(snapshot.complete());
        let mut output = Vec::new();
        let mut errors = Vec::new();
        write_snapshot(&snapshot, &mut output, &mut errors).unwrap();
        assert!(errors.is_empty());
        assert_eq!(
            String::from_utf8(output).unwrap(),
            "memory_total_kib=4096, memory_available_kib=1024, memory_used_estimate_kib=3072\nthermal_zone=\"cpu-thermal\", temperature_mC=46700\n"
        );
    }

    #[test]
    fn unavailable_is_not_a_zero_measurement() {
        let snapshot = collect(
            Path::new("fixture"),
            |_| Err(io::ErrorKind::NotFound.into()),
        );
        assert!(!snapshot.complete());
        let mut output = Vec::new();
        let mut errors = Vec::new();
        write_snapshot(&snapshot, &mut output, &mut errors).unwrap();
        assert_eq!(output, b"memory=unavailable\nthermal=unavailable\n");
        assert!(!errors.is_empty());
    }

    #[test]
    fn denied_thermal_read_does_not_discard_valid_memory() {
        let snapshot = collect(Path::new("fixture"), |path| {
            if path.ends_with("proc/meminfo") {
                Ok("MemTotal: 4096 kB\nMemAvailable: 1024 kB\n".into())
            } else {
                Err(io::ErrorKind::PermissionDenied.into())
            }
        });
        assert!(snapshot.memory.is_ok());
        assert!(snapshot.thermal.is_err());
        assert!(!snapshot.complete());
    }

    #[test]
    fn options_preserve_root_and_reject_duplicate_flags() {
        let config = parse_args(["--strict", "--root", "fixture"].map(OsString::from))
            .unwrap()
            .unwrap();
        assert!(config.strict);
        assert_eq!(config.root, Path::new("fixture"));
        assert!(parse_args(["--strict", "--strict"].map(OsString::from)).is_err());
        assert!(parse_args(["--root"].map(OsString::from)).is_err());
        assert!(
            parse_args(["--help"].map(OsString::from))
                .unwrap()
                .is_none()
        );
    }

    #[test]
    fn output_and_flush_failures_propagate() {
        struct Broken(bool);
        impl Write for Broken {
            fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
                if self.0 {
                    Err(io::ErrorKind::BrokenPipe.into())
                } else {
                    Ok(bytes.len())
                }
            }
            fn flush(&mut self) -> io::Result<()> {
                Err(io::ErrorKind::BrokenPipe.into())
            }
        }
        let snapshot = Snapshot {
            memory: parse_memory("MemTotal: 100 kB\nMemAvailable: 40 kB\n").map_err(str::to_owned),
            thermal: parse_thermal("fixture", "0").map_err(str::to_owned),
        };
        for fail_write in [true, false] {
            assert!(write_snapshot(&snapshot, &mut Broken(fail_write), &mut Vec::new()).is_err());
        }
    }
}

src/main.rs

#![forbid(unsafe_code)]

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

fn main() -> ExitCode {
    let config = match pi_status::parse_args(std::env::args_os().skip(1)) {
        Ok(Some(config)) => config,
        Ok(None) => {
            return if writeln!(io::stdout().lock(), "{}", pi_status::USAGE).is_ok() {
                ExitCode::SUCCESS
            } else {
                ExitCode::FAILURE
            };
        }
        Err(reason) => {
            let _ = writeln!(io::stderr().lock(), "{reason}; {}", pi_status::USAGE);
            return ExitCode::from(2);
        }
    };
    let snapshot = pi_status::collect(&config.root, |path| std::fs::read_to_string(path));
    if let Err(error) = pi_status::write_snapshot(
        &snapshot,
        &mut io::stdout().lock(),
        &mut io::stderr().lock(),
    ) {
        let _ = writeln!(io::stderr().lock(), "output failed: {error}");
        return ExitCode::FAILURE;
    }
    if config.strict && !snapshot.complete() {
        ExitCode::FAILURE
    } else {
        ExitCode::SUCCESS
    }
}

tests/cli_contract.rs

use std::process::Command;

#[test]
fn help_has_success_status_and_no_diagnostic() {
    let output = Command::new(env!("CARGO_BIN_EXE_pi_status"))
        .arg("--help")
        .output()
        .unwrap();
    assert!(output.status.success());
    assert_eq!(
        output.stdout,
        b"Usage: pi_status [--root ROOT] [--strict]\n"
    );
    assert!(output.stderr.is_empty());
}

#[test]
fn argument_error_has_no_success_report() {
    let output = Command::new(env!("CARGO_BIN_EXE_pi_status"))
        .arg("--wrong")
        .output()
        .unwrap();
    assert_eq!(output.status.code(), Some(2));
    assert!(output.stdout.is_empty());
    assert!(!output.stderr.is_empty());
}

Fixture first: three known input files

Create these paths beneath fixtures. They represent artificial values, not the installed Pi's measurements:

1
2
3
mkdir -p fixtures/proc fixtures/sys/class/thermal/thermal_zone0
# Save the three contents shown below at their stated paths.
cargo run --offline --quiet -- --root fixtures --strict

fixtures/proc/meminfo:

MemTotal: 4096 kB
MemAvailable: 1024 kB

fixtures/sys/class/thermal/thermal_zone0/type:

cpu-thermal

fixtures/sys/class/thermal/thermal_zone0/temp:

46700

Expected fixture stdout, with no stderr diagnostic and status zero:

memory_total_kib=4096, memory_available_kib=1024, memory_used_estimate_kib=3072
thermal_zone="cpu-thermal", temperature_mC=46700

The fixture's small memory size is intentional. Changing those values is a parser exercise, not changing Linux RAM configuration.

Then run the same application against the Pi

1
2
3
cargo build --offline --release
./target/release/pi_status --strict
echo "$?"

This reads only /proc/meminfo and thermal_zone0/type and temp. Do not add sudo merely because a reading is unavailable: identify permissions, missing mounts, driver support or a different zone first. A desktop Linux machine can also run the tool, but zone identity and availability vary.

Live output changes with system activity and thermal conditions. Do not compare it byte-for-byte with fixture output or present one observed temperature as a benchmark. The reads are sequential, not an atomic system-wide snapshot. --root is a test/input prefix, not a sandbox: filesystem symlinks can resolve outside it. The program reads whole text files and is not an untrusted-file service.

Explain the architecture through contracts

parse_memory and parse_thermal are pure: errors have no I/O side effects. Memory's fields are private, so safe callers cannot manufacture an available-greater-than-total state and then underflow the estimate method. Snapshot owns parsed values or an error description for each source; a successful memory read survives a thermal failure.

collect accepts a reader, preserving the same path logic for fixture and live reads. Relative suffixes are important: joining a suffix beginning with / would discard the chosen root. Rendering borrows the snapshot, escapes the zone name with Debug formatting and checks write/flush errors. main decides the exit policy separately. For a reusable production API, retain typed error sources rather than flattening them into our display-oriented String diagnostics.

Ordinary mode deliberately allows partial output with warnings. Strict mode returns failure but still prints the successfully collected fields and explicit unavailable markers. An output failure is always failure and can leave partial bytes already written. This is a different contract from lesson 27's all-or-nothing input report; document it rather than making callers infer it.

Final integration checkpoint

Explain, then test, these changes before adding new sources:

  1. Change the fixture temperature to zero and -500; both are measured values. A missing temp file is unavailable, not zero.
  2. Remove MemAvailable, duplicate MemTotal, change kB to MB, and make available exceed total. Each must reject the memory reading rather than fabricate a default.
  3. Provide valid memory with a missing thermal file. Ordinary mode succeeds with a warning; strict mode returns 1 on this Linux target. Both preserve the valid memory line.
  4. Swap the order of --strict and --root, try a missing root argument and duplicate flags, and check stdout, stderr and status independently.
  5. Inject PermissionDenied through the reader, then a broken/flush-failing writer. Distinguish source failure from output failure without modifying actual system permissions.
  6. Explain why traits are useful at the I/O boundary but a custom trait object hierarchy, shared ownership or async runtime is not needed for these three small files. Revisit the worker checkpoint before adding periodic/background work.

These are completion criteria for integration, not a demand to use every language feature in one program. Add GPIO, system services, packaging or cross-compilation as separate projects with their own operational contracts.

Verification and next step

On October 10, 2026, the four-file project 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, formatting, eight unit tests, two CLI integration tests and one documentation test passed in debug/release; documentation was generated. Twelve fixture process contracts per profile covered argument order/errors, partial reads, strict status, invalid units/temperature, zero and negative values. Injected read denial and write/flush failures were tested. The release binary also read live /proc/meminfo and the cpu-thermal zone successfully without sudo; these changing measurements are not the fixture oracle or a benchmark. No device settings, cooling policies or permissions were changed.

Continue with reading the Rust Reference and the final course review. Use the reading map to distinguish a language guarantee from a library or application contract.

Previous: files and CLI · Next: reading the Reference · Course overview

Donate