πŸ¦€ Rust Programming: Files, Vectors, and Memory Management

Andrew J. Pounds, Ph.D.
Departments of Chemistry and Computer Science
Mercer University

🎯 Learning Objectives

After completing this exercise, you will be able to:

1 Getting Started: Compiling Rust Programs

Rust provides two ways to compile programs: the rustc compiler directly, or the cargo build system. Both are useful in different situations.

Using rustc (Direct Compilation)

For simple, single-file programs, you can use rustc directly:

// Save as: hello.rs
fn main() {
    println!("Hello, Rust!");
}

πŸ”§ Compile with rustc

rustc hello.rs
./hello

Using Cargo (Recommended for Projects)

Cargo is Rust's package manager and build system. It handles dependencies, builds, and more:

πŸ”§ Create and Build with Cargo

# Create a new project
cargo new my_project
cd my_project

# Build the project
cargo build

# Build and run in one step
cargo run

# Build optimized release version
cargo build --release

Cargo creates a project structure:

my_project/
β”œβ”€β”€ Cargo.toml      # Project configuration and dependencies
└── src/
    └── main.rs     # Main source file
For the examples in this document, we'll show both compilation methods. Use rustc for quick tests and cargo for larger projects or when you need external dependencies (crates).

2 Command-Line Arguments

Command-line arguments allow users to pass information to a program when it is executed. In Rust, command-line arguments are accessed using the std::env module.

Basic Command-Line Argument Access

Rust provides functions in std::env to access command-line arguments:

Function/Method Description
std::env::args() Returns an iterator over command-line arguments as String
args().len() Returns the number of arguments (including program name)
args().nth(n) Returns the nth argument as Option<String>
args().collect() Collects all arguments into a Vec<String>
// cmd_args.rs
use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();

    println!("Number of arguments: {}", args.len());
    println!("Program name: {}", args[0]);

    for (i, arg) in args.iter().enumerate() {
        println!("args[{}] = {}", i, arg);
    }
}

Compile and run with arguments:

rustc cmd_args.rs
./cmd_args hello world 123
Number of arguments: 4 Program name: ./cmd_args args[0] = ./cmd_args args[1] = hello args[2] = world args[3] = 123
Unlike some languages, args[0] is the program name itself in Rust. User-provided arguments start at args[1]. The total count includes the program name.

Processing Command-Line Arguments

Here's an example that expects a filename as a command-line argument:

// process_args.rs
use std::env;
use std::process;

fn main() {
    let args: Vec<String> = env::args().collect();

    // Check if correct number of arguments provided
    if args.len() < 2 {
        eprintln!("Usage: {} <filename> [count]", args[0]);
        process::exit(1);
    }

    println!("Processing file: {}", args[1]);

    // If a second argument is provided, treat it as a number
    if args.len() >= 3 {
        match args[2].parse::<i32>() {
            Ok(count) => println!("Count parameter: {}", count),
            Err(_) => eprintln!("Warning: '{}' is not a valid number", args[2]),
        }
    }
}
Rust uses eprintln! to print to standard error (stderr) and process::exit() to exit with a specific code. The parse() method returns a Result type, which we handle with match.

Idiomatic Argument Handling with Pattern Matching

// args_match.rs
use std::env;
use std::process;

fn main() {
    let args: Vec<String> = env::args().collect();

    // Use slice pattern matching
    let (program, args) = match args.as_slice() {
        [program, rest @ ..] => (program, rest),
        _ => {
            eprintln!("Error: No program name found");
            process::exit(1);
        }
    };

    if args.is_empty() {
        eprintln!("Usage: {} <filename>", program);
        process::exit(1);
    }

    let filename = &args[0];
    println!("Filename: {}", filename);

    // Optional second argument
    if let Some(count_str) = args.get(1) {
        if let Ok(count) = count_str.parse::<i32>() {
            println!("Count: {}", count);
        }
    }
}

3 Reading from Files

Rust provides robust file handling capabilities through the std::fs and std::io modules. Rust's approach emphasizes explicit error handling.

Reading an Entire File

The simplest way to read a file is using fs::read_to_string:

// read_file_simple.rs
use std::env;
use std::fs;
use std::process;

fn main() {
    let args: Vec<String> = env::args().collect();

    if args.len() < 2 {
        eprintln!("Usage: {} <filename>", args[0]);
        process::exit(1);
    }

    let filename = &args[1];

    // Read entire file into a String
    match fs::read_to_string(filename) {
        Ok(contents) => {
            println!("File contents:");
            println!("{}", contents);
        }
        Err(error) => {
            eprintln!("Error reading file '{}': {}", filename, error);
            process::exit(1);
        }
    }
}

