programming · Level 4

Rust: ownership, safety and a small command-line tool

Follow who owns the data, borrow what you need and test a bounded local command.

By Mickarle Wagstaff-Irons - Micky Irons

  • Level 4Frontier
  • 180 min
  • 6 chapters
  • Free PDF, no account
The Sequenceprogramming / 04

Start with the essentials

The short answer

Rust checks how values are owned and borrowed, helping prevent invalid memory access in safe code. It does not know whether a programme calculates the right answer. This course builds a small command that sums fictional input and output token counts, rejects invalid records and reports failures clearly. Compiler rejection examples and runtime fault tests show why both kinds of evidence matter.

What you will learn

  • Create and run a small Cargo project with a recorded toolchain.
  • Explain a move, a copy and a temporary borrow using executed examples.
  • Validate a bounded text format without returning partial success.
  • Use Result and explicit exit status for expected failures.
  • Separate compiler rejection checks from runtime tests.
  • Document resource limits and unverified behaviour before reusing a CLI.

Who it is for

Builders comfortable with a small Python programme who want a careful first systems-language project.

Before you start

  • Read functions, loops, integer arithmetic and simple assertions.
  • Create folders and files and run commands in a terminal.
  • Have a Rust toolchain and its platform linker installed; record rustc and cargo versions.

Read a sample · Chapter 01 of 06

01

Set up a command with one clear job

Keep the input format and expected output small enough to inspect by hand.

Our fictional token-meter reads two non-negative counts per record: input tokens, then output tokens. It adds the columns and prints a one-line summary. It does not tokenise text, call a model, calculate a bill or measure a provider. The numbers are invented teaching data. A real usage pipeline would need a source contract explaining which token categories are included and how repeated events are identified.

Use a new local folder. Create Cargo.toml, src/lib.rs, src/main.rs, tests/contract.rs and examples/borrow.rs at the exact paths shown in this course. Every file is printed in full. When a file spans several code blocks, join its blocks in order with one blank line between them. The package has a library for analysis, a command-line entry point and a separate borrowing example. The integration tests exercise the public library interface.

Cargo manages building and testing. Rustc is the compiler it invokes. Begin by recording rustc --version and cargo --version. This release used version 1.95.0 of both tools on Windows 11 x86_64; those are tested versions, not a claim about the newest release. Edition 2024 selects language conventions. An edition is not an exact compiler pin. The local toolchain still needs the platform linker, even when the project has no external crate dependencies.

toml · 6 lines
[package]
name = "token-meter"
version = "0.1.0"
edition = "2024"

[dependencies]

There is no dependencies section entry to fetch. After saving all five files, run cargo test --offline. Cargo creates Cargo.lock and build output under target. Keep the lockfile with this application and ignore target in version control. Later checks can use --locked as well as --offline to reject an unexpected lockfile change. Offline mode does not install a missing Rust toolchain, linker or component for you.

text · 4 lines
rustc --version
cargo --version
cargo test --offline
cargo build --offline --locked --release

The input is UTF-8 plain text, not CSV or JSON. Each nonblank line has exactly two fields separated by ASCII whitespace. Zero and leading zeroes are accepted; signs, decimals, exponent notation and thousands separators are not. Each field is at most 1,000,000. At most 1,000 nonblank records and 65,536 bytes are accepted. A UTF-8 byte-order mark is not removed. Empty input produces a zero summary.

Scroll sideways to see every column.

Set up a command with one clear job · Table 1
InputMeaning or outcome
12 3One record: 12 input and 3 output tokens.
0 0002Accepted: zero input and two output tokens.
1,2Rejected: this is not the two-field format.
1 2 3Rejected: an unexpected third field.

These rules deliberately exclude headers, comments and identifiers. Blank lines do not count as records, but do count towards physical line numbers in errors. Do not silently add another grammar because a spreadsheet export happens to look similar. Decide the new contract, update the parser and tests, then change the documentation together.

Try it yourself · Activity 01

15 min

Write the contract before reading the implementation

Predict the summary for two records: 12 3 and 8 2.

  1. Add the input column and output column separately.
  2. Write the record count and combined total.
  3. Decide whether an invalid third record should produce a partial result.

What new test would you need if someone asked to skip malformed rows?

Worked answer

