Tech Wiki

TOPICSSERIES

[Rust Zero to Production 05] Practical Rust Collections: Vec, String, and HashMap

Take three lines of endpoint data and count each status. That small job gives Vec, String, and HashMap distinct responsibilities: Vec preserves the parsed record order, String owns text stored in each record, and HashMap connects a status to its count.

The example uses only the standard library. It skips malformed lines, represents missing values with Option, and never slices a Korean name at an arbitrary byte offset. The complete standalone crate below was formatted, linted with Clippy, and tested without relying on earlier installments.

1. Collection Roles

Vec<T> is a growable, contiguous sequence of values of one type. It fits input whose length is unknown before the program runs, especially when order matters. It supports two access styles: items[10] panics when the index is out of bounds, while items.get(10) returns Option<&T>.

String is an owned, UTF-8-encoded string. The parser below creates records that own their fields, so each borrowed &str field is copied into a String. A function that only needs to inspect text can still accept &str. The useful tension is that String is backed by bytes, but it guarantees valid UTF-8.

HashMap<K, V> maps keys to values. Here, statuses such as ok and error are keys, and their occurrence counts are values. entry(...).or_insert(0) handles first and subsequent occurrences in one path. On the read side, get lets the program handle an absent status without a panic.

2. Rejecting Malformed Lines

The input format is name|URL|status, one record per line. Split a line, collect its fields into Vec<&str>, and use a slice pattern to require exactly three fields.

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EndpointRecord {
    pub name: String,
    pub url: String,
    pub status: String,
}

pub fn parse_endpoints(input: &str) -> Vec<EndpointRecord> {
    input.lines().filter_map(parse_line).collect()
}

fn parse_line(line: &str) -> Option<EndpointRecord> {
    let fields: Vec<&str> = line.split('|').map(str::trim).collect();
    let [name, url, status] = fields.as_slice() else {
        return None;
    };

    if name.is_empty() || url.is_empty() || status.is_empty() {
        return None;
    }

    Some(EndpointRecord {
        name: (*name).to_owned(),
        url: (*url).to_owned(),
        status: (*status).to_owned(),
    })
}

filter_map adds only Some(record) values to the result. A line with the wrong field count or an empty field produces None and is left out. A production importer would probably return line numbers and errors instead of silently dropping bad records. This example keeps that policy simple so the collection operations stay visible.

The slice pattern, let [name, url, status] = fields.as_slice(), says more than a chain of fields[0], fields[1], and fields[2] expressions. A record exists only when all three fields exist and no extra field does. For optional positional access, fields.get(index) offers the same no-panic boundary.

3. Use entry and get

The summary counts statuses and computes a preview of the first endpoint name. Empty input remains a valid case.

use std::collections::HashMap;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EndpointSummary {
    pub total: usize,
    pub first_name_preview: Option<String>,
    pub status_counts: HashMap<String, usize>,
}

pub fn summarize(endpoints: &[EndpointRecord]) -> EndpointSummary {
    let mut status_counts = HashMap::new();
    for endpoint in endpoints {
        *status_counts.entry(endpoint.status.clone()).or_insert(0) += 1;
    }

    let first_name_preview = endpoints
        .first()
        .map(|endpoint| endpoint.name.chars().take(4).collect());

    EndpointSummary {
        total: endpoints.len(),
        first_name_preview,
        status_counts,
    }
}

impl EndpointSummary {
    pub fn count_for(&self, status: &str) -> usize {
        self.status_counts.get(status).copied().unwrap_or(0)
    }
}

entry combines lookup and insertion. It inserts 0 for a new status or returns a mutable reference to the existing count. The *... += 1 expression increments whichever value the reference points to. The same pattern also works for word-frequency counting.

get returns Option<&usize>. Calling copied() turns that into Option<usize>, then unwrap_or(0) defines the count of a status that never appeared. This is an expected absence, so unwrap() would encode the wrong policy.

The first record gets the same treatment. endpoints[0] panics on an empty vector; endpoints.first() or endpoints.get(0) returns None. The two forms support different failure contracts.

4. UTF-8 and Byte Indexing

With Korean text in the input, it is easy to mistake String::len() for a character count. It returns the number of UTF-8 bytes. Rust also rejects integer string indexing such as name[0]: an arbitrary byte offset may land in the middle of a UTF-8 code point. Byte ranges used for slicing must fall on UTF-8 boundaries; an invalid boundary panics.

The example uses a character iterator instead:

let preview: String = "결제 API".chars().take(4).collect();
assert_eq!(preview, "결제 A");

That preserves valid UTF-8 boundaries. One caveat matters: an item from chars() is a Unicode scalar value, not necessarily one user-perceived character. Combining marks and some emoji contain multiple scalar values. If a UI must truncate by grapheme cluster, use a Unicode segmentation library rather than claiming that chars() solves that larger problem. The standard-library example makes no such promise.

5. Run the Standalone Crate

You only need a Rust toolchain with Rust 2024 edition support. The example has no third-party dependencies and does not rely on this series' earlier installments. Start in any directory where you want to create the project, then run:

mkdir -p rust-collections-demo/src
cd rust-collections-demo

The second command makes rust-collections-demo the starting directory for every later command. Create the following three files exactly as shown.

Cargo.toml:

[package]
name = "article-05-vec-string-hashmap"
version = "0.1.0"
edition = "2024"
publish = false