Reading a File Line by Line

For large files or line-by-line processing, use BufReader:

// read_lines.rs
use std::env;
use std::fs::File;
use std::io::{self, BufRead, BufReader};
use std::process;

fn main() {
    let args: Vec<String> = env::args().collect();

    if args.len() < 2 {
        eprintln!("Usage: {} <filename>", args[0]);
        process::exit(1);
    }

    let filename = &args[1];

    // Open the file
    let file = match File::open(filename) {
        Ok(file) => file,
        Err(error) => {
            eprintln!("Error opening file '{}': {}", filename, error);
            process::exit(1);
        }
    };

    // Create a buffered reader
    let reader = BufReader::new(file);

    // Read line by line
    println!("File contents:");
    for (line_num, line_result) in reader.lines().enumerate() {
        match line_result {
            Ok(line) => println!("{}: {}", line_num + 1, line),
            Err(error) => {
                eprintln!("Error reading line: {}", error);
                break;
            }
        }
    }
}

File Operation Functions

Function/Method Description
fs::read_to_string(path) Reads entire file into a String
File::open(path) Opens a file for reading
File::create(path) Creates a new file (or truncates existing)
BufReader::new(file) Creates a buffered reader for efficient reading
reader.lines() Returns an iterator over lines
path.exists() Checks if a path exists (use std::path::Path)

Using the ? Operator for Error Handling

Rust's ? operator simplifies error handling in functions that return Result:

// read_file_question.rs
use std::env;
use std::fs::File;
use std::io::{self, BufRead, BufReader};
use std::path::Path;
use std::process;

fn read_and_print_file(filename: &str) -> io::Result<()> {
    // The ? operator returns early if there's an error
    let file = File::open(filename)?;
    let reader = BufReader::new(file);

    for line in reader.lines() {
        let line = line?;  // ? propagates errors
        println!("{}", line);
    }

    Ok(())
}

fn main() {
    let args: Vec<String> = env::args().collect();

    if args.len() < 2 {
        eprintln!("Usage: {} <filename>", args[0]);
        process::exit(1);
    }

    let filename = &args[1];

    // Check if file exists before opening
    if !Path::new(filename).exists() {
        eprintln!("Error: File '{}' not found.", filename);
        process::exit(1);
    }

    if let Err(error) = read_and_print_file(filename) {
        eprintln!("Error reading file: {}", error);
        process::exit(1);
    }
}
The ? operator can only be used in functions that return Result or Option. It automatically converts errors using the From trait. This is the idiomatic way to handle errors in Rust.

Checking if a File Exists

// check_exists.rs
use std::env;
use std::fs;
use std::path::Path;
use std::process;

fn main() {
    let args: Vec<String> = env::args().collect();

    if args.len() < 2 {
        eprintln!("Usage: {} <filename>", args[0]);
        process::exit(1);
    }

    let filename = &args[1];
    let path = Path::new(filename);

    // Check various properties
    if !path.exists() {
        eprintln!("Error: '{}' does not exist.", filename);
        process::exit(1);
    }

    if !path.is_file() {
        eprintln!("Error: '{}' is not a file.", filename);
        process::exit(1);
    }

    // Get file metadata
    match fs::metadata(path) {
        Ok(metadata) => {
            println!("File: {}", filename);
            println!("Size: {} bytes", metadata.len());
            println!("Is read-only: {}", metadata.permissions().readonly());
        }
        Err(e) => eprintln!("Error getting metadata: {}", e),
    }
}

4 Tokenizing Strings

Tokenization is the process of breaking a string into smaller pieces (tokens) based on delimiters. Rust provides powerful string methods for this purpose.

String Types in Rust

Type Description Ownership
String Growable, heap-allocated string Owned
&str String slice (view into a string) Borrowed
&String Reference to a String Borrowed

Basic String Methods for Tokenization

Method Description Returns
s.split(pat) Splits string by pattern Iterator of &str
s.split_whitespace() Splits by whitespace Iterator of &str
s.lines() Splits by line endings Iterator of &str
s.trim() Removes leading/trailing whitespace &str
s.find(pat) Finds first occurrence Option<usize>
s.contains(pat) Checks if pattern exists bool
s.replace(from, to) Replaces all occurrences String
s.to_lowercase() Converts to lowercase String

Splitting Strings

