learn.sol
Rust Foundations • Collections And String Handling Practice

Collections and String Handling Practice

Practice vectors, hash maps, strings, and parsing by building one transaction log analyzer in Rust.

This practice page should not split your attention across multiple projects.

It should give you one realistic program that forces the collections lesson to become usable.

That is what this page does.

The project is small on purpose.

If you can store records, parse input, group totals, and answer simple queries without getting lost, then this section is doing its job.

The Project

Build a transaction log analyzer.

The program will read a small list of text commands like these:

send alice bob 500
send bob carol 200
send alice dave 300
send carol alice 100

Then it will:

  • parse each line
  • store every valid transfer in a Vec<Transfer>
  • calculate per-wallet totals with HashMap<String, u64>
  • print a readable summary

This is the right project for this lesson because it uses the exact skills you just learned:

  • Vec<T> for ordered history
  • HashMap<K, V> for lookup and aggregation
  • String for owned stored data
  • &str for borrowed parsing input
  • split_whitespace, parse, and trim for text processing

What You Are Proving

By the end, you should be able to explain:

  • why the transfer history belongs in a vector
  • why per-wallet totals belong in a hash map
  • why parsed input starts as &str and later becomes String
  • where ownership is required and where borrowing is enough

If you cannot explain those four things, the code is not the real win yet.

Step 1: Define The Core Type

Start with one struct.

#[derive(Debug, Clone)]
struct Transfer {
    from: String,
    to: String,
    amount: u64,
}

This struct is intentionally small.

You do not need timestamps, memo fields, signatures, token metadata, or network state.

Those would only distract from the collections lesson.

Step 2: Parse One Line Correctly

Write one function that turns a command line into a Transfer.

fn parse_transfer(line: &str) -> Result<Transfer, String> {
    let parts: Vec<&str> = line.split_whitespace().collect();

    if parts.len() != 4 {
        return Err(String::from("expected format: send <from> <to> <amount>"));
    }

    if parts[0] != "send" {
        return Err(String::from("command must start with 'send'"));
    }

    let amount = parts[3]
        .parse::<u64>()
        .map_err(|_| String::from("amount must be a positive integer"))?;

    Ok(Transfer {
        from: parts[1].to_string(),
        to: parts[2].to_string(),
        amount,
    })
}

What this step teaches

  • line is borrowed as &str because parsing does not require ownership of the whole input
  • parts becomes Vec<&str> because split_whitespace borrows slices from the original line
  • from and to become String because the Transfer struct needs to own them after parsing finishes

That ownership shift matters.

It is the main string-handling idea in this practice.

Step 3: Store Every Parsed Transfer In A Vector

Now use a vector as the ordered history.

fn main() {
    let input = vec![
        "send alice bob 500",
        "send bob carol 200",
        "send alice dave 300",
        "send carol alice 100",
    ];

    let mut transfers: Vec<Transfer> = Vec::new();

    for line in input {
        match parse_transfer(line) {
            Ok(transfer) => transfers.push(transfer),
            Err(error) => println!("skipping line: {} | {}", line, error),
        }
    }

    println!("parsed transfers: {:?}", transfers);
}

A vector is the right tool here because:

  • order matters
  • you want to keep every record
  • you want to iterate through the whole history later

Step 4: Build A Hash Map For Totals Sent

Now aggregate the transfers by sender.

use std::collections::HashMap;

fn totals_sent(transfers: &[Transfer]) -> HashMap<String, u64> {
    let mut totals = HashMap::new();

    for transfer in transfers {
        *totals.entry(transfer.from.clone()).or_insert(0) += transfer.amount;
    }

    totals
}

Why the hash map belongs here

A vector is good for the raw history.

A hash map is better for questions like:

  • how much did Alice send in total
  • how much did Bob send in total

That is keyed lookup.

That is what HashMap is for.

Step 5: Build A Hash Map For Totals Received

Do the same thing for recipients.

use std::collections::HashMap;