There are two records, input=20, output=5 and total=25. The command should print calls=2 input=20 output=5 total=25. This contract rejects the whole input when a later record is invalid; it does not print the earlier subtotal.

Read a sample · Chapter 02 of 06

02

Follow moves, copies and borrowed text

Use the compiler to make the lifetime of a reference concrete.

A String owns a growable UTF-8 buffer. Assigning it to another binding ordinarily moves that ownership instead of copying all its text. The previous binding can no longer be used as that value. A shared reference borrows access while ownership remains elsewhere. These are different operations, even when their source syntax is short. The distinction becomes useful when data should be inspected without allocating another copy.

Save examples/borrow.rs. The first binding moves into owner. The byte_length function takes &str, so calling it with &owner borrows a string slice. Its returned usize is an integer length, not a reference into the string. Integers of this type implement Copy, which is why both before and copy remain usable after the assignment. All text here is ASCII: the lengths 4 and 8 are byte counts.

rust · 15 lines
fn byte_length(text: &str) -> usize {
    text.len()
}

fn main() {
    let first = String::from("12 3");
    let mut owner = first;
    let before = byte_length(&owner);
    let borrowed = &owner;
    println!("borrowed={borrowed}");
    owner.push_str("\n8 2");
    let after = byte_length(&owner);
    let copy = before;
    println!("before={before} copy={copy} after={after}");
}

text · 2 lines
borrowed=12 3
before=4 copy=4 after=8

Run cargo run --offline --quiet --example borrow. The reference named borrowed is last used by the first println. The later push_str can mutate owner because that shared borrow is no longer needed. A reference need not stay active until the closing brace if its last use occurred earlier. Moving the final use below the mutation changes that relationship; it is the actual use pattern that matters.

The following complete programme is deliberately invalid. Save it separately as move.rs, outside the normal src and examples folders, and run rustc --edition=2024 move.rs. It attempts to use first after moving its String into second. The tested compiler reports E0382. Do not include this expected failure in the working application build.

rust · 5 lines
fn main() {
    let first = String::from("12 3");
    let second = first;
    println!("{first} {second}");
}

The second expected failure holds a shared view until after a mutable operation. Save it separately as alias.rs and compile it in the same way. The tested compiler reports E0502. A useful correction is to finish using view before the push, as in the working example. Adding clone can sometimes create the independent ownership you need, but cloning everywhere hides design questions and adds allocation.

rust · 6 lines
fn main() {
    let mut text = String::from("12 3");
    let view = &text;
    text.push_str("\n8 2");
    println!("{view}");
}

The library we build next returns owned numeric results. It never returns a slice into a local input String. That keeps the result usable after input storage is dropped. Borrowed fields are useful while parsing, but they should not escape into an output whose lifetime would outlast the source text. You do not need explicit lifetime parameters for these particular signatures.

Try it yourself · Activity 02

20 min

Explain both compiler rejections

Compile the two isolated invalid programmes, then compare them with the successful borrowing example.

  1. Name which binding owns the String after the move.
  2. Find the last use of the shared view in each borrowing example.
  3. Repair the invalid code without using unsafe or cloning the whole input.

When would a separately owned clone be justified by the application rather than used just to silence a diagnostic?

Worked answer

In move.rs, second owns the String; print second without trying to print first. In alias.rs, use view before text.push_str or take a new view after mutation. The successful example does exactly that: it ends the shared reference use before mutation, while retaining copied numeric lengths.

Read a sample · Chapter 03 of 06

03

Build a bounded parser and owned result

Translate the input contract into small checks whose failures are explicit.

Follow ownership. Check the boundary.

String ownership moves from first to owner. A shared borrow is last used before mutation. An owned summary survives its input. Reading one extra byte detects oversized input.
The diagram uses a fresh offline build of the published Rust lab and a small additional observation fixture. The text below contains every illustrated result. Open the full-size Rust ownership diagram.

let mut owner = first moves the String. The original binding cannot then be read. A separate compiler probe attempting that read is rejected with E0382. The arrows describe ownership, not measured allocation addresses or a guarantee about physical copying.

Executed ownership sequence: all characters here are ASCII
ObservationRecorded value
After moveowner=12 3; bytes=4
Shared borrow last useborrowed=12 3
After mutationowner=12 3\n8 2; bytes=8
Copied lengthbefore=4; copy=4