// tokenize_demo.rs
fn main() {
    let sentence = "The quick brown fox jumps";

    println!("Original: {}", sentence);
    println!("\nTokens (split by space):");

    // Split by single character
    for (i, token) in sentence.split(' ').enumerate() {
        println!("  Token {}: {}", i + 1, token);
    }

    // Split by whitespace (handles multiple spaces)
    let sentence2 = "  Multiple   spaces   here  ";
    println!("\nSplit by whitespace:");
    for token in sentence2.split_whitespace() {
        println!("  '{}'", token);
    }

    // Collect into a vector
    let tokens: Vec<&str> = sentence.split(' ').collect();
    println!("\nAs vector: {:?}", tokens);
    println!("Number of tokens: {}", tokens.len());
}
Original: The quick brown fox jumps Tokens (split by space): Token 1: The Token 2: quick Token 3: brown Token 4: fox Token 5: jumps Split by whitespace: 'Multiple' 'spaces' 'here' As vector: ["The", "quick", "brown", "fox", "jumps"] Number of tokens: 5

Parsing CSV Data

// csv_parse.rs
fn main() {
    let csv_line = "Alice,25,Computer Science,3.85";

    println!("Parsing CSV: {}", csv_line);
    println!();

    // Split by comma and collect into vector
    let fields: Vec<&str> = csv_line.split(',').collect();

    if fields.len() >= 4 {
        let name = fields[0];
        let age = fields[1];
        let major = fields[2];
        let gpa = fields[3];

        println!("Name: {}", name);
        println!("Age: {}", age);
        println!("Major: {}", major);
        println!("GPA: {}", gpa);
    }

    // Alternative: using iterator methods
    println!("\nUsing iterator with enumerate:");
    for (i, field) in csv_line.split(',').enumerate() {
        println!("  Field {}: {}", i, field.trim());
    }
}
Parsing CSV: Alice,25,Computer Science,3.85 Name: Alice Age: 25 Major: Computer Science GPA: 3.85 Using iterator with enumerate: Field 0: Alice Field 1: 25 Field 2: Computer Science Field 3: 3.85

A Reusable Tokenizer Function

// tokenizer_function.rs
/// Splits a string by delimiter and returns owned Strings
fn tokenize(text: &str, delimiter: char) -> Vec<String> {
    text.split(delimiter)
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .collect()
}

/// Splits a string by delimiter and returns the next token,
/// modifying the source string (similar to Pascal/Ada approach)
fn get_next_token(source: &mut String, delimiter: char) -> Option<String> {
    let trimmed = source.trim_start();
    *source = trimmed.to_string();

    if source.is_empty() {
        return None;
    }

    match source.find(delimiter) {
        Some(pos) => {
            let token = source[..pos].trim().to_string();
            *source = source[pos + 1..].to_string();
            Some(token)
        }
        None => {
            let token = source.trim().to_string();
            source.clear();
            if token.is_empty() {
                None
            } else {
                Some(token)
            }
        }
    }
}

fn main() {
    // Using tokenize function
    let csv = "Alice, 25, Computer Science, 3.85";
    let tokens = tokenize(csv, ',');
    println!("Tokenized: {:?}", tokens);

    // Using get_next_token
    println!("\nExtracting tokens one by one:");
    let mut data = String::from("Bob,30,Physics,3.92");

    while let Some(token) = get_next_token(&mut data, ',') {
        println!("  Token: {}", token);
    }
}
Tokenized: ["Alice", "25", "Computer Science", "3.85"] Extracting tokens one by one: Token: Bob Token: 30 Token: Physics Token: 3.92

Tokenizing File Contents

// tokenize_file.rs
use std::env;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
use std::process;

fn main() {
    let args: Vec<String> = env::args().collect();

    if args.len() < 2 {
        eprintln!("Usage: {} <filename>", args[0]);
        process::exit(1);
    }

    let filename = &args[1];

    if !Path::new(filename).exists() {
        eprintln!("Error: File '{}' not found.", filename);
        process::exit(1);
    }

    let file = File::open(filename).expect("Failed to open file");
    let reader = BufReader::new(file);

    let mut token_count = 0;

    for line_result in reader.lines() {
        let line = line_result.expect("Failed to read line");

        // Tokenize each line by whitespace
        for token in line.split_whitespace() {
            token_count += 1;
        }
    }

    println!("Total tokens in file: {}", token_count);
}
Rust iterators are lazyβ€”they don't process elements until consumed. This makes chaining operations like split().map().filter().collect() very efficient.

5 Vectors in Rust

Rust uses Vec<T> (vectors) for dynamic arrays. Vectors are heap-allocated and can grow or shrink at runtime.

Creating Vectors