[dependencies]

src/lib.rs:

use std::collections::HashMap;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EndpointRecord {
    pub name: String,
    pub url: String,
    pub status: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EndpointSummary {
    pub total: usize,
    pub first_name_preview: Option<String>,
    pub status_counts: HashMap<String, usize>,
}

#[must_use]
pub fn parse_endpoints(input: &str) -> Vec<EndpointRecord> {
    input.lines().filter_map(parse_line).collect()
}

fn parse_line(line: &str) -> Option<EndpointRecord> {
    let fields: Vec<&str> = line.split('|').map(str::trim).collect();
    let [name, url, status] = fields.as_slice() else {
        return None;
    };

    if name.is_empty() || url.is_empty() || status.is_empty() {
        return None;
    }

    Some(EndpointRecord {
        name: (*name).to_owned(),
        url: (*url).to_owned(),
        status: (*status).to_owned(),
    })
}

#[must_use]
pub fn summarize(endpoints: &[EndpointRecord]) -> EndpointSummary {
    let mut status_counts = HashMap::new();
    for endpoint in endpoints {
        *status_counts.entry(endpoint.status.clone()).or_insert(0) += 1;
    }

    let first_name_preview = endpoints
        .first()
        .map(|endpoint| endpoint.name.chars().take(4).collect());

    EndpointSummary {
        total: endpoints.len(),
        first_name_preview,
        status_counts,
    }
}

impl EndpointSummary {
    #[must_use]
    pub fn count_for(&self, status: &str) -> usize {
        self.status_counts.get(status).copied().unwrap_or(0)
    }
}

#[cfg(test)]
mod tests {
    use super::{parse_endpoints, summarize};

    const INPUT: &str = "결제 API|https://pay.example.com/health|ok\n\
                         검색 🔎|https://search.example.com/health|error\n\
                         malformed line\n\
                         문서 API|https://docs.example.com/health|ok";

    #[test]
    fn parses_only_complete_records() {
        assert_eq!(parse_endpoints(INPUT).len(), 3);
    }

    #[test]
    fn rejects_extra_fields() {
        assert!(parse_endpoints("name|https://example.com|ok|extra").is_empty());
    }

    #[test]
    fn rejects_empty_fields() {
        assert!(parse_endpoints("name||ok").is_empty());
    }

    #[test]
    fn preserves_input_order() {
        let endpoints = parse_endpoints(INPUT);
        assert_eq!(
            endpoints.first().map(|item| item.name.as_str()),
            Some("결제 API")
        );
        assert_eq!(
            endpoints.get(1).map(|item| item.name.as_str()),
            Some("검색 🔎")
        );
    }

    #[test]
    fn stores_owned_strings() {
        let endpoints = {
            let input = String::from("docs|https://docs.example.com|ok");
            parse_endpoints(&input)
        };
        assert_eq!(endpoints[0].name, "docs");
    }

    #[test]
    fn counts_each_status() {
        let summary = summarize(&parse_endpoints(INPUT));
        assert_eq!(summary.total, 3);
        assert_eq!(summary.count_for("ok"), 2);
        assert_eq!(summary.count_for("error"), 1);
    }

    #[test]
    fn missing_status_has_zero_count() {
        assert_eq!(summarize(&parse_endpoints(INPUT)).count_for("unknown"), 0);
    }

    #[test]
    fn empty_input_has_no_first_endpoint() {
        let summary = summarize(&parse_endpoints(""));
        assert_eq!(summary.total, 0);
        assert_eq!(summary.first_name_preview, None);
    }

    #[test]
    fn preview_uses_unicode_scalar_boundaries() {
        let summary = summarize(&parse_endpoints(INPUT));
        assert_eq!(summary.first_name_preview.as_deref(), Some("결제 A"));
    }
}

src/main.rs:

use article_05_vec_string_hashmap::{parse_endpoints, summarize};

fn main() {
    let input = "결제 API|https://pay.example.com/health|ok\n\
                 검색 🔎|https://search.example.com/health|error\n\
                 문서 API|https://docs.example.com/health|ok";

    let summary = summarize(&parse_endpoints(input));

    println!("endpoints: {}", summary.total);
    println!("ok: {}", summary.count_for("ok"));
    println!("error: {}", summary.count_for("error"));
    println!(
        "first preview: {}",
        summary.first_name_preview.as_deref().unwrap_or("없음")
    );
}

Still in rust-collections-demo, format, lint, test, and run the crate:

cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet

The program printed:

endpoints: 3
ok: 2
error: 1
first preview: 결제 A

The nine tests cover incomplete and extra fields, empty fields, input order, owned strings, status counts, a missing status, empty input, and the Korean preview. With Rust 1.98.1, the format, Clippy, test, and run commands should complete without warnings.

The working rule is compact. Use Vec for an ordered set of records, String for UTF-8 text a record must own, and HashMap for keyed aggregation. At input boundaries, reach for patterns, first, and get before unchecked indexing. For text positions, decide whether you mean bytes, Unicode scalar values, or grapheme clusters before writing the code.

Full source code

The complete runnable source for this article is available in the Chapter 05 project on GitHub.

Sources


One response

  1. […] Previous articlePractical Rust Collections: Vec, String, and HashMap […]

Leave a Reply

Your email address will not be published. Required fields are marked *

Tech Wiki

Built with WordPress · Learn in public.