Crabrix blog

Rust text input: buffered reading without lost errors

Read text reliably in Rust with BufRead, Cursor and reusable buffers. Preserve I/O errors, define line rules, and validate records before changing state.

Reading a file is several separate operations

A file can open successfully and still contain invalid UTF-8. Its text can decode correctly and still contain a malformed record. A record can have the expected fields and still violate a domain limit. Combining all these situations into “could not read file” makes an importer difficult to repair and test. Separate access to bytes, text decoding, line handling and domain validation so each failure has a useful meaning.

We will begin with an in-memory reader, which makes the behavior repeatable without depending on a filesystem path. The same parsing function can later accept a buffered file on a desktop. In Crabrix, the in-memory examples run without any extra dependency. Filesystem access in the app belongs to its project sandbox; this guide does not promise access to arbitrary device files or a desktop home directory.

A paper ribbon passes through a buffered inspection station while malformed segments are retained in a separate tray.
Keep access, decoding and validation as distinct stages, and retain enough information to explain a rejected record.

Choose between a whole string and a stream

Reading a small configuration into a complete string can be a sensible choice. It simplifies borrowed parsing and lets several passes inspect the same input. A line-oriented stream is useful when processing should begin incrementally or when retaining the entire file is unnecessary. Neither approach is always superior. The right choice depends on input limits, validation requirements and whether later operations need earlier text to remain available.

Buffering reduces small underlying reads, but it does not by itself impose a hard bound on line length. A single extremely long line can still require a large string. If input is untrusted or a product needs strict memory limits, define a maximum record size and enforce it deliberately. Avoid teaching “line by line uses constant memory” without stating the assumptions about line size and any accumulated output. A streaming parser can still collect enough results to consume substantial memory.

Use Cursor to make the first example deterministic

Cursor adapts an in-memory byte sequence to the standard I/O interfaces. That lets a parser accept a reader while tests provide exact bytes. The example below reads two integers using the lines iterator. Each item is a Result, because reading and decoding a line can fail. The parser preserves that failure, adds a line number to numeric validation, and returns a vector only when every line is accepted.

This importer intentionally trims surrounding whitespace and rejects an empty line as an invalid integer. Those are format choices. If blank lines should be ignored, add a rule and a fixture for it. If the final vector is unnecessary, the same approach can process accepted values incrementally, but consider whether a later failure should undo earlier effects. The small example keeps validation free of external writes so a rejected input cannot leave half an import stored.