// vector_basics.rs
fn main() {
    // Create an empty vector
    let mut numbers: Vec<i32> = Vec::new();

    // Create with initial values using vec! macro
    let mut grades = vec![85.0, 92.5, 78.0, 95.5];

    // Create with repeated value
    let zeros = vec![0; 5];  // [0, 0, 0, 0, 0]

    // Create with capacity (no initial elements)
    let mut with_capacity: Vec<String> = Vec::with_capacity(10);

    println!("numbers: {:?}", numbers);
    println!("grades: {:?}", grades);
    println!("zeros: {:?}", zeros);
    println!("with_capacity length: {}, capacity: {}",
             with_capacity.len(), with_capacity.capacity());
}
numbers: [] grades: [85.0, 92.5, 78.0, 95.5] zeros: [0, 0, 0, 0, 0] with_capacity length: 0, capacity: 10

Vector Operations

Operation Description
vec.push(value) Adds element to the end
vec.pop() Removes and returns last element as Option
vec.len() Returns number of elements
vec.is_empty() Returns true if empty
vec[index] Access by index (panics if out of bounds)
vec.get(index) Safe access, returns Option<&T>
vec.first() Returns Option<&T> for first element
vec.last() Returns Option<&T> for last element
vec.insert(i, v) Inserts at index, shifting elements
vec.remove(i) Removes at index, shifting elements
vec.clear() Removes all elements
vec.iter() Returns an iterator over references
// vector_operations.rs
fn main() {
    let mut numbers: Vec<i32> = Vec::new();

    // Add elements
    numbers.push(10);
    numbers.push(20);
    numbers.push(30);
    numbers.push(40);
    numbers.push(50);

    println!("After pushing: {:?}", numbers);
    println!("Length: {}", numbers.len());

    // Access elements
    println!("First element: {}", numbers[0]);
    println!("Element at index 2: {}", numbers[2]);

    // Safe access with get()
    match numbers.get(10) {
        Some(value) => println!("Element at 10: {}", value),
        None => println!("No element at index 10"),
    }

    // Remove last element
    if let Some(last) = numbers.pop() {
        println!("Popped: {}", last);
    }
    println!("After pop: {:?}", numbers);

    // Iterate over elements
    println!("\nIterating:");
    for (i, num) in numbers.iter().enumerate() {
        println!("  numbers[{}] = {}", i, num);
    }

    // Modify elements
    for num in numbers.iter_mut() {
        *num *= 2;
    }
    println!("\nAfter doubling: {:?}", numbers);
}
After pushing: [10, 20, 30, 40, 50] Length: 5 First element: 10 Element at index 2: 30 No element at index 10 Popped: 50 After pop: [10, 20, 30, 40] Iterating: numbers[0] = 10 numbers[1] = 20 numbers[2] = 30 numbers[3] = 40 After doubling: [20, 40, 60, 80]
Use vec.get(index) instead of vec[index] when the index might be out of bounds. The get method returns Option instead of panicking.

Vectors of Strings

// string_vector.rs
fn main() {
    let mut words: Vec<String> = Vec::new();

    // Push owned Strings
    words.push(String::from("Hello"));
    words.push(String::from("Dynamic"));
    words.push(String::from("World"));

    println!("Words: {:?}", words);

    // Iterate and print
    for (i, word) in words.iter().enumerate() {
        println!("  [{}]: {}", i, word);
    }

    // Create from string literals
    let days: Vec<&str> = vec!["Mon", "Tue", "Wed", "Thu", "Fri"];
    println!("\nDays: {:?}", days);

    // Convert Vec<&str> to Vec<String>
    let owned_days: Vec<String> = days.iter()
        .map(|s| s.to_string())
        .collect();
    println!("Owned days: {:?}", owned_days);
}

Storing Tokens from a File

// tokens_to_vector.rs
use std::env;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
use std::process;

fn main() {
    let args: Vec<String> = env::args().collect();

    if args.len() < 2 {
        eprintln!("Usage: {} <filename>", args[0]);
        process::exit(1);
    }

    let filename = &args[1];

    if !Path::new(filename).exists() {
        eprintln!("Error: File not found.");
        process::exit(1);
    }

    let file = File::open(filename).expect("Failed to open file");
    let reader = BufReader::new(file);

    let mut tokens: Vec<String> = Vec::new();

    for line_result in reader.lines() {
        let line = line_result.expect("Failed to read line");

        // Tokenize and store
        for token in line.split_whitespace() {
            tokens.push(token.to_string());
        }
    }

    // Display results
    println!("=== File Token Analysis ===");
    println!("Total tokens: {}", tokens.len());

    if let Some(first) = tokens.first() {
        println!("First token: {}", first);
    }

    if let Some(last) = tokens.last() {
        println!("Last token: {}", last);
    }

    println!("\nTokens longer than 5 characters:");
    for (i, token) in tokens.iter().enumerate() {
        if token.len() > 5 {
            println!("  [{}]: {} (length: {})", i, token, token.len());
        }
    }
}

