OpenViSTAI · Agents · Blockchain · Crypto
Menu

OPENVIST LEARN · FREE LESSONS

Build a bounded Rust task-report CLI

Combine files, validation, counters and error handling in a downloadable program.

Lesson 29 of 30 · About 40 minutes · Beginner · English

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.

Complete task_report.rsRUST · save as task_report.rs
// 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
Run the downloaded Cargo projectTERMINAL · Bash or Windows PowerShell
cargo test --offline
cargo run --quiet --offline --bin task-report -- tasks.tsv
What you should see · example output
records=3
open=2
closed=1
Compile and run on macOS/LinuxTERMINAL · Bash or Windows PowerShell
rustc --edition=2024 rust-project -o lesson
./lesson tasks.tsv

3. 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.