use std::io::{BufRead, Cursor};
fn readings<R: BufRead>(reader: R) -> Result<Vec<u32>, String> {
    reader.lines().enumerate().map(|(index, line)| {
        let line = line.map_err(|error| format!("line {}: {error}", index + 1))?;
        line.trim().parse::<u32>()
            .map_err(|error| format!("line {}: {error}", index + 1))
    }).collect()
}
fn main() {
    assert_eq!(readings(Cursor::new(b"12
7
")), Ok(vec![12, 7]));
    let error = readings(Cursor::new(b"12
bad
")).unwrap_err();
    assert!(error.starts_with("line 2:"));
    println!("Reader checks passed");
}

Distinguish the failure layers in the report

The conceptual pipeline in the infographic starts with bytes, decodes text, identifies lines and validates domain values. Its sample labels illustrate the stages; they are not an assertion that the displayed greeting parses into the displayed table. In Rust’s buffered text APIs, invalid UTF-8 is reported through an I/O result. A valid line whose field cannot be parsed is a later validation failure. A production error type can retain these categories as separate variants.

This separation helps both people and interfaces. An access failure may need a different file selection; an encoding failure may need a supported export format; a malformed field may need editing on a specific line. Avoid responding to all three by silently skipping the record. Preserve the underlying cause where possible, attach bounded context at the stage that understands it, and keep sensitive whole-file content out of routine logs.

Bytes pass through UTF-8 decoding, line processing and domain validation, with a read_line loop that clears its buffer and stops at zero bytes.
These are conceptual stages. Text-reading errors and field-validation errors need separate handling.

Sources: Standard library: BufRead · Standard library: Cursor

A reusable read_line buffer must be cleared

read_line appends to the string supplied by the caller. Reusing one string without clearing it makes the next operation see accumulated text from earlier lines. That can look like a parser defect even though the parser is faithfully processing the contents it received. Clear the buffer before each new read when the intended unit is one line, then inspect the returned byte count to detect end of input.

A return value of zero indicates that no further bytes were read at the end of the stream. It is not the same as a blank line: a blank line contains a line terminator and therefore has a nonzero byte count when read. The complete program below demonstrates the reuse pattern without a filesystem dependency. It also retains the terminators in the stored strings, making the difference from the lines iterator explicit and testable.

use std::io::{self, BufRead, Cursor};
fn main() -> io::Result<()> {
    let mut reader = Cursor::new(b"first

third
");
    let mut buffer = String::new();
    let mut records = Vec::new();
    loop {
        buffer.clear();
        let bytes_read = reader.read_line(&mut buffer)?;
        if bytes_read == 0 { break; }
        records.push(buffer.clone());
    }
    assert_eq!(records, vec!["first
", "
", "third
"]);
    println!("Read {} lines, including a blank line", records.len());
    Ok(())
}

Do not erase I/O errors with a convenient adapter

An iterator of line results is not just an iterator of strings. Using a filtering operation that keeps only successful items can make read or decoding failures disappear. That may produce a plausible partial report while hiding the exact record that needed attention. A strict importer should propagate failures. An intentionally tolerant importer should retain a count and explanation of rejected records according to a documented policy.

The same issue appears at later stages. Converting parse errors to None and filtering them out changes “every record must be valid” into “any record we could parse is good enough.” Both can be legitimate products, but they are different promises. Write down the promise before choosing adapters. A shorter expression does not compensate for losing the information users need to understand why their import contains fewer records than the source.

Line endings belong to the input format

Different line APIs preserve different details. BufRead::lines yields strings without their newline terminators and handles a carriage return before the newline as part of the line ending. read_line retains the bytes it appends, including a terminator when present. Splitting a whole string literally on a newline can leave carriage returns or trailing empty pieces. Choose the operation that matches the format rather than treating them as identical spellings.

Trimming all whitespace is another transformation. It is convenient for our numeric example, but it may remove meaningful leading or trailing spaces from a text field. If a format preserves spaces, remove only the terminator according to that format’s rules. Test Unix-style line endings, Windows-style endings, an empty line and a final record without a terminator. These small fixtures catch real interoperability defects that a large ordinary sample can miss.

Attach record context before ownership moves on

A line number is easiest to obtain while iterating through lines. A field name is easiest to obtain while validating that field. A file label belongs to the caller that opened or selected the input. Preserve context at those boundaries instead of trying to reconstruct it later from a generic error string. A structured error can carry a category, location and underlying cause while allowing an interface to choose suitable wording.

Avoid attaching an entire sensitive record by default. A line number and field name may be sufficient, and a bounded redacted preview can be a separate diagnostic policy. Also distinguish byte positions from visible columns if errors identify substrings. UTF-8 offsets cannot be presented as universal character indexes without a conversion rule. Good context identifies the repair location accurately while disclosing only the information needed for that repair.

Validate before committing an import

Incremental reading and incremental permanent writes are independent decisions. A parser can read line by line while collecting a validated batch, then store it only after all records pass. Alternatively, a large import may use a transaction or a documented partial-success policy. The choice depends on the storage system and the product’s recovery requirements, not simply on whether the input arrived through a buffered reader.

For a small project, keeping parsing pure and separating the final update often gives the clearest failure behavior. If line seven is malformed, the old project state remains available and the user can correct the file. If partial success is intentional, report which records were accepted and make retry behavior explicit. Otherwise users may retry the whole file and create duplicates because the interface concealed that earlier records were already stored.

Keep the core independent of one file path

A function accepting a reader can be reused with an in-memory cursor, a buffered file or another suitable source. The caller decides how access is obtained, while the core decides what the bytes and records mean. This separation is particularly useful across a desktop filesystem and an app-managed project sandbox, where opening a source can have different platform rules.

Do not interpret the generic reader as a promise that every source has identical latency or failure behavior. The core still needs to propagate read errors and respect its input limits. Keep platform access checks near file selection and parsing checks near the reader function. Then a failing fixture identifies the rule that broke rather than requiring an interactive file picker for every test.

Bound records before trusting an input stream

A reusable buffer retains capacity after it is cleared. That can be beneficial for ordinary repeated lines, but a single huge record can leave a large allocation retained for the rest of the import. If strict limits matter, use a deliberate bounded-reading strategy and decide what to do with the remainder of a rejected record. Checking length only after reading the whole line does not prevent its initial allocation.

A maximum file size and a maximum line size are different limits. An input may contain many short records or one very long record. Specify both when the product requires them, and test the rejection path without storing partial results accidentally. Buffering is an I/O technique; resource limits are part of the parser’s contract and need their own enforcement.

Test readers independently from file selection

An in-memory fixture can test valid text, malformed fields, blank lines, missing final terminators and invalid UTF-8 bytes without depending on the operating system’s file picker. Separate integration checks can verify that a selected project file is opened from the correct sandbox location and that access failures are reported. This division makes parser failures repeatable and keeps platform-specific access behavior visible at its own boundary.

Before shipping, explain four policies in plain language: which encoding is accepted, how lines and whitespace are treated, what validation rejects, and what remains unchanged after failure. Then keep fixtures for each boundary. A reliable text importer is not defined by one clever loop. It is defined by preserving data and errors through a sequence of understandable decisions, from the first byte to the final accepted domain value.

Explore the free Crabrix lessons →