6 Memory Management in Rust

Rust's most distinctive feature is its ownership system, which provides memory safety without garbage collection. Understanding ownership is essential for writing Rust programs.

The Three Rules of Ownership

  1. Each value in Rust has exactly one owner
  2. When the owner goes out of scope, the value is dropped (memory freed)
  3. Ownership can be transferred (moved) or borrowed

Ownership and Move Semantics

// ownership_demo.rs
fn main() {
    // s1 owns the String
    let s1 = String::from("Hello");

    // Ownership moves from s1 to s2
    let s2 = s1;

    // This would cause a compile error:
    // println!("{}", s1);  // ERROR: s1 no longer valid

    println!("{}", s2);  // OK: s2 owns the String

    // When s2 goes out of scope here, memory is freed
}

Borrowing with References

// borrowing_demo.rs
fn print_length(s: &String) {
    // s is a reference (borrow) - doesn't own the data
    println!("Length: {}", s.len());
}  // s goes out of scope, but doesn't drop the String (it's borrowed)

fn add_exclamation(s: &mut String) {
    // Mutable borrow - can modify without taking ownership
    s.push('!');
}

fn main() {
    let mut message = String::from("Hello");

    // Immutable borrow
    print_length(&message);

    // Mutable borrow
    add_exclamation(&mut message);

    // message is still valid and accessible
    println!("Message: {}", message);
}
Length: 5 Message: Hello!

Comparison: Rust vs. Other Languages

Feature Rust C/C++ Java/Python (GC)
Memory management Ownership system Manual Garbage collection
When memory freed Immediately at scope end When you call free/delete When GC runs
Memory leaks Prevented by compiler Possible (if forget free) Rare
Dangling pointers Prevented by compiler Possible Prevented by GC
Data races Prevented by compiler Possible Possible
Runtime overhead Zero-cost abstractions None GC pauses
Null pointers No null (use Option) Possible Possible

Automatic Memory Management with Vectors

// auto_memory_demo.rs
fn create_data() {
    // Vector is allocated on the heap
    let mut data: Vec<String> = Vec::new();

    for i in 0..1000 {
        data.push(format!("Item {}", i));
    }

    println!("Created {} items inside function", data.len());

    // When this function returns, `data` goes out of scope
    // Rust automatically:
    // 1. Drops each String in the vector (freeing their heap memory)
    // 2. Drops the vector itself (freeing its heap memory)
    // No explicit cleanup needed!
}

fn main() {
    println!("=== Automatic Memory Management Demo ===");

    for i in 1..=5 {
        println!("\nIteration {}", i);
        create_data();
        // Memory from previous call has been freed
    }

    println!("\nProgram completed - all memory cleaned up automatically");
}
Rust achieves memory safety through compile-time checks. The borrow checker ensures that references are always valid and that there are no data races. There is no runtime garbage collectorβ€”memory is freed deterministically when owners go out of scope.

The Drop Trait

// drop_demo.rs
struct Resource {
    name: String,
}

impl Resource {
    fn new(name: &str) -> Resource {
        println!("Creating resource: {}", name);
        Resource {
            name: String::from(name),
        }
    }
}

impl Drop for Resource {
    fn drop(&mut self) {
        println!("Dropping resource: {}", self.name);
    }
}

fn main() {
    println!("=== Drop Trait Demo ===\n");

    {
        let r1 = Resource::new("First");
        let r2 = Resource::new("Second");
        println!("\nInside inner scope...\n");
    }  // r2 dropped first, then r1 (reverse order)

    println!("\nBack in main scope\n");

    let r3 = Resource::new("Third");
    println!("\nAbout to exit main...\n");
}  // r3 dropped here
=== Drop Trait Demo === Creating resource: First Creating resource: Second Inside inner scope... Dropping resource: Second Dropping resource: First Back in main scope Creating resource: Third About to exit main... Dropping resource: Third

Best Practices for Rust Memory Management

// best_practices.rs
use std::fs::File;
use std::io::{self, BufRead, BufReader};

// 1. Use references when you don't need ownership
fn process_data(data: &[String]) {
    for item in data {
        println!("Processing: {}", item);
    }
}

// 2. Return owned data when creating new values
fn create_list() -> Vec<String> {
    vec![
        String::from("One"),
        String::from("Two"),
        String::from("Three"),
    ]
}

// 3. Use Result for fallible operations
fn read_lines(filename: &str) -> io::Result<Vec<String>> {
    let file = File::open(filename)?;
    let reader = BufReader::new(file);

    let mut lines = Vec::new();
    for line_result in reader.lines() {
        lines.push(line_result?);
    }

    Ok(lines)
}