The two-character notation \n represents a newline byte in the actual string. String::len counts UTF-8 bytes, not displayed characters. The shared borrow's last use is the print of borrowed; mutation follows it. A separate probe that mutates and then uses the shared reference is rejected with E0502. Both probes fail to produce an executable. before and copy are usize values: copying that number leaves both usable.

The fixture also creates 12 3\n8 2\n inside a scope, passes a shared reference to parse, then drops the input when the scope ends. Reading the returned Summary outside the scope produces calls=2 input=20 output=5 total=25. Its fields own numbers and retain no input reference. This illustrates the actual return type; borrowed return types need their own lifetime reasoning.

Separate bounded-reader checks using in-memory Cursor inputs
InputBytes read Result
65,536 spaces65,536Ok: 0 records
65,537 spaces65,537Error: input exceeds 65536 bytes
65,538 spaces65,537Error: input exceeds 65536 bytes

analyse reads through take(MAX_BYTES + 1), with MAX_BYTES = 65_536. The extra byte distinguishes oversized input from an accepted prefix. Size is checked before UTF-8 decoding, then parse checks the record grammar. These fixtures contain only spaces, so an accepted input has zero records. The third check leaves one byte unread in its Cursor.

This is a read-byte limit, not an exact memory bound or a deadline. An incomplete, stalled input can still block. Invalid input returns an error rather than a successful partial summary. A separate input 12 3\nbad\n returns line 2: expected two fields: input output. None of these checks measures throughput, allocation behaviour or cross-platform performance. These local examples were executed with Rust 1.95.0 on Windows.

Save the following blocks as src/lib.rs in order. The Summary struct owns three numbers. Derive supplies debugging, equality and zero-default behaviour used by tests. Error is an enum: each variant describes a different failure. The row variant retains a physical line number and a static explanatory string, rather than retaining the original user text. Display defines the message the command will show.

rust · 23 lines
use std::fmt;
use std::io::{self, Read};

pub const MAX_BYTES: usize = 65_536;
pub const MAX_ROWS: usize = 1_000;
pub const MAX_COUNT: u64 = 1_000_000;

#[derive(Debug, Default, PartialEq, Eq)]
pub struct Summary {
    pub calls: usize,
    pub input: u64,
    pub output: u64,
}

#[derive(Debug)]
pub enum Error {
    Io(io::Error),
    TooLarge,
    Utf8,
    TooManyRows,
    Row { line: usize, reason: &'static str },
    Usage,
}

rust · 14 lines
impl fmt::Display for Error {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Io(_) => write!(f, "input/output failure"),
            Self::TooLarge => write!(f, "input exceeds {MAX_BYTES} bytes"),
            Self::Utf8 => write!(f, "input is not UTF-8"),
            Self::TooManyRows => write!(f, "more than {MAX_ROWS} records"),
            Self::Row { line, reason } => write!(f, "line {line}: {reason}"),
            Self::Usage => write!(f, "takes no arguments; supply counts on stdin"),
        }
    }
}

impl std::error::Error for Error {}

rust · 14 lines
fn count(field: &str, line: usize) -> Result<u64, Error> {
    let bad = || Error::Row {
        line,
        reason: "counts must be ASCII integers from 0 to 1000000",
    };
    if field.is_empty() || !field.bytes().all(|b| b.is_ascii_digit()) {
        return Err(bad());
    }
    let value = field.parse::<u64>().map_err(|_| bad())?;
    if value > MAX_COUNT {
        return Err(bad());
    }
    Ok(value)
}

rust · 27 lines
pub fn parse(text: &str) -> Result<Summary, Error> {
    if text.len() > MAX_BYTES {
        return Err(Error::TooLarge);
    }
    let mut summary = Summary::default();
    for (index, line) in text.lines().enumerate() {
        let fields: Vec<&str> = line.split_ascii_whitespace().collect();
        if fields.is_empty() {
            continue;
        }
        if summary.calls == MAX_ROWS {
            return Err(Error::TooManyRows);
        }
        if fields.len() != 2 {
            return Err(Error::Row {
                line: index + 1,
                reason: "expected two fields: input output",
            });
        }
        let input = count(fields[0], index + 1)?;
        let output = count(fields[1], index + 1)?;
        summary.calls += 1;
        summary.input += input;
        summary.output += output;
    }
    Ok(summary)
}