fn totals_received(transfers: &[Transfer]) -> HashMap<String, u64> {
    let mut totals = HashMap::new();

    for transfer in transfers {
        *totals.entry(transfer.to.clone()).or_insert(0) += transfer.amount;
    }

    totals
}

This is a good checkpoint.

You now have:

  • one vector for the full history
  • one hash map for sent totals
  • one hash map for received totals

That is exactly the kind of separation a real program needs.

Step 6: Print A Useful Summary

Now make the program produce something readable.

fn main() {
    let input = vec![
        "send alice bob 500",
        "send bob carol 200",
        "send alice dave 300",
        "send carol alice 100",
    ];

    let mut transfers: Vec<Transfer> = Vec::new();

    for line in input {
        match parse_transfer(line) {
            Ok(transfer) => transfers.push(transfer),
            Err(error) => println!("skipping line: {} | {}", line, error),
        }
    }

    let sent = totals_sent(&transfers);
    let received = totals_received(&transfers);

    println!("transfers parsed: {}", transfers.len());
    println!("totals sent: {:?}", sent);
    println!("totals received: {:?}", received);
}

You can improve the formatting later.

At this stage, correctness matters more than pretty output.

Step 7: Add One Real Query Function

Make one helper that answers a practical question.

fn transfers_for_wallet<'a>(transfers: &'a [Transfer], wallet: &str) -> Vec<&'a Transfer> {
    transfers
        .iter()
        .filter(|transfer| transfer.from == wallet || transfer.to == wallet)
        .collect()
}

This proves another useful distinction:

  • the history vector owns Transfer values
  • the query result can return &Transfer references because it only borrows from the history

That is a good ownership decision.

You do not need to clone the whole history just to answer one query.

Step 8: Handle Bad Input On Purpose

A practice project is weak if every line is valid.

Add bad input and make sure your parser behaves correctly.

let input = vec![
    "send alice bob 500",
    "send bob carol two_hundred",
    "deposit alice 400",
    "send carol alice 100",
];

What should happen:

  • valid send commands become Transfer records
  • invalid lines do not panic the program
  • invalid lines report a clear reason and get skipped

That is a much stronger exercise than just parsing the happy path.

Suggested Build Order

Use this order and do not rush it:

  1. write Transfer
  2. write parse_transfer
  3. parse a few valid lines into Vec<Transfer>
  4. add one invalid line and verify the error path
  5. write totals_sent
  6. write totals_received
  7. write transfers_for_wallet
  8. print one full summary

That order keeps the difficulty under control.

Common Mistakes

Mistake 1: using a hash map for the raw history

If you store the transaction log itself in a hash map, you lose the simple ordered record list.

The history is a vector.

The summary lookups are hash maps.

Mistake 2: converting everything to String too early

When parsing, borrowed &str pieces are fine for temporary work.

Only convert to String when the final stored struct needs ownership.

Mistake 3: cloning more than necessary

If a function only needs to read transfers, borrow &[Transfer].

Do not clone the whole vector just because borrowing feels harder.

Mistake 4: mixing parsing logic with summary logic too early

First parse the input.

Then aggregate.

Then print results.

If all three happen in one tangled block, the lesson gets harder than it needs to be.

What A Good Finished Version Looks Like

By the end, your program should:

  • parse a list of transfer commands
  • skip invalid lines safely
  • store valid transfers in order
  • compute total sent per wallet
  • compute total received per wallet
  • answer at least one query for a specific wallet

That is enough.

If you can do that cleanly, you understand the point of this lesson.

Optional Extension

Only add this after the main version is clean.

Add a function that builds a net balance change per wallet:

  • sent amount subtracts from the wallet
  • received amount adds to the wallet

That is a good extension because it still uses the same core tools:

  • vectors
  • hash maps
  • string parsing
  • careful ownership

What Comes Next

After Rust Foundations, the next layers of the curriculum start asking you to use these same ideas in more realistic Solana code.

That only works if collections and strings now feel normal instead of magical.

Solana Assistant

AI-powered documentation helper

Welcome to Solana Assistant

Ask specific questions about Solana development:

Ask specific questions for better results400px
    Collections and String Handling Practice | learn.sol