fn main() {
    // Create data - we own it
    let my_list = create_list();

    // Borrow data - function doesn't take ownership
    process_data(&my_list);

    // We can still use my_list
    println!("\nList still accessible: {:?}", my_list);

    // my_list dropped automatically when main ends
}
Key Insight: In Rust, you rarely need to think about freeing memory explicitly. The compiler enforces ownership rules, and memory is freed automatically when values go out of scope. Focus on ownership and borrowing; memory safety follows naturally.

7 Complete Example: Word Frequency Analyzer

This comprehensive example combines all the concepts: command-line arguments, file reading, tokenization, vectors, and demonstrates Rust's memory management:

// word_frequency_analyzer.rs
use std::collections::HashMap;
use std::env;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
use std::process;

/// Removes punctuation from the beginning and end of a word
fn strip_punctuation(word: &str) -> String {
    let punctuation = ".,!?;:\"'()-";

    let trimmed: String = word
        .trim_start_matches(|c| punctuation.contains(c))
        .trim_end_matches(|c| punctuation.contains(c))
        .to_string();

    trimmed
}

fn main() {
    // Check command-line arguments
    let args: Vec<String> = env::args().collect();

    if args.len() < 2 {
        eprintln!("Word Frequency Analyzer");
        eprintln!("Usage: {} <filename>", args[0]);
        process::exit(1);
    }

    let filename = &args[1];

    // Check if file exists
    if !Path::new(filename).exists() {
        eprintln!("Error: File '{}' not found.", filename);
        process::exit(1);
    }

    // Open and read the file
    let file = match File::open(filename) {
        Ok(f) => f,
        Err(e) => {
            eprintln!("Error opening file: {}", e);
            process::exit(1);
        }
    };

    let reader = BufReader::new(file);

    // Store all tokens and count frequencies
    let mut all_tokens: Vec<String> = Vec::new();
    let mut word_counts: HashMap<String, u32> = HashMap::new();

    for line_result in reader.lines() {
        let line = match line_result {
            Ok(l) => l,
            Err(e) => {
                eprintln!("Error reading line: {}", e);
                continue;
            }
        };

        // Tokenize and process each word
        for token in line.split_whitespace() {
            let word = strip_punctuation(token).to_lowercase();

            if !word.is_empty() {
                all_tokens.push(word.clone());

                // Update word count
                *word_counts.entry(word).or_insert(0) += 1;
            }
        }
    }

    // Display results
    println!("=== Word Frequency Analysis ===");
    println!("File: {}", filename);
    println!("Total tokens: {}", all_tokens.len());
    println!("Unique words: {}", word_counts.len());
    println!();

    // Find words appearing more than once
    println!("Words appearing more than once:");
    println!("{:-<40}", "");

    let mut repeated_words: Vec<(&String, &u32)> = word_counts
        .iter()
        .filter(|(_, &count)| count > 1)
        .collect();

    // Sort by count (descending)
    repeated_words.sort_by(|a, b| b.1.cmp(a.1));

    for (word, count) in &repeated_words {
        println!("  {:<20}: {}", word, count);
    }

    // Find most common word
    if let Some((word, count)) = word_counts.iter().max_by_key(|(_, &v)| v) {
        println!();
        println!("Most common word: '{}' ({} occurrences)", word, count);
    }

    // Memory note: All data structures (all_tokens, word_counts, etc.)
    // will be automatically freed when main() ends.
    // Rust's ownership system ensures no memory leaks.
}

πŸ”§ Compile and Run

# With rustc
rustc word_frequency_analyzer.rs
./word_frequency_analyzer sample.txt

# With cargo (create project first)
cargo new word_analyzer
# Copy code to src/main.rs
cargo run -- sample.txt

8 Quick Reference

Task Rust Syntax
Get all arguments let args: Vec<String> = env::args().collect();
Get argument count args.len()
Exit with error code process::exit(1);
Print to stderr eprintln!("Error: {}", msg);
Read file to string fs::read_to_string(filename)?
Open file File::open(filename)?
Create buffered reader BufReader::new(file)
Read lines iterator reader.lines()
Check file exists Path::new(filename).exists()
Split string s.split(',') or s.split_whitespace()
Trim whitespace s.trim()
Parse string to number s.parse::<i32>()?
Create empty vector let v: Vec<T> = Vec::new();
Create vector with values let v = vec![1, 2, 3];
Add to vector v.push(value);
Get vector length v.len()
Safe element access v.get(index) (returns Option)
Iterate with index for (i, item) in v.iter().enumerate()
Borrow (immutable) &value
Borrow (mutable) &mut value
Convert &str to String s.to_string() or String::from(s)