rust · 12 lines
pub fn analyse<R: Read>(reader: R) -> Result<Summary, Error> {
    let mut bytes = Vec::new();
    reader
        .take((MAX_BYTES + 1) as u64)
        .read_to_end(&mut bytes)
        .map_err(Error::Io)?;
    if bytes.len() > MAX_BYTES {
        return Err(Error::TooLarge);
    }
    let text = String::from_utf8(bytes).map_err(|_| Error::Utf8)?;
    parse(&text)
}

Read count first. It borrows one field as &str. The byte predicate enforces ASCII digits before parsing into u64. This deliberately rejects a leading plus even if a general integer parser would accept one. Parse failures, including values too large for u64, become a row error. Values that fit u64 but exceed the application ceiling are also rejected. The closure bad creates the same error shape without storing or echoing the input field.

The question mark operator returns an error early from the surrounding function, or unwraps the successful value for the next statement. It is not a catch-all exception handler. Map_err converts an error from one layer into the error type this API returns. A valid input count followed by an invalid output count stops before the current row is added. An error after earlier rows also returns only Err: no Summary is returned to the caller.

Inside parse, text is borrowed and fields is a Vec of borrowed string slices. Splitting does not copy every field into a fresh String, although collecting the references allocates a small vector. A row is checked for exactly two fields before indexing positions zero and one. The enumerate index counts every physical line. The separate calls counter advances only for valid nonblank records. That is why blank lines do not confuse error locations or the record limit.

Integer arithmetic is safe here for a specific numerical reason: at most 1,000 records times at most 1,000,000 per column gives 1,000,000,000 per column, and a combined maximum of 2,000,000,000. Those totals fit u64. Rust ownership alone does not prove this business rule. If you later loosen or remove the limits, redo the arithmetic and consider checked_add. Do not assume changing a constant cannot affect correctness.

Analyse owns its generic Read value. It takes at most MAX_BYTES + 1 bytes into a vector. That extra byte distinguishes a full valid-size input from an oversized input. Without it, stopping at the limit could accept a truncated prefix. String::from_utf8 consumes the vector and establishes valid UTF-8 before parse borrows the String. The returned Summary contains no references to it, so dropping the local String is safe.

The byte cap bounds the number of input bytes retained and examined by this adapter. It is not an exact limit on process memory: vector capacity, allocator overhead, parsed references and the runtime also use memory. It is not a timeout either. A producer that sends some bytes and leaves standard input open can still make a read wait. The plain parse API repeats the byte check because callers may bypass analyse and supply a String directly.

Try it yourself · Activity 03

20 min

Prove the boundaries instead of guessing

Trace the byte, row and arithmetic limits through the library.

  1. Explain why the reader takes 65,537 bytes when the accepted maximum is 65,536.
  2. Calculate the largest possible combined total.
  3. Trace what is returned when line three is invalid after two accepted records.

Which assumptions must be revisited if the tool becomes a long-running network service?

Worked answer

The extra byte proves oversize input without reading the whole stream. The maximum combined total is 1,000 x (1,000,000 + 1,000,000) = 2,000,000,000. An invalid third line returns an Error; the local running total is dropped and never exposed as success. A byte cap still leaves stalled-input timing and allocator failure outside this demonstration.

Read a sample · Chapter 04 of 06

04

Give the command a predictable exit contract

Keep computation separate from the terminal boundary.

Save src/main.rs. A Cargo package name containing a hyphen is referenced as token_meter in Rust paths. The command accepts no positional arguments or flags. It reads standard input through a locked handle, asks the library for a Summary and only then writes one output line. Its main function returns ExitCode, making the process outcome explicit.

rust · 20 lines
use std::io::{self, Write};
use std::process::ExitCode;
use token_meter::{Error, analyse};

fn run() -> Result<(), Error> {
    if std::env::args_os().len() != 1 {
        return Err(Error::Usage);
    }
    let report = analyse(io::stdin().lock())?;
    let mut out = io::stdout().lock();
    writeln!(
        out,
        "calls={} input={} output={} total={}",
        report.calls,
        report.input,
        report.output,
        report.input + report.output
    )
    .map_err(Error::Io)
}

rust · 9 lines
fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(error) => {
            let _ = writeln!(io::stderr().lock(), "error: {error}");
            ExitCode::from(2)
        }
    }
}

