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¶
The exact readings.txt fixture contains a blank second line:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 | |
Default fixture output:
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:
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.
- Test no arguments, a missing threshold, an unknown trailing option, extra arguments and --help. State which cases should read a file.
- Lower the threshold to zero. Zero must count as measured and matching, not missing. Negative values remain valid within this fixture policy.
- Test an empty file and CRLF input. Explain why a terminal newline does not add a missing record.
- 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.
- Add a writer that succeeds at write but fails on flush. Expect CliError::Output as well.
- Change >= to > in a scratch copy. The boundary test must detect the altered policy; changing business semantics is not a formatting repair.
- 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