✏️ Programming Exercise: CSV Data Processor

Write a Rust program that processes a CSV (Comma-Separated Values) file containing student grade data. Your program should:

  1. Accept the filename as a command-line argument
    • Display a usage message if no filename is provided
    • Display an error if the file doesn't exist
  2. Read and parse the CSV file where each line has the format:
    StudentName,Score1,Score2,Score3
    Example: Alice,85,92,78
  3. Store the data in vectors:
    • A vector of String for student names
    • A vector of f64 for each student's average score
  4. Calculate and display:
    • Each student's name and average score
    • The class average (average of all student averages)
    • The highest and lowest scoring students
  5. Demonstrate understanding of memory management by adding a comment in your code explaining how Rust handles the cleanup
grades.csv
Alice,85,92,78
Bob,90,88,95
Charlie,72,68,75
Diana,95,98,92

Sample Output

=== Student Grade Analyzer === File: grades.csv Students loaded: 4 Student Averages: Alice : 85.00 Bob : 91.00 Charlie : 71.67 Diana : 95.00 Class Statistics: Class Average : 85.67 Highest Score : Diana (95.00) Lowest Score : Charlie (71.67)

Hints

Submission

Save your program as grade_analyzer.rs and compile with:

rustc grade_analyzer.rs
./grade_analyzer grades.csv

# Or with cargo:
cargo new grade_analyzer
# Edit src/main.rs
cargo run -- grades.csv

πŸ“‹ Student Starter Code

Use the following template to get started. Fill in each blank marked with ______ with the appropriate code. Read the comments carefully for hints!

// grade_analyzer.rs
// CSV Grade Analyzer - Student Starter Code
// =========================================
// Complete the sections marked with TODO to finish this program.
//
// This program will:
// 1. Accept a CSV filename as a command-line argument
// 2. Read and parse the CSV file (Name,Score1,Score2,Score3)
// 3. Store student names and calculated averages in vectors
// 4. Display individual averages and class statistics
//
// Author: [Your Name]
// Date:   [Date]

use std::env;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
use std::process;

