Rust Tutorial for Beginners: Ownership, Types and a Text Counter

Build a small Rust text analyser and trace what the compiler sees. The example connects basic types and control flow to moves, borrows and owned results.

KnowledgeGate Team

Exam prep & CS education

Updated 17 Sep 20265 min read

Rust syntax is not a beginner's main obstacle. The surprise is that every value has one owner, and the compiler checks each move and borrow. One program will count words and characters, filter by length, and expose these rules without explicit lifetime syntax. If you are trying to choose a programming path, use the wider catalogue for context, but it has no Rust-specific course.

Rust basics: bindings, concrete types, and expressions

let creates an immutable binding by default. Add mut only when the value must change:

rust
let threshold: usize = 5;
let mut attempts: u8 = 3;
attempts -= 1;

The resulting attempts is 2, while threshold remains 5. Rust often infers types, while annotations document constraints. u8 and bool are scalars. String and Vec<T> are owned collections.

Blocks and match expressions can return values:

rust
let status = match attempts {
    0 => "stop",
    1 | 2 => "retry",
    _ => "ready",
};

Here, status == "retry". The _ fallback covers every remaining u8 value. For wider context, compare Rust's model in the Programming Languages Survey.

Model the analyser with a struct, enums, and Result

The struct Report { words: usize, chars: usize, matches: Vec<String> } groups named result fields. Modes are Mode::Summary and Mode::LongWords(usize). Expected failures are AnalyseError::EmptyInput and AnalyseError::ZeroLimit. LongWords(5) carries the threshold 5.

The exact function contract is:

rust
fn analyse(text: &str, mode: Mode) -> Result<Report, AnalyseError>

Ok(Report { ... }) is success, while Err(...) is an expected failure for the caller to handle. This Result is not an exception, and this enum does not represent every Rust error. Pattern matching makes Mode::Summary create an empty vector, Mode::LongWords(min) if min > 0 retain words of at least min characters, and Mode::LongWords(_) return Err(AnalyseError::ZeroLimit).

Ownership before lifetimes: move, borrow, and string slices

Start with an owned value:

rust
let text = String::from("rust makes ownership explicit");
let moved = text;

Ownership moves to moved, so using text afterwards is rejected. Borrow instead when the caller must keep its value:

rust
let text = String::from("rust makes ownership explicit");
let borrowed: &str = &text;

Both borrowed and text remain readable during the shared borrow. In the program, main owns the String, analyse(&text, ...) borrows a &str, trim() returns a borrowed &str, and Vec<&str> holds borrowed slices. The returned Vec<String> owns its three matches, so Report does not borrow from the local words vector. The compiler infers these relationships, requiring no explicit lifetime annotation.

For a comparison with lower-level programming foundations, the C Language course is an optional next step. C and Rust do not enforce the same memory model.

Ownership map showing main's String borrowed into analyse as slices, with three owned String matches returned in Report.

Build the first Rust program end to end

Place this program in src/main.rs of a local Rust project, run it, and compare:

rust
#[derive(Debug)]
struct Report {
    words: usize,
    chars: usize,
    matches: Vec<String>,
}

enum Mode {
    Summary,
    LongWords(usize),
}

#[derive(Debug)]
enum AnalyseError {
    EmptyInput,
    ZeroLimit,
}

fn analyse(text: &str, mode: Mode) -> Result<Report, AnalyseError> {
    let trimmed = text.trim();
    if trimmed.is_empty() {
        return Err(AnalyseError::EmptyInput);
    }

    let words: Vec<&str> = trimmed.split_whitespace().collect();
    let matches = match mode {
        Mode::Summary => Vec::new(),
        Mode::LongWords(min) if min > 0 => words
            .iter()
            .filter(|word| word.chars().count() >= min)
            .map(|word| (*word).to_string())
            .collect(),
        Mode::LongWords(_) => return Err(AnalyseError::ZeroLimit),
    };

    Ok(Report {
        words: words.len(),
        chars: trimmed.chars().count(),
        matches,
    })
}

fn main() {
    let text = String::from("rust makes ownership explicit");

    match analyse(&text, Mode::LongWords(5)) {
        Ok(report) => println!(
            "words={}, chars={}, matches={:?}",
            report.words, report.chars, report.matches
        ),
        Err(error) => println!("error: {:?}", error),
    }

    println!("original={}", text);
}

The successful output is exactly:

Code
words=4, chars=29, matches=["makes", "ownership", "explicit"]
original=rust makes ownership explicit

The second line works because the call borrowed text.

Trace the worked values through the program

The input has no outer whitespace. Its word lengths are 4 + 5 + 9 + 8 = 26, and the three spaces bring the character count to 26 + 3 = 29. split_whitespace() produces "rust" (4), "makes" (5), "ownership" (9), and "explicit" (8). With >= 5, the last three enter matches.

value

type

owner or borrower

value after the call

text

String

owned by main

unchanged

trimmed

&str

borrows text inside analyse

borrow ended

words

Vec<&str>

vector owned locally, elements borrow text

dropped

matches

Vec<String>

owned strings moved into Report

returned

report.words

usize

owned numeric value

4

report.chars

usize

owned numeric value

29

Boundary checks expose each branch. Mode::LongWords(9) returns only ["ownership"]. Mode::LongWords(0) returns Err(AnalyseError::ZeroLimit). Three spaces become empty after trim() and return Err(AnalyseError::EmptyInput).

Execution trace of the counter: word lengths 4, 5, 9, 8 filtered at threshold 5, giving a Report of words=4, chars=29, and three matches.

Beginner traps: moves, counting, cloning, and unchecked errors

If analyse accepted text: String, the call would consume the value. text: &str borrows it. Using .clone() merely to silence a move creates another allocation. Clone for a genuinely independent copy, not as the automatic borrow-checker repair.

Changing >= min to > min wrongly excludes "makes" at threshold 5. Also, str::len() counts UTF-8 bytes, while chars().count() counts Unicode scalar values. For "café", len() == 5 bytes and chars().count() == 4. Neither measure counts user-perceived grapheme clusters.

The exhaustive match handles Ok and Err, so these expected failures do not panic here. unwrap() is not forbidden, but it is unsuitable when errors are expected and recoverable.

How coding exercises and interviews test these Rust ideas

Useful exercises test the model, not a claimed official pattern:

  1. Does let second = text; println!("{}", text); compile? No. The assignment moves ownership to second, so the later use of text is rejected.

  2. What is the threshold-9 result? It is ["ownership"].

  3. Add Mode::FirstWord. The match must gain a Mode::FirstWord arm to remain exhaustive.

Next, change Report.matches to Vec<usize> and return lengths at threshold 5. Expect [5, 9, 8]. These numbers are owned values, so no cloned words are needed. A C++ learning path can provide syntax comparison, but Rust ownership remains the focus. These are informal practice drills rather than exam questions, so they carry no marks or fixed pattern.

Short version and the next program to write

Keep this mental model:

  • Bindings are immutable unless marked mut.

  • Every owned value has one owner at a time, and &T borrows without taking ownership.

  • Structs collect related fields.

  • Enums plus match model alternatives.

  • Result makes success and failure explicit.

Now analyse "red blue red green" into a word-frequency map. Acceptance checks are red=2, blue=1, green=1, and the original input must still print after analysis. For more small cross-language exercises, try Classic Programs in C, Java and Python.

For broader placement-oriented practice, the Coding for Placements course covers C, C++, Java, Python, and competitive coding, not Rust.