Successful input returns exit zero. Argument errors, input failures, invalid data and output failures return exit two in this lab. The chosen number is an application convention; its meaning is documented here rather than assumed to be universal. Errors go to standard error. Valid summaries go to standard output, which lets another command consume them without parsing human-facing diagnostics.

Writeln can itself fail, for example when an output destination closes. Run maps that I/O failure into Error rather than unwrapping it. Main attempts to write a short error message but ignores a second failure on standard error, then returns the failure status. The retained io::Error value is available to a future caller; this Display deliberately uses a generic message instead of exposing operating-system details. It does not currently chain that underlying error through Error::source.

The command prints nothing until the entire input validates. That prevents a malformed later line from leaving an apparently successful subtotal. It does not make stdout an atomic transactional channel: an actual write failure could leave partial output. A consumer should still inspect the exit status. Handling a broken pipe was considered in the code, but a real process-level broken-pipe experiment was not part of this release.

For a quick Windows PowerShell example, pipe the two strings shown below. For a POSIX shell, the printf form supplies the same ASCII records. Only the Windows execution environment was tested here. A release build creates target/release/token-meter.exe on this Windows toolchain; Unix-like targets use the filename without .exe. Cargo run is convenient while learning because it locates the executable for the current target.

powershell · 1 line
'12 3', '8 2' | cargo run --offline --quiet

sh · 1 line
printf '12 3\n8 2\n' | cargo run --offline --quiet

text · 1 line
calls=2 input=20 output=5 total=25

When running without a pipe, the command waits for standard input to end. Avoid that ambiguity while learning: use a finite producer or input redirection and check the producer too. This example performs no asynchronous work and has no cancellation protocol. A command invoked by another application should be supervised with a timeout appropriate to that application.

Try it yourself · Activity 04

15 min

Check stdout, stderr and exit status separately

Run the valid example, then replace the second record with 8 nope.

  1. Record the successful output and status.
  2. Record the invalid run output streams and status.
  3. Decide whether a downstream step should accept a subtotal.

If a process writes a plausible partial line and then exits nonzero, which signal should control the next step?

Worked answer

The valid input gives calls=2 input=20 output=5 total=25 and exit zero. The invalid run gives no stdout, reports error: line 2: counts must be ASCII integers from 0 to 1000000 on stderr, and exits two. A downstream step should treat that entire input as failed. In PowerShell, inspect $LASTEXITCODE for the native command.

Read a sample · Chapter 05 of 06

05

Test the logic that the compiler cannot know

Use examples that distinguish the intended behaviour from plausible wrong implementations.

Save tests/contract.rs. The tests import the public library exactly as a separate consumer would. Unwrap is appropriate for these assertions because an unexpected error should fail the test. It is not an instruction to replace the command boundary with unwrap. Each group names a property of the contract rather than a particular private function implementation.

rust · 22 lines
use std::io::{self, Read};
use token_meter::{Error, MAX_BYTES, MAX_ROWS, Summary, analyse, parse};

#[test]
fn sums_fictional_counts() {
    assert_eq!(
        parse("12 3\n8 2\n").unwrap(),
        Summary {
            calls: 2,
            input: 20,
            output: 5
        }
    );
}

#[test]
fn empty_and_ascii_whitespace_are_ignored() {
    for text in ["", " \t\r\n", "\n\n"] {
        assert_eq!(parse(text).unwrap(), Summary::default());
    }
    assert_eq!(parse("\t12 3\r\n\n8\t2  \r\n").unwrap().calls, 2);
}

rust · 25 lines
#[test]
fn count_boundaries_and_leading_zeroes() {
    let result = parse("0 0002\n1000000 1000000").unwrap();
    assert_eq!(result.input, 1_000_000);
    assert_eq!(result.output, 1_000_002);
}

#[test]
fn rejects_invalid_numbers_in_either_column() {
    for bad in [
        "-1",
        "+1",
        "1.5",
        "1_000",
        "1e3",
        "1000001",
        "18446744073709551616",
        "one",
        "\u{ff11}",
    ] {
        for text in [format!("{bad} 1"), format!("1 {bad}")] {
            assert!(matches!(parse(&text), Err(Error::Row { line: 1, .. })));
        }
    }
}