fn main() {
    // ===================================================================
    // PART 1: Command-Line Argument Handling
    // ===================================================================

    // TODO: Collect command-line arguments into a vector
    // HINT: Use env::args().collect()
    let args: Vec<String> = env::______().______();

    // TODO: Check if a filename was provided (args[0] is the program name)
    // HINT: Check if args.len() is less than 2
    if args.______() < 2 {
        eprintln!("=== Student Grade Analyzer ===");
        // TODO: Print usage message with program name
        // HINT: args[0] contains the program name
        eprintln!("Usage: {} <filename>", ______[0]);
        process::exit(1);
    }

    // TODO: Get the filename from args[1]
    // HINT: Use &args[1] to borrow the string
    let filename = &______[1];

    // TODO: Check if file exists
    // HINT: Use Path::new(filename).exists()
    if !______(filename).______() {
        eprintln!("Error: File '{}' not found.", filename);
        process::exit(1);
    }

    // ===================================================================
    // PART 2: Open File and Prepare for Reading
    // ===================================================================

    // TODO: Open the file
    // HINT: Use File::open(filename) with match or expect
    let file = match ______::______(filename) {
        Ok(f) => f,
        Err(e) => {
            eprintln!("Error opening file: {}", e);
            process::exit(1);
        }
    };

    // TODO: Create a buffered reader
    // HINT: Use BufReader::new(file)
    let reader = ______::______(file);

    // ===================================================================
    // PART 3: Declare Vectors for Storing Data
    // ===================================================================

    // TODO: Create an empty vector for student names (String type)
    // HINT: Use Vec::new() or vec![]
    let mut names: Vec<String> = ______::______();

    // TODO: Create an empty vector for student averages (f64 type)
    let mut averages: Vec<f64> = ______::______();

    // ===================================================================
    // PART 4: Read and Parse the CSV File
    // ===================================================================

    // TODO: Iterate over lines in the file
    // HINT: Use reader.lines()
    for line_result in reader.______() {
        // TODO: Handle the Result from reading the line
        // HINT: Use match or if let Ok(line) = line_result
        let line = match line_result {
            Ok(l) => l,
            Err(_) => continue,
        };

        // Skip empty lines
        if line.trim().is_empty() {
            continue;
        }

        // TODO: Split the line by comma and collect into a vector
        // HINT: Use line.split(',').collect()
        let fields: Vec<&str> = line.______(',').______();

        // Check that we have enough fields
        if fields.len() < 4 {
            continue;
        }

        // TODO: Extract the student name (first field)
        // HINT: fields[0] is a &str, use .to_string() to convert to String
        let name = fields[0].trim().______();

        // TODO: Parse the three scores
        // HINT: Use .parse::<f64>().unwrap_or(0.0)
        let score1: f64 = fields[1].trim().______::<f64>().unwrap_or(0.0);
        let score2: f64 = fields[2].trim().______::<______>().unwrap_or(0.0);
        let score3: f64 = fields[3].trim().______::<______>().______;

        // TODO: Calculate the student's average
        // HINT: (score1 + score2 + score3) / 3.0
        let student_avg = (______ + ______ + ______) / 3.0;

        // TODO: Add name and average to their respective vectors
        // HINT: Use .push()
        names.______(name);
        averages.______(student_avg);
    }

    // ===================================================================
    // PART 5: Calculate Statistics
    // ===================================================================

    // TODO: Calculate class total and find highest/lowest
    let mut class_total: f64 = 0.0;
    let mut highest_avg: f64 = 0.0;
    let mut lowest_avg: f64 = f64::MAX;
    let mut highest_name = String::new();
    let mut lowest_name = String::new();

    // TODO: Loop through all students using enumerate
    // HINT: Use for (i, avg) in averages.iter().enumerate()
    for (i, avg) in averages.______().______() {
        class_total += avg;

        // TODO: Check if this is the highest average
        if *avg > ______ {
            highest_avg = *avg;
            // TODO: Get the corresponding name
            // HINT: Use names[i].clone()
            highest_name = names[i].______();
        }

        // TODO: Check if this is the lowest average
        if *avg < ______ {
            lowest_avg = *avg;
            lowest_name = names[i].______();
        }
    }

    // TODO: Calculate class average
    // HINT: class_total / names.len() as f64
    let class_avg = class_total / ______.______() as f64;

    // ===================================================================
    // PART 6: Display Results
    // ===================================================================

    println!("=== Student Grade Analyzer ===");
    println!("File: {}", filename);
    // TODO: Display number of students
    // HINT: Use names.len()
    println!("Students loaded: {}", names.______());
    println!();

    println!("Student Averages:");
    // TODO: Loop through and display each student
    for (i, name) in names.iter().enumerate() {
        // TODO: Get the average for this student
        let avg = averages[i];
        println!("  {:<15}: {:.2}", name, avg);
    }

    println!();
    println!("Class Statistics:");
    println!("  Class Average  : {:.2}", class_avg);
    println!("  Highest Score  : {} ({:.2})", highest_name, highest_avg);
    println!("  Lowest Score   : {} ({:.2})", lowest_name, lowest_avg);

    // ===================================================================
    // MEMORY MANAGEMENT EXPLANATION
    // ===================================================================
    // TODO: Answer these questions in comments:
    //
    // 1. Why don't we need to manually free the `names` and `averages` vectors?
    //    YOUR ANSWER: ________________________________________________
    //
    // 2. When exactly is the memory for these vectors released?
    //    YOUR ANSWER: ________________________________________________
    //
    // 3. What happens to the `file` and `reader` variables?
    //    YOUR ANSWER: ________________________________________________
    //
    // 4. How does Rust's approach differ from garbage-collected languages?
    //    YOUR ANSWER: ________________________________________________
}

Blank Reference Guide

Use this table to check your work:

Section Blank Location Answer
Part 1 env::______() args
Part 1 .______() (collect) collect
Part 1 args.______() (length) len
Part 1 ______[0] args
Part 1 &______[1] args
Part 1 ______(filename) Path::new
Part 1 .______() (exists) exists
Part 2 ______::______(filename) File::open
Part 2 ______::______(file) BufReader::new
Part 3 ______::______() Vec::new
Part 4 reader.______() lines
Part 4 line.______(',').______() split, collect
Part 4 .______() (to owned string) to_string
Part 4 .______::<f64>() parse
Part 4 Average calculation score1, score2, score3
Part 4 .______() (add to vector) push
Part 5 .______().______() iter, enumerate
Part 5 > ______ highest_avg
Part 5 .______() (clone string) clone
Part 5 < ______ lowest_avg
Part 5 ______.______() as f64 names.len
Part 6 names.______() len

Testing Your Program

Create a file named grades.csv:

Alice,85,92,78
Bob,90,88,95
Charlie,72,68,75
Diana,95,98,92
Test Command Expected Result
No arguments ./grade_analyzer Usage message displayed
Missing file ./grade_analyzer nofile.csv Error message about file not found
Valid file ./grade_analyzer grades.csv All statistics displayed correctly

Expected Values

Student Average
Alice 85.00
Bob 91.00
Charlie 71.67
Diana 95.00
Class Average 85.67

Common Errors to Watch For