By the end of this lesson
Combine files, validation, counters and error handling in a downloadable program.
Before you start
- Rust 1.90.0 or later, a terminal and a plain-text editor. Examples were tested with Rust 1.90.0, Edition 2024 on Linux.
- Complete lesson 28, or be comfortable explaining and running its examples.
Three words to know
- Pure parser
- A function validating supplied bytes without performing I/O.
- Boundary layer
- Code that reads arguments/files and writes process output.
- Bounded evidence
- Recorded passing cases, with explicit limits on what they establish.
1. Understand the idea
The program separates parse_tasks from run. The parser accepts bytes and returns a Summary or an error without reading files or printing. run handles exactly one path argument, opens an ordinary local file and reads at most 65,537 bytes. main writes a report only after run succeeds. Internal validation details are replaced by a generic public diagnostic, so invalid input and file paths are not echoed.
2. Save, run and observe
Save this complete program as rust-project in a fresh practice folder. It replaces that lesson file only; keep earlier examples separately. Compile it with rustc, then run the executable. Rust source goes in the editor; rustc and cargo commands go in the terminal.
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 OpenViST
use std::fs::File;
use std::io::{self, Read, Write};
const BYTE_LIMIT: u64 = 65_536;
const ROW_LIMIT: usize = 1_000;
#[derive(Debug, PartialEq, Eq)]
struct Summary {
records: usize,
open: usize,
closed: usize,
}
fn parse_tasks(bytes: &[u8]) -> Result<Summary, &'static str> {
if bytes.len() > BYTE_LIMIT as usize {
return Err("too large");
}
let text = std::str::from_utf8(bytes).map_err(|_| "invalid UTF-8")?;
if text.starts_with('\u{feff}') || text.contains('\r') {
return Err("unsupported encoding or line ending");
}
// Remove at most one final LF. A second final LF is an empty record.
let body = text.strip_suffix('\n').unwrap_or(text);
let mut lines = body.split('\n');
if lines.next() != Some("status\ttitle") {
return Err("invalid header");
}
let mut summary = Summary {
records: 0,
open: 0,
closed: 0,
};
for line in lines {
if summary.records == ROW_LIMIT {
return Err("too many records");
}
let (status, title) = line.split_once('\t').ok_or("missing tab")?;
if title.is_empty() || title.chars().count() > 100 || title.chars().any(char::is_control) {
return Err("invalid title");
}
match status {
"open" => summary.open += 1,
"closed" => summary.closed += 1,
_ => return Err("invalid status"),
}
summary.records += 1;
}
if summary.records == 0 {
return Err("no records");
}
Ok(summary)
}
fn format_summary(summary: &Summary) -> String {
format!(
"records={}\nopen={}\nclosed={}\n",
summary.records, summary.open, summary.closed
)
}
fn run() -> Result<String, ()> {
let mut args = std::env::args_os().skip(1);
let path = args.next().ok_or(())?;
if args.next().is_some() {
return Err(());
}
let file = File::open(path).map_err(|_| ())?;
if !file.metadata().map_err(|_| ())?.is_file() {
return Err(());
}
let mut bytes = Vec::new();
file.take(BYTE_LIMIT + 1)
.read_to_end(&mut bytes)
.map_err(|_| ())?;
let summary = parse_tasks(&bytes).map_err(|_| ())?;
Ok(format_summary(&summary))
}
fn main() {
match run() {
Ok(report) => {
if io::stdout().lock().write_all(report.as_bytes()).is_err() {
std::process::exit(1);
}
}
Err(()) => {
let _ = io::stderr().lock().write_all(b"error: invalid input\n");
std::process::exit(2);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn counts_valid_rows() {
assert_eq!(
parse_tasks(b"status\ttitle\nopen\tOne\nclosed\tTwo\nopen\tThree\n"),
Ok(Summary {
records: 3,
open: 2,
closed: 1
})
);
}
#[test]
fn final_newline_is_optional() {
assert_eq!(
parse_tasks(b"status\ttitle\nopen\tOne"),
parse_tasks(b"status\ttitle\nopen\tOne\n")
);
}
#[test]
fn counts_duplicate_rows() {
assert_eq!(
parse_tasks(b"status\ttitle\nopen\tOne\nopen\tOne\n")
.unwrap()
.records,
2
);
}
#[test]
fn rejects_bad_status() {
assert!(parse_tasks(b"status\ttitle\nOPEN\tOne\n").is_err());
}
#[test]
fn rejects_missing_header() {
assert!(parse_tasks(b"open\tOne\n").is_err());
}
#[test]
fn rejects_empty_input_and_header_only() {
for value in [b"".as_slice(), b"status\ttitle", b"status\ttitle\n"] {
assert!(parse_tasks(value).is_err());
}
}
#[test]
fn rejects_extra_columns() {
assert!(parse_tasks(b"status\ttitle\nopen\tOne\tTwo\n").is_err());
}
#[test]
fn rejects_empty_title() {
assert!(parse_tasks(b"status\ttitle\nopen\t\n").is_err());
}
#[test]
fn title_boundary_counts_unicode_scalars() {
assert!(
parse_tasks(format!("status\ttitle\nopen\t{}\n", "é".repeat(100)).as_bytes()).is_ok()
);
assert!(
parse_tasks(format!("status\ttitle\nopen\t{}\n", "é".repeat(101)).as_bytes()).is_err()
);
}
#[test]
fn rejects_control_characters() {
assert!(parse_tasks(b"status\ttitle\nopen\tOne\x00\n").is_err());
}
#[test]
fn rejects_invalid_utf8() {
assert!(parse_tasks(b"status\ttitle\nopen\t\xff\n").is_err());
}
#[test]
fn rejects_bom_and_crlf() {
assert!(parse_tasks("\u{feff}status\ttitle\nopen\tOne\n".as_bytes()).is_err());
assert!(parse_tasks(b"status\ttitle\r\nopen\tOne\r\n").is_err());
}
#[test]
fn rejects_blank_records() {
assert!(parse_tasks(b"status\ttitle\nopen\tOne\n\n").is_err());
}
#[test]
fn row_limit_boundary() {
assert!(
parse_tasks(format!("status\ttitle\n{}", "open\tx\n".repeat(1000)).as_bytes()).is_ok()
);
assert!(
parse_tasks(format!("status\ttitle\n{}", "open\tx\n".repeat(1001)).as_bytes()).is_err()
);
}
#[test]
fn byte_limit_boundary() {
let mut data = String::from("status\ttitle\n");
for _ in 0..618 {
data.push_str(&format!("open\t{}\n", "x".repeat(100)));
}
data.push_str(&format!("open\t{}", "x".repeat(10)));
assert_eq!(data.len(), 65536);
assert!(parse_tasks(data.as_bytes()).is_ok());
data.push('x');
assert!(parse_tasks(data.as_bytes()).is_err());
}
#[test]
fn formats_fixed_order() {
assert_eq!(
format_summary(&Summary {
records: 3,
open: 2,
closed: 1
}),
"records=3\nopen=2\nclosed=1\n"
);
}
}
What you should see · example output
records=3 open=2 closed=1
cargo test --offline
cargo run --quiet --offline --bin task-report -- tasks.tsvWhat you should see · example output
records=3 open=2 closed=1
rustc --edition=2024 rust-project -o lesson
./lesson tasks.tsv3. Explain the result
Download all files into one fresh folder: Cargo.toml points its binary target at task_report.rs, so this project deliberately does not require src/main.rs. The fixture tasks.tsv contains real tabs; it is not the two characters backslash and t. Cargo progress messages can appear on stderr; --quiet suppresses ordinary progress for the sample run. The parser rejects the 65,537th byte and the 1,001st record. The byte limit is not a timeout or a sandbox: opening a special or remote file can still block. This project is for ordinary local practice files.
YOUR TURN
A small check before you continue
Where would you change the row limit, and what must you update afterwards?
Show a suggested answer
Change ROW_LIMIT in task_report.rs, update the documented contract and boundary tests, then run cargo test again. Do not just change the displayed lesson text.
Sources and scope
Documentation and examples reviewed 2026-10-18. Provider screens, package versions and output wording can differ. Examples show expected outcomes; they are not a claim that OpenViST connected to your server or verified your setup.