rust · 16 lines
#[test]
fn rejects_wrong_field_count_and_unicode_separator() {
    for text in ["1", "1 2 3", "1,2", "1\u{00a0}2"] {
        assert!(matches!(parse(text), Err(Error::Row { .. })));
    }
}

#[test]
fn physical_line_number_survives_blank_lines() {
    let error = parse("12 3\n\n8 nope").unwrap_err();
    assert!(matches!(error, Error::Row { line: 3, .. }));
    assert_eq!(
        error.to_string(),
        "line 3: counts must be ASCII integers from 0 to 1000000"
    );
}

rust · 22 lines
#[test]
fn exact_record_limit_and_totals() {
    let text = "1000000 1000000\n".repeat(MAX_ROWS);
    assert_eq!(
        parse(&text).unwrap(),
        Summary {
            calls: MAX_ROWS,
            input: 1_000_000_000,
            output: 1_000_000_000
        }
    );
    assert!(matches!(parse(&(text + "0 0\n")), Err(Error::TooManyRows)));
}

#[test]
fn exact_byte_limit_and_one_extra() {
    let exact = " ".repeat(MAX_BYTES);
    assert_eq!(analyse(exact.as_bytes()).unwrap(), Summary::default());
    let extra = exact + " ";
    assert!(matches!(analyse(extra.as_bytes()), Err(Error::TooLarge)));
    assert!(matches!(parse(&extra), Err(Error::TooLarge)));
}

rust · 23 lines
#[test]
fn rejects_invalid_utf8_and_bom() {
    assert!(matches!(analyse(&[0xff][..]), Err(Error::Utf8)));
    assert!(matches!(parse("\u{feff}1 2"), Err(Error::Row { .. })));
}

#[test]
fn input_reader_is_bounded() {
    let mut cursor = io::Cursor::new(vec![b' '; MAX_BYTES + 100]);
    assert!(matches!(analyse(&mut cursor), Err(Error::TooLarge)));
    assert_eq!(cursor.position(), (MAX_BYTES + 1) as u64);
}

#[test]
fn read_errors_propagate() {
    struct Failing;
    impl Read for Failing {
        fn read(&mut self, _: &mut [u8]) -> io::Result<usize> {
            Err(io::Error::other("fictional failure"))
        }
    }
    assert!(matches!(analyse(Failing), Err(Error::Io(_))));
}

rust · 8 lines
#[test]
fn result_does_not_borrow_input_storage() {
    let report = {
        let owned = String::from("12 3");
        parse(&owned).unwrap()
    };
    assert_eq!(report.input + report.output, 15);
}

The tests cover valid sums, blank lines, CRLF, tabs, accepted endpoints and leading zeroes. They reject signed, fractional, excessive, non-ASCII and malformed values in either column. A separate test ensures errors retain physical line numbers after blanks. Boundary tests distinguish exactly 1,000 records from 1,001 and exactly 65,536 bytes from one more. The cursor observation checks how much input the adapter consumed, not just its final return value.

A custom Failing reader proves that an I/O error propagates through the adapter. It does not simulate every device failure. Another test obtains a Summary inside a nested scope and uses it after the owned input String has been dropped. That illustrates that this output does not borrow the input buffer. None of these tests measure inference throughput, memory usage or end-to-end service reliability.

Run cargo test --offline --locked, cargo fmt -- --check and cargo clippy --offline --locked --all-targets -- -D warnings. Rustfmt checks the printed layout convention and Clippy checks a collection of common code issues; both passed with the recorded toolchain. Clippy and rustfmt are toolchain components, so an offline command cannot install a missing component. Keep their versions visible when comparing results.

The release also exercised the compiled release executable through ten process cases, checking exact stdout, stderr and exit codes. Those include valid and empty input, malformed second row, invalid UTF-8, exact/over byte and row limits, unexpected arguments and CRLF/tab input. Process evidence complements the library tests because a correct parser can still be connected to the wrong output stream or status.

To test the tests, three isolated copies received deliberate defects: add input into the output column; allow one extra record; and read only MAX_BYTES so oversized input becomes an accepted truncated prefix. Each faulty version passed cargo check before tests failed. These are concrete counterexamples to the idea that successful compilation establishes application correctness. The published learner files retain the correct versions.

Try it yourself · Activity 05

20 min

Make one wrong version fail for a useful reason

Work on a separate copy and replace summary.output += output with summary.output += input.

  1. Check that the altered programme still compiles.
  2. Run the test suite and identify the wrong result.
  3. Restore the correct source and rerun the suite.

Which additional wrong programme could pass the current tests, and what example would distinguish it?

Worked answer

The ordinary example now gives output=20 rather than 5, so the sums test fails even though the types and ownership rules are satisfied. The correct two-column assertion distinguishes this defect from the intended contract. The release also caught one-extra-row acceptance and silent truncation. Do not keep a deliberately broken copy in the final application.

Read a sample · Chapter 06 of 06

06

Review the limits and prepare a useful handover

Carry forward evidence rather than a general claim that the language makes everything safe.

The finished project is a small local data tool, not a production token accounting service. It trusts the meaning of the supplied numbers. Duplicate records are counted again because there is no event identifier. The name calls means accepted input records, not verified requests to a model. It knows nothing about retries, caching, reasoning-token categories, provider bills or timezone boundaries. Treating those omissions as a product feature would require another contract.

Safe Rust restricts certain invalid memory operations through its type and borrowing rules, assuming the trusted compiler and libraries behave correctly. It does not automatically prevent logic errors, resource exhaustion, deadlocks, leaks or incorrect authorisation. This lab contains no unsafe blocks. That is useful scope information, not evidence that all possible failures have been eliminated. The deliberate mutants already demonstrate several valid Rust programmes with wrong behaviour.

Keep the package manifest and Cargo.lock. Record the actual rustc and cargo versions, platform, command lines and all five source files. Include one success example and one failure example, plus the byte/record/numeric limits. A handover should say that twelve Rust tests and ten process cases passed, that two expected compiler failures were observed, and that three compiled logic faults were caught. It should also state what those tests did not exercise.

The tested target was Windows 11 x86_64. The POSIX shell example is a conventional way to supply equivalent bytes, but was not executed on Linux or macOS here. A cross-platform release needs its own build and process checks. There was no real broken-pipe experiment, exhausted-memory test, fuzz campaign or benchmark. No model or external service was contacted. Those limitations belong beside the results so the next person can prioritise work.

If an AI coding assistant suggests a rewrite, ask it to preserve the input and exit contracts before optimising. Review changes to input bounds, integer conversion, blank-line handling and error propagation especially carefully. Read the diff, run tests and introduce a small distinguishing example for any claimed bug fix. Avoid accepting a clone-heavy rewrite merely because it makes the borrow checker quiet; explain why each value must be owned or borrowed.

For a next project, keep the separation between pure parsing and I/O. Add a file-reading adapter only after deciding path rules and error reporting. Add structured output only after specifying its stable schema. If the tool processes private records, decide storage, retention and logging policy separately; removing source text from these error messages is not a complete privacy design. Start with the smallest new boundary that has a testable contract.

Try it yourself · Activity 06

15 min

Write a bounded release note

Prepare a short handover for someone who has not followed this lesson.

  1. State the exact toolchain and file set.
  2. Include the successful two-record example and a failed record example.
  3. List passed checks, resource limits and unverified environments.

What evidence would justify changing this local exercise into a component of your application?

Worked answer

A useful note says: standard-library token-meter, Rust/Cargo 1.95.0, edition 2024, Windows x86_64. Input is bounded UTF-8 with two ASCII integer fields, maximum 1,000 records and 65,536 bytes. The sample produces 20 input, 5 output and 25 total; malformed input gives no summary and exit two. Twelve tests, ten process cases, formatting, Clippy, two rejected compiler probes and three caught logic faults pass. Stalled stdin, broken-pipe behaviour, allocator failure and other operating systems still need acceptance appropriate to their intended use.

Keep learning

The complete workbook

Create a five-file Rust project with no third-party dependencies. Trace moves, copies and borrowed strings, use Result to report expected failures, bound standard input and run twelve tests plus a reproducible command-line example. Six worked activities finish with a handover that distinguishes tested behaviour from operational limits.

  1. 01
    Set up a command with one clear job

    Keep the input format and expected output small enough to inspect by hand.

    Read here · 1 exercise
  2. 02
    Follow moves, copies and borrowed text

    Use the compiler to make the lifetime of a reference concrete.

    Read here · 1 exercise
  3. 03
    Build a bounded parser and owned result

    Translate the input contract into small checks whose failures are explicit.

    Read here · 1 exercise
  4. 04
    Give the command a predictable exit contract

    Keep computation separate from the terminal boundary.

    Read here · 1 exercise
  5. 05
    Test the logic that the compiler cannot know

    Use examples that distinguish the intended behaviour from plausible wrong implementations.

    Read here · 1 exercise
  6. 06
    Review the limits and prepare a useful handover

    Carry forward evidence rather than a general claim that the language makes everything safe.

    Read here · 1 exercise

Also inside: a 8-point checklist, a glossary of 10 terms and 10 questions and answers to test yourself. 6 hands-on exercises, each with a worked answer at the back where the workbook gives one.

No login, no card, no account. Before the download we ask you to follow Mickai (two quick links). Free to download and use for personal learning, study groups and inside your own team. Please do not resell the workbooks or republish them as your own. Link people to trust-agent.ai instead.

Test yourself

Questions and answers

Does moving a String copy all its text?

Ordinary assignment transfers ownership rather than cloning the buffer. The previous binding cannot then be used as that String.

Why can before still be used after assigning copy?

The usize length implements Copy. Assignment duplicates that numeric value rather than invalidating before.

Why can the working example mutate owner after printing borrowed?

The shared reference is no longer used. The compiler can end that borrow before the later mutable operation.

Why read one byte more than the accepted limit?

The extra byte detects oversized input. Reading only the accepted length could mistake a truncated prefix for complete valid input.

Does the byte cap prevent a stalled command?

No. A producer may leave standard input open. The lab has no read deadline or cancellation mechanism.

Why do the totals fit u64?

At most 1,000 records with two counts of at most 1,000,000 gives a combined maximum of 2,000,000,000. Changes to those limits require a new arithmetic review.

Does an invalid later row return the earlier subtotal?

No. The library returns Err and the command prints no successful summary. The accumulated local value is not returned.

What does exit two mean here?

It is this command's documented failure convention for invalid arguments, bad input and I/O failure, not a universal meaning for every programme.

Do successful compilation and Clippy prove correct totals?

No. Three deliberate logic defects compiled but failed the tests. Compiler and lint evidence must be paired with behavioural checks.

Does this tool measure actual model usage?

No. It sums supplied fictional records. It does not tokenise, contact a provider, identify duplicate events or verify billing categories.

When you have finished

Get your certificate of completion

Type your name and download a certificate for this workbook as a PDF, ready to print or to add to LinkedIn. It is made on your own device, so your name is never sent to us. It is a self-declared certificate, not an accredited qualification.

Learn the language

Key terms

Ownership
The relationship determining which value is responsible for a resource and when that resource is dropped.
Move
Transfer of a value that makes the previous binding unavailable for that value unless it is reinitialised.
Copy
A trait for values that can be duplicated implicitly rather than moved by an assignment.
Borrow
Temporary access through a reference without taking ownership of the referenced value.
String slice
A borrowed UTF-8 text view, written as str behind a reference such as &str.
Result
An enum representing either a successful value in Ok or an error in Err.

6 of the workbook's 10 terms. The complete glossary is in the workbook.

Follow the evidence

Sources and checks

Facts last checked: .

Examples in this workbook were run on: Rust and Cargo 1.95.0 on Windows 11 x86_64, edition 2024. Standard library only. Twelve Rust tests, ten compiled-process cases, two compiler rejection probes, three caught logic mutations, rustfmt, clippy and a release build pass. No model, network service, deployment or cross-platform execution is claimed. (2026-09-27).

These workbooks use AI assistance. See how the workbooks are made.

  1. OwnershipThe Rust Project
  2. References and borrowingThe Rust Project
  3. Recoverable errors with ResultThe Rust Project
  4. Bounded reads with Read::takeThe Rust Project
  5. Creating a Cargo packageThe Rust Project
  6. Writing Rust testsThe Rust Project
  7. String slice operationsThe Rust Project
  8. UTF-8 ownership conversionThe Rust Project
  9. Process exit statusThe Rust Project
  10. Compiler error E0382The Rust Project
  11. Compiler error E0502The Rust Project
  12. Scope of safe and unsafe RustThe Rust Project

Created by Mickarle Wagstaff-Irons - Micky Irons with the Mickai team. Published by Mickai LTD. Last updated 27 September 2026.

NextKeep going

Where to go next