CSV Stream Transformer
Problem Statement
Build a high-performance CSV transformation pipeline that processes large CSV files (potentially larger than RAM) using iterator patterns. Your transformer should read, validate, filter, transform, and aggregate CSV data without loading entire files into memory.
Your CSV transformer should support:
- Streaming CSV parsing with configurable delimiters and quotes
- Type-safe column extraction and validation
- Filtering rows based on column values
- Transforming columns (type conversion, string manipulation, computed fields)
- Aggregations (sum, count, group-by) with constant memory
- Writing transformed results to output CSV files
Example CSV data:
timestamp,user_id,action,amount,status
2024-12-01T10:00:00,1001,purchase,299.99,completed
2024-12-01T10:01:30,1002,refund,49.99,pending
2024-12-01T10:03:15,1001,purchase,159.50,completed
Key Concepts Explained
This project demonstrates advanced Rust patterns for processing large datasets efficiently. Understanding these concepts will help you build scalable, memory-efficient data pipelines.
1. Streaming vs Loading: The Memory Problem
The Problem: Traditional CSV parsing loads the entire file into memory:
#![allow(unused)]
fn main() {
// ❌ Memory disaster for large files
fn parse_csv_bad(path: &str) -> Vec<Vec<String>> {
let contents = std::fs::read_to_string(path).unwrap(); // Loads ENTIRE file
contents.lines()
.map(|line| line.split(',').map(String::from).collect())
.collect()
}
// For a 10GB CSV: Uses 10GB+ RAM, crashes on limited memory systems
}
The Solution: Streaming with iterators processes one record at a time:
#![allow(unused)]
fn main() {
// ✅ Constant memory usage, any file size
fn parse_csv_stream(path: &Path) -> impl Iterator<Item = CsvRecord> {
BufReader::new(File::open(path).unwrap())
.lines()
.filter_map(|line| CsvRecord::parse_csv_line(&line.unwrap(), ',').ok())
}
// For a 10GB CSV: Uses ~8KB buffer (BufReader default), independent of file size
}
Key insight:
- Loading: O(n) memory where n = file size → OOM for large files
- Streaming: O(1) memory (constant buffer size) → handles any file size
2. Iterator Trait: The Foundation of Streaming
The Iterator trait is Rust’s abstraction for sequences of values:
#![allow(unused)]
fn main() {
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
}
Why it matters:
- Lazy evaluation: Values are computed on-demand, not upfront
- Composability: Chain operations without intermediate allocations
- Memory efficiency: Only current item needs to exist
Example - Iterator vs Vec:
#![allow(unused)]
fn main() {
// Allocates intermediate vectors at each step
let result: Vec<_> = records.into_iter()
.filter(|r| r.is_valid()) // ❌ Allocates filtered Vec
.map(|r| r.transform()) // ❌ Allocates transformed Vec
.collect(); // ❌ Final Vec
// Zero intermediate allocations - all operations fuse into single pass
let result: Vec<_> = records.into_iter()
.filter(|r| r.is_valid()) // ✅ No allocation, just iterator adapter
.map(|r| r.transform()) // ✅ No allocation, just iterator adapter
.collect(); // ✅ Only one allocation for final result
}
3. BufReader: Efficient File I/O
BufReader adds buffering to reduce system calls:
#![allow(unused)]
fn main() {
// Without BufReader: 1 syscall per byte = SLOW
let file = File::open(path)?;
for byte in file.bytes() { // Each byte requires OS call
process(byte); // Millions of syscalls for large files
}
// With BufReader: 1 syscall per 8KB chunk = FAST
let file = File::open(path)?;
let reader = BufReader::new(file); // 8KB internal buffer
for line in reader.lines() { // Reads in chunks, not bytes
process(line); // ~1000x fewer syscalls
}
}
Performance impact:
- Without buffering: ~1,000,000 syscalls for 1MB file
- With buffering: ~128 syscalls for 1MB file (8KB buffer)
- ~7800x reduction in syscalls → massive speedup
4. Trait-Based Type Conversion
The FromCsvField trait enables type-safe extraction:
#![allow(unused)]
fn main() {
pub trait FromCsvField: Sized {
fn from_csv_field(field: &str) -> Result<Self, ConversionError>;
}
// Implement for various types
impl FromCsvField for i64 { /* parse integer */ }
impl FromCsvField for f64 { /* parse float */ }
impl FromCsvField for bool { /* parse boolean */ }
// Generic extraction method
impl CsvRecord {
pub fn get_typed<T: FromCsvField>(&self, index: usize) -> Result<T, ConversionError> {
let field = self.get_field(index)?;
T::from_csv_field(field) // Dispatch to trait implementation
}
}
}
Benefits:
- Type safety: Compiler catches type mismatches
- Extensibility: Add new types without modifying core code
- Error handling: Explicit validation with
Result
Example usage:
#![allow(unused)]
fn main() {
let record = CsvRecord::parse_csv_line("Alice,30,95.5", ',')?;
let name: String = record.get_typed(0)?; // Calls FromCsvField for String
let age: i64 = record.get_typed(1)?; // Calls FromCsvField for i64
let score: f64 = record.get_typed(2)?; // Calls FromCsvField for f64
}
5. Iterator Adapters and Combinators
Iterator adapters transform iterators without consuming them:
#![allow(unused)]
fn main() {
// Each method returns a NEW iterator, original unchanged
let iter = records.into_iter()
.filter(|r| r.status == "completed") // FilterIterator
.map(|r| r.amount) // MapIterator
.skip(10) // SkipIterator
.take(100); // TakeIterator
// Nothing executed yet! (lazy evaluation)
// Only when we consume:
let sum: f64 = iter.sum(); // NOW it processes records
}
Common combinators:
filter(predicate)- Keep items matching predicatemap(function)- Transform each itemfilter_map(function)- Combined filter + mapfold(init, function)- Reduce to single valueskip(n)/take(n)- Skip/take n itemscollect()- Consume into collection
Key property: All adapt operations are zero-cost - the compiler fuses them into a single loop.
6. Streaming Aggregation with Fold
fold() enables aggregation without storing all records:
#![allow(unused)]
fn main() {
// ❌ Collects all records into memory first
let records: Vec<CsvRecord> = iterator.collect();
let sum = records.iter().map(|r| r.amount).sum();
// ✅ Aggregates while streaming, constant memory
let sum = iterator.fold(0.0, |acc, record| acc + record.amount);
}
Example - Computing statistics:
#![allow(unused)]
fn main() {
#[derive(Default)]
struct Stats {
count: usize,
sum: f64,
min: f64,
max: f64,
}
// Process 1 billion records with ~32 bytes of state
let stats = records.into_iter().fold(Stats::default(), |mut stats, record| {
stats.count += 1;
stats.sum += record.value;
stats.min = stats.min.min(record.value);
stats.max = stats.max.max(record.value);
stats
});
// Memory usage: O(1) regardless of record count
}
7. Extension Traits for API Design
Extension traits add methods to types you don’t own:
#![allow(unused)]
fn main() {
// Can't add methods directly to Iterator (defined in std)
// Solution: Extension trait
pub trait CsvFilterExt: Iterator<Item = Result<CsvRecord, Error>> + Sized {
fn filter_by_column<F>(self, column: usize, predicate: F) -> FilterByColumn<Self, F>
where
F: FnMut(&str) -> bool
{
FilterByColumn { iter: self, column, predicate }
}
}
// Implement for ALL iterators yielding CSV results
impl<I> CsvFilterExt for I where I: Iterator<Item = Result<CsvRecord, Error>> {}
// Now any CSV iterator gets the method
let filtered = csv_iterator
.filter_by_column(0, |status| status == "completed");
}
Pattern: Define trait with default implementations + blanket impl = methods for all matching types.
8. Custom Iterator Implementation
Implementing Iterator makes your type usable with all iterator methods:
#![allow(unused)]
fn main() {
pub struct CsvFileIterator {
reader: BufReader<File>,
delimiter: char,
line_number: usize,
}
impl Iterator for CsvFileIterator {
type Item = Result<CsvRecord, Error>;
fn next(&mut self) -> Option<Self::Item> {
let mut line = String::new();
loop {
line.clear();
match self.reader.read_line(&mut line) {
Ok(0) => return None, // EOF
Ok(_) => {
self.line_number += 1;
match CsvRecord::parse_csv_line(&line.trim(), self.delimiter) {
Ok(record) => return Some(Ok(record)),
Err(ParseError::EmptyLine) => continue, // Skip empties
Err(e) => return Some(Err(Error::Parse(e, self.line_number))),
}
}
Err(e) => return Some(Err(Error::Io(e))),
}
}
}
}
// Now gets ALL iterator methods for free!
csv_iterator.skip(1).filter(...).map(...).collect()
}
9. Error Handling in Iterators
Iterators can yield Result to propagate errors:
#![allow(unused)]
fn main() {
// Iterator yielding Results
let iterator: impl Iterator<Item = Result<CsvRecord, Error>> = csv_iterator;
// Pattern 1: Collect with early return
let records: Vec<CsvRecord> = iterator.collect::<Result<Vec<_>, _>>()?;
// Pattern 2: Filter out errors (risky - silently drops)
let valid_records: Vec<CsvRecord> = iterator.filter_map(Result::ok).collect();
// Pattern 3: Separate valid and invalid
let (valid, errors): (Vec<_>, Vec<_>) = iterator.partition(Result::is_ok);
// Pattern 4: Handle each error
for result in iterator {
match result {
Ok(record) => process(record),
Err(e) => log_error(e), // Don't stop on errors
}
}
}
10. Parallel Processing with Rayon
Rayon adds data parallelism with minimal code changes:
#![allow(unused)]
fn main() {
use rayon::prelude::*;
// Sequential: uses 1 core
let sum: f64 = records.iter()
.map(|r| r.amount)
.sum();
// Parallel: uses all cores
let sum: f64 = records.par_iter() // Just add par_
.map(|r| r.amount)
.sum();
}
How it works:
- Work stealing: Idle threads steal work from busy threads
- Divide and conquer: Data split into chunks, processed in parallel
- Automatic merging: Results combined with associative operations
Performance characteristics:
- Overhead: ~1-10μs per parallel operation
- Worth it when: Work per item > ~1μs (parsing, validation, complex transforms)
- Not worth it when: Simple operations like arithmetic (too fast)
Example - CSV parallel aggregation:
#![allow(unused)]
fn main() {
// Split file into chunks at record boundaries
let chunk_size = file_size / num_cores;
let chunks = split_into_chunks(file, chunk_size);
// Process each chunk in parallel
let partial_results: Vec<Stats> = chunks.par_iter()
.map(|chunk| aggregate_chunk(chunk))
.collect();
// Merge results (sequential, but tiny compared to processing)
let final_stats = partial_results.into_iter()
.fold(Stats::default(), |a, b| a.merge(b));
}
11. Generic Programming with Trait Bounds
Trait bounds enable generic functions that work with any type meeting constraints:
#![allow(unused)]
fn main() {
// Generic over any iterator yielding CsvRecord
pub fn aggregate<I>(records: I, column: usize) -> Stats
where
I: Iterator<Item = CsvRecord> // Trait bound
{
records.fold(Stats::default(), |mut stats, record| {
if let Ok(value) = record.get_typed::<f64>(column) {
stats.update(value);
}
stats
})
}
// Works with any iterator!
let stats1 = aggregate(csv_file_iterator, 2);
let stats2 = aggregate(vec_of_records.into_iter(), 2);
let stats3 = aggregate(filtered_records.map(...), 2);
}
12. Builder Pattern for Configuration
Configure complex types incrementally:
#![allow(unused)]
fn main() {
pub struct CsvParser {
delimiter: char,
has_header: bool,
skip_empty: bool,
}
impl CsvParser {
pub fn new() -> Self {
Self { delimiter: ',', has_header: true, skip_empty: true }
}
pub fn delimiter(mut self, d: char) -> Self {
self.delimiter = d;
self // Return self for chaining
}
pub fn no_header(mut self) -> Self {
self.has_header = false;
self
}
}
// Usage: method chaining
let parser = CsvParser::new()
.delimiter('|')
.no_header()
.parse_file("data.csv")?;
}
Connection to This Project
Here’s how each concept maps to the specific milestones in this project:
Milestone 1: Basic CSV Record Parser
Concepts applied:
- String parsing: Manual character-by-character parsing for quoted fields
- State machines: Tracking
in_quotesstate to handle delimiters inside quotes - Error handling:
Result<CsvRecord, ParseError>for validation
Why this matters: The CSV format is deceptively complex - fields can contain delimiters and newlines when quoted. Your parser must handle:
"Smith, John","123 Main St, Apt 4",42
The commas inside quotes should NOT split fields. This requires stateful parsing, not simple split(',').
Real-world impact: Incorrect quote handling causes data corruption in production systems. A parser that treats "Smith, John" as two fields will misalign all subsequent columns.
Milestone 2: Streaming CSV File Iterator
Concepts applied:
- BufReader: 8KB buffered reads instead of byte-by-byte I/O
- Custom Iterator: Implementing
IteratorforCsvFileIterator - Lazy evaluation: Records parsed only when
.next()is called - Error propagation:
Item = Result<CsvRecord, Error>to handle I/O and parse errors
Why this matters: File size independence. Your iterator uses O(1) memory regardless of file size:
#![allow(unused)]
fn main() {
// This works identically for 1KB or 100GB files
for record in CsvFileIterator::new(path, ',')? {
process(record?); // Only current record in memory
}
}
Real-world impact: Without streaming, a 5GB CSV file would:
- Require 5GB+ RAM (OOM crash on 4GB systems)
- Take 30+ seconds just to load before processing starts
- Block other operations until fully loaded
With streaming:
- Uses ~8KB RAM (BufReader buffer)
- Processing starts immediately (first record in ~1ms)
- Can process on memory-constrained devices
Performance metrics:
- Memory: 5,000,000KB → 8KB (625,000x reduction)
- Time to first record: 30s → 1ms (30,000x faster start)
Milestone 3: Type-Safe Column Extraction
Concepts applied:
- Trait-based dispatch:
FromCsvFieldtrait for type conversions - Generic methods:
get_typed<T>()works for anyT: FromCsvField - Type safety: Compiler prevents using wrong types
- Validation: Conversion errors caught and reported
Why this matters: CSV files store everything as text. Without type safety:
#![allow(unused)]
fn main() {
// ❌ Runtime panic if column isn't a number
let age: i64 = record.get_field(1).unwrap().parse().unwrap();
// ✅ Compile-time type checking + graceful error handling
let age: i64 = record.get_typed(1)?; // Returns Result
}
Real-world impact: A production system processing financial transactions:
- Without type safety: Invalid amount “$1,234.56” parsed as string, concatenated instead of summed → silent data corruption
- With type safety: Parsing fails immediately, transaction rejected, human alerted
Example failure mode:
#![allow(unused)]
fn main() {
// Data: "user123,invalid_age,100.50"
let age: i64 = record.get_typed(1)?; // Returns Err(ParseInt)
// Program can: log error, skip row, use default, or abort
// Instead of: panic, corrupt data, or wrong results
}
Milestone 4: Filter and Transform Pipeline
Concepts applied:
- Extension traits:
CsvFilterExtadds domain-specific methods - Iterator adapters:
FilterByColumn,MapColumnfor zero-copy transformations - Lazy evaluation: Filters applied during iteration, not upfront
- Zero-cost abstraction: No performance penalty vs hand-written loops
Why this matters: Composability without performance loss:
#![allow(unused)]
fn main() {
// Looks high-level, compiles to efficient machine code
let result = csv_iterator
.filter_by_column(0, |status| status == "completed") // Lazy
.filter_valid() // Lazy
.map_column(2, |amount| amount.trim()) // Lazy
.collect(); // Now executes
// Equivalent to hand-written loop, but readable and maintainable
}
Real-world impact: Processing 10M row CSV, keeping only rows where column 0 = “active”:
- Naive approach: Load all 10M rows → filter → 500MB intermediate Vec
- Streaming approach: Process row-by-row → no intermediate storage → 8KB buffer
Memory comparison:
- Naive: 10,000,000 rows × 50 bytes/row = 500MB
- Streaming: 1 row × 50 bytes = 50 bytes (10,000,000x less)
Speed comparison (10M rows, 30% pass filter):
- Naive collect → filter: ~8 seconds (500MB allocation + copy)
- Streaming filter: ~2 seconds (no allocation, cache-friendly)
Milestone 5: Streaming Aggregations
Concepts applied:
- Fold pattern: Reduce entire dataset to summary statistics
- Constant memory: O(1) space complexity regardless of row count
- Incremental computation: Update stats as records stream
- Associative operations: Merge partial results from different sources
Why this matters: Computing statistics without loading data:
#![allow(unused)]
fn main() {
// Process 1 billion rows, use 32 bytes of memory
let stats = records.fold(CsvAggregator::new(), |mut agg, record| {
agg.update(record.get_typed(2)?); // Only aggregator in memory
agg
});
}
Real-world impact: Analyzing web server logs (1TB, 10 billion requests):
- Without streaming: Impossible (1TB won’t fit in RAM)
- With streaming:
- Memory: 32 bytes (count, sum, min, max)
- Time: ~30 minutes (single-threaded)
- Result: Request statistics across entire dataset
Group-by aggregations:
#![allow(unused)]
fn main() {
// Group by user_id, aggregate amounts
// Memory: O(unique_users) not O(total_rows)
let grouped = records.fold(GroupedAggregator::new(), |mut agg, record| {
let user = record.get_typed::<String>(0)?;
let amount = record.get_typed::<f64>(1)?;
agg.update(user, amount); // Only unique users in memory
agg
});
}
Memory scaling:
- Total rows: 1 billion
- Unique users: 1 million
- Memory without grouping: 1 billion × 100 bytes = 100GB
- Memory with streaming group-by: 1 million × 64 bytes = 64MB (1,562x reduction)
Milestone 6: Parallel CSV Processing
Concepts applied:
- Data parallelism: Rayon distributes work across CPU cores
- Work stealing: Automatic load balancing
- Chunk-based processing: Split file at record boundaries
- Merge pattern: Combine partial results from parallel workers
Why this matters: Multi-core speedup for CPU-bound operations:
#![allow(unused)]
fn main() {
// Sequential: Uses 1 of 8 cores
let stats = sequential_aggregate(records); // 40 seconds
// Parallel: Uses all 8 cores
let stats = parallel_aggregate(records, 8); // 5 seconds (8x speedup)
}
Real-world impact: Processing daily transaction log (10GB, 100M records):
- Sequential:
- 1 core @ 2.5M records/sec = 40 seconds
- CPU utilization: 12.5% (1 of 8 cores)
- Parallel (8 cores):
- 8 cores @ 2.5M records/sec each = 5 seconds
- CPU utilization: 100%
- Speedup: 8x
When parallelism helps:
- ✅ CSV parsing: ~500ns/record → 8x speedup
- ✅ Data validation: ~1μs/record → 7.5x speedup
- ✅ Complex transforms: ~10μs/record → 7.9x speedup
- ❌ Simple arithmetic: ~10ns/record → 2x speedup (overhead dominates)
Chunking strategy:
#![allow(unused)]
fn main() {
// Must split at newlines, not arbitrary byte offsets
// Wrong: file_size / num_cores (might split mid-record)
// Right: Find newline boundaries for each chunk
let chunk_size = file_size / num_cores;
let chunks = (0..num_cores).map(|i| {
let start = i * chunk_size;
let end = seek_to_newline(start + chunk_size); // Align to record boundary
(start, end)
});
}
Scaling efficiency (8-core system, 10GB CSV):
- 1 thread: 40s (baseline)
- 2 threads: 20s (2.0x speedup, 100% efficiency)
- 4 threads: 10s (4.0x speedup, 100% efficiency)
- 8 threads: 5s (8.0x speedup, 100% efficiency)
- 16 threads: 5s (8.0x speedup, 50% efficiency - more threads than cores)
Memory considerations:
- Sequential: 8KB buffer
- Parallel (8 workers): 8 × 8KB = 64KB buffers + per-worker aggregators
- Still O(1) relative to file size
Project-Wide Benefits
By combining these concepts, your CSV processor achieves:
- Scalability: Handles 1KB to 1TB files identically
- Performance: 8x faster with parallelism, zero unnecessary allocations
- Memory efficiency: O(1) memory usage regardless of file size
- Type safety: Compile-time guarantees prevent runtime errors
- Composability: Build complex pipelines from simple operations
- Maintainability: High-level code that compiles to efficient machine code
Production-ready characteristics:
- ✅ Handles malformed input gracefully (error propagation)
- ✅ Processes files larger than RAM (streaming)
- ✅ Maximizes hardware utilization (multi-core)
- ✅ Prevents silent data corruption (type safety)
- ✅ Minimal memory footprint (suitable for containers/edge devices)
- ✅ Near-optimal performance (zero-cost abstractions)
Build The Project
Milestone 1: Basic CSV Record Parser
Goal: Create a CSV record parser that handles quoted fields and escapes.
What to implement:
- Define
CsvRecordstruct representing a parsed CSV row - Implement
parse_csv_line(line: &str, delimiter: char) -> Result<CsvRecord, ParseError> - Handle quoted fields with embedded delimiters and quotes
- Support configurable field delimiter (comma, tab, pipe)
Architecture:
- Structs:
CsvRecord,ParseError - Fields (CsvRecord):
fields: Vec<String> - Functions:
parse_csv_line(line: &str, delimiter: char) -> Result<CsvRecord, ParseError>- Parse single CSV lineget_field(&self, index: usize) -> Option<&str>- Get field by indexfield_count(&self) -> usize- Count fields
Starter Code:
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq)]
pub struct CsvRecord {
fields: Vec<String>,
}
#[derive(Debug, PartialEq)]
pub enum ParseError {
UnterminatedQuote,
InvalidEscape,
EmptyLine,
}
impl CsvRecord {
/// Parse a CSV line with respect to quotes and delimiters
/// Role: Handle quoted fields that may contain delimiters
pub fn parse_csv_line(line: &str, delimiter: char) -> Result<Self, ParseError> {
todo!("Implement CSV parsing with quote handling")
}
/// Get field value by column index
/// Role: Safe field access
pub fn get_field(&self, index: usize) -> Option<&str> {
todo!("Return field at index")
}
/// Return the number of fields in this record
/// Role: Query record structure
pub fn field_count(&self) -> usize {
todo!("Return field count")
}
}
}
Checkpoint Tests:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_simple_csv_parsing() {
let record = CsvRecord::parse_csv_line("foo,bar,baz", ',').unwrap();
assert_eq!(record.field_count(), 3);
assert_eq!(record.get_field(0), Some("foo"));
assert_eq!(record.get_field(1), Some("bar"));
assert_eq!(record.get_field(2), Some("baz"));
}
#[test]
fn test_quoted_fields() {
let record = CsvRecord::parse_csv_line(r#"foo,"bar,baz",qux"#, ',').unwrap();
assert_eq!(record.field_count(), 3);
assert_eq!(record.get_field(1), Some("bar,baz"));
}
#[test]
fn test_escaped_quotes() {
let record = CsvRecord::parse_csv_line(r#""foo ""bar"" baz""#, ',').unwrap();
assert_eq!(record.get_field(0), Some(r#"foo "bar" baz"#));
}
#[test]
fn test_empty_fields() {
let record = CsvRecord::parse_csv_line("foo,,bar", ',').unwrap();
assert_eq!(record.field_count(), 3);
assert_eq!(record.get_field(1), Some(""));
}
#[test]
fn test_custom_delimiter() {
let record = CsvRecord::parse_csv_line("foo|bar|baz", '|').unwrap();
assert_eq!(record.field_count(), 3);
}
}
}
Milestone 2: Streaming CSV File Iterator
Goal: Create an iterator that yields CSV records one at a time from a file.
Why the previous milestone is not enough: Milestone 1 parses individual lines, but we need to process entire files. Loading a multi-gigabyte CSV into memory causes OOM errors.
What’s the improvement: Using BufReader with iterator patterns enables streaming - only the current record occupies memory. This allows processing CSV files of any size with O(1) memory usage (constant overhead per record). A 10GB CSV file uses the same memory as a 10KB file.
Architecture:
- Structs:
CsvFileIterator - Fields:
reader: BufReader<File>,delimiter: char,line_number: usize - Functions:
new(path: &Path, delimiter: char) -> Result<Self, io::Error>- Open CSV filenext() -> Option<Result<CsvRecord, Error>>- Iterate records
Starter Code:
#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
/// Iterator over CSV records from a file
pub struct CsvFileIterator {
reader: BufReader<File>,
delimiter: char,
line_number: usize,
}
#[derive(Debug)]
pub enum Error {
Io(std::io::Error),
Parse(ParseError, usize), // error and line number
}
impl CsvFileIterator {
/// Create a new CSV file iterator
/// Role: Open file and prepare for streaming
pub fn new(path: &Path, delimiter: char) -> Result<Self, std::io::Error> {
todo!("Open file with BufReader")
}
}
impl Iterator for CsvFileIterator {
type Item = Result<CsvRecord, Error>;
/// Read and parse next CSV line
/// Role: Stream records without loading entire file
fn next(&mut self) -> Option<Self::Item> {
todo!("Read line, parse CSV, handle errors")
}
}
}
Checkpoint Tests:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn create_test_csv(content: &str) -> NamedTempFile {
let mut file = NamedTempFile::new().unwrap();
file.write_all(content.as_bytes()).unwrap();
file
}
#[test]
fn test_iterate_simple_csv() {
let file = create_test_csv("a,b,c\n1,2,3\n4,5,6");
let mut iter = CsvFileIterator::new(file.path(), ',').unwrap();
let record1 = iter.next().unwrap().unwrap();
assert_eq!(record1.get_field(0), Some("a"));
let record2 = iter.next().unwrap().unwrap();
assert_eq!(record2.get_field(0), Some("1"));
let record3 = iter.next().unwrap().unwrap();
assert_eq!(record3.get_field(0), Some("4"));
assert!(iter.next().is_none());
}
#[test]
fn test_skip_empty_lines() {
let file = create_test_csv("a,b\n\n1,2\n");
let records: Vec<_> = CsvFileIterator::new(file.path(), ',')
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert_eq!(records.len(), 2);
}
#[test]
fn test_error_reporting_with_line_numbers() {
let file = create_test_csv("a,b\n\"unterminated\n3,4");
let mut iter = CsvFileIterator::new(file.path(), ',').unwrap();
iter.next(); // Skip header
let err = iter.next().unwrap().unwrap_err();
match err {
Error::Parse(ParseError::UnterminatedQuote, line_num) => {
assert_eq!(line_num, 2);
}
_ => panic!("Expected parse error with line number"),
}
}
}
}
Milestone 3: Type-Safe Column Extraction
Goal: Extract and parse typed columns from CSV records.
Why the previous milestone is not enough: CsvRecord stores fields as strings. We need type-safe access (integers, floats, dates) with validation.
What’s the improvement: Implementing a trait-based column extraction system with FromCsvField enables type conversions with error handling. This prevents runtime panics from invalid type assumptions and makes data validation explicit. The type system catches schema mismatches at compile time when possible.
Architecture:
- Traits:
FromCsvField - Structs:
TypedRecord<T> - Functions:
get_typed<T: FromCsvField>(&self, index: usize) -> Result<T, ConversionError>- Parse field as type Textract<T>(&self) -> Result<T, ExtractionError>where T: FromCsvFields - Extract entire row into struct
Starter Code:
#![allow(unused)]
fn main() {
pub trait FromCsvField: Sized {
fn from_csv_field(field: &str) -> Result<Self, ConversionError>;
}
#[derive(Debug, PartialEq)]
pub enum ConversionError {
ParseInt(std::num::ParseIntError),
ParseFloat(std::num::ParseFloatError),
InvalidValue(String),
MissingField,
}
impl FromCsvField for String {
/// Role: Parse string into Self
fn from_csv_field(field: &str) -> Result<Self, ConversionError> {
Ok(field.to_string())
}
}
impl FromCsvField for i64 {
/// Role: Parse integer fields
fn from_csv_field(field: &str) -> Result<Self, ConversionError> {
todo!("Parse string as i64")
}
}
impl FromCsvField for f64 {
/// Role: Parse float fields
fn from_csv_field(field: &str) -> Result<Self, ConversionError> {
todo!("Parse string as f64")
}
}
impl FromCsvField for bool {
/// Role: Parse boolean fields (true/false, yes/no, 1/0)
fn from_csv_field(field: &str) -> Result<Self, ConversionError> {
todo!("Parse string as bool")
}
}
impl CsvRecord {
/// Get field as typed value
/// Role: Type-safe field extraction with validation
pub fn get_typed<T: FromCsvField>(&self, index: usize) -> Result<T, ConversionError> {
todo!("Get field and convert to T")
}
}
}
Checkpoint Tests:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_integers() {
let record = CsvRecord::parse_csv_line("100,200,300", ',').unwrap();
assert_eq!(record.get_typed::<i64>(0).unwrap(), 100);
assert_eq!(record.get_typed::<i64>(1).unwrap(), 200);
}
#[test]
fn test_extract_floats() {
let record = CsvRecord::parse_csv_line("3.14,2.71,1.41", ',').unwrap();
assert_eq!(record.get_typed::<f64>(0).unwrap(), 3.14);
}
#[test]
fn test_extract_booleans() {
let record = CsvRecord::parse_csv_line("true,false,yes,no,1,0", ',').unwrap();
assert_eq!(record.get_typed::<bool>(0).unwrap(), true);
assert_eq!(record.get_typed::<bool>(1).unwrap(), false);
assert_eq!(record.get_typed::<bool>(2).unwrap(), true);
assert_eq!(record.get_typed::<bool>(3).unwrap(), false);
assert_eq!(record.get_typed::<bool>(4).unwrap(), true);
assert_eq!(record.get_typed::<bool>(5).unwrap(), false);
}
#[test]
fn test_conversion_errors() {
let record = CsvRecord::parse_csv_line("not_a_number,42", ',').unwrap();
assert!(record.get_typed::<i64>(0).is_err());
assert!(record.get_typed::<i64>(1).is_ok());
}
#[test]
fn test_missing_field_error() {
let record = CsvRecord::parse_csv_line("a,b", ',').unwrap();
assert!(matches!(
record.get_typed::<String>(5),
Err(ConversionError::MissingField)
));
}
}
}
Milestone 4: Filter and Transform Pipeline
Goal: Build composable filter and transform operations on CSV streams.
Why the previous milestone is not enough: We can read and parse CSV, but real-world use cases require filtering rows, transforming values, and computing derived columns.
What’s the improvement: Creating iterator adapters for filtering and mapping enables declarative pipelines. Filters compose without intermediate allocations - all operations fuse into a single pass. This is dramatically more efficient than creating intermediate vectors after each operation.
Optimization focus: Memory and speed through zero-allocation iterator composition.
Architecture:
- Traits:
CsvFilter,CsvTransform - Structs:
FilteredCsv,TransformedCsv,ComputedColumn - Functions:
filter<F>(predicate: F)- Filter rows based on predicatemap_column<F>(index: usize, f: F)- Transform specific columnadd_computed_column<F>(f: F)- Add computed field
Starter Code:
#![allow(unused)]
fn main() {
/// Extension trait for filtering CSV records
pub trait CsvFilterExt: Iterator<Item = Result<CsvRecord, Error>> + Sized {
/// Filter rows where column matches predicate
/// Role: Declarative row filtering
fn filter_by_column<F>(self, column: usize, predicate: F) -> FilterByColumn<Self, F>
where
F: FnMut(&str) -> bool;
/// Skip records that fail validation
/// Role: Filter out malformed data
fn filter_valid(self) -> FilterValid<Self>;
}
pub struct FilterByColumn<I, F> {
iter: I,
column: usize,
predicate: F,
}
impl<I, F> Iterator for FilterByColumn<I, F>
where
I: Iterator<Item = Result<CsvRecord, Error>>,
F: FnMut(&str) -> bool,
{
type Item = Result<CsvRecord, Error>;
/// Role: Apply filter lazily as records stream
fn next(&mut self) -> Option<Self::Item> {
todo!("Filter records based on column value")
}
}
pub struct FilterValid<I> {
iter: I,
}
impl<I> Iterator for FilterValid<I>
where
I: Iterator<Item = Result<CsvRecord, Error>>,
{
type Item = CsvRecord;
/// Role: Skip errors and yield only valid records
fn next(&mut self) -> Option<Self::Item> {
todo!("Skip error results, yield valid records")
}
}
/// Extension trait for transforming CSV records
///
/// Functions:
/// - map_column() - Transform values in specific column
/// - with_computed_column() - Add derived column
pub trait CsvTransformExt: Iterator<Item = CsvRecord> + Sized {
/// Transform a specific column in-place
/// Role: Modify column values
fn map_column<F>(self, column: usize, f: F) -> MapColumn<Self, F>
where
F: FnMut(&str) -> String;
}
pub struct MapColumn<I, F> {
iter: I,
column: usize,
mapper: F,
}
impl<I, F> Iterator for MapColumn<I, F>
where
I: Iterator<Item = CsvRecord>,
F: FnMut(&str) -> String,
{
type Item = CsvRecord;
/// Role: Transform column values on-the-fly
fn next(&mut self) -> Option<Self::Item> {
todo!("Apply transformation to specified column")
}
}
}
Checkpoint Tests:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_filter_by_column() {
let csv = "status,amount\ncompleted,100\npending,200\ncompleted,300";
let file = create_test_csv(csv);
let completed: Vec<_> = CsvFileIterator::new(file.path(), ',')
.unwrap()
.skip(1) // Skip header
.filter_by_column(0, |status| status == "completed")
.filter_valid()
.collect();
assert_eq!(completed.len(), 2);
assert_eq!(completed[0].get_field(1), Some("100"));
assert_eq!(completed[1].get_field(1), Some("300"));
}
#[test]
fn test_map_column_transformation() {
let csv = "name,age\nalice,30\nbob,25";
let file = create_test_csv(csv);
let uppercase: Vec<_> = CsvFileIterator::new(file.path(), ',')
.unwrap()
.filter_valid()
.map_column(0, |name| name.to_uppercase())
.collect();
assert_eq!(uppercase[1].get_field(0), Some("ALICE"));
assert_eq!(uppercase[2].get_field(0), Some("BOB"));
}
#[test]
fn test_chained_operations() {
let csv = "status,amount\ncompleted,100\npending,200\ncompleted,50\nfailed,75";
let file = create_test_csv(csv);
let result: Vec<_> = CsvFileIterator::new(file.path(), ',')
.unwrap()
.skip(1)
.filter_by_column(0, |s| s == "completed")
.filter_valid()
.collect();
assert_eq!(result.len(), 2);
}
}
}
Milestone 5: Streaming Aggregations
Goal: Compute aggregates over CSV streams without loading data into memory.
Why the previous milestone is not enough: We can filter and transform, but we need summary statistics. Collecting all records to compute aggregates defeats the purpose of streaming.
What’s the improvement: Streaming aggregation using .fold() maintains only summary statistics (counts, sums, min/max) rather than storing records. For a 10GB CSV with 100M rows, this uses ~1KB of memory instead of 10GB. This is the key to analyzing arbitrarily large datasets.
Optimization focus: Memory - O(1) aggregate storage instead of O(n) record storage.
Architecture:
- Structs:
CsvAggregator,GroupedAggregator - Fields:
count: usize,sum: f64,min: f64,max: f64,groups: HashMap<String, Stats> - Functions:
aggregate<F>(extractor: F)- Compute stats from columngroup_by<K, V>(key_fn: K, value_fn: V)- Group-by aggregation
Starter Code:
#![allow(unused)]
fn main() {
use std::collections::HashMap;
/// Aggregation statistics for numeric columns
#[derive(Debug, Clone)]
pub struct CsvAggregator {
count: usize,
sum: f64,
min: f64,
max: f64,
}
impl CsvAggregator {
/// Create a new aggregator
/// Role: Initialize aggregate state
pub fn new() -> Self {
todo!("Initialize with appropriate min/max defaults")
}
/// Update aggregator with a value
/// Role: Incrementally update statistics
pub fn update(&mut self, value: f64) {
todo!("Update count, sum, min, max")
}
/// Compute mean
/// Role: Calculate average
pub fn mean(&self) -> Option<f64> {
todo!("Return mean or None if count is 0")
}
/// Create aggregator from iterator
/// Role: Stream values into aggregate
pub fn from_column<I>(records: I, column: usize) -> Self
where
I: Iterator<Item = CsvRecord>,
{
todo!("Fold records into aggregator")
}
}
/// Grouped aggregations by key
pub struct GroupedAggregator<K> {
groups: HashMap<K, CsvAggregator>,
}
impl<K: Eq + std::hash::Hash> GroupedAggregator<K> {
/// Create grouped aggregator
/// Role: Initialize empty group map
pub fn new() -> Self {
todo!("Initialize empty HashMap")
}
/// Update with key-value pair
/// Role: Add value to appropriate group
pub fn update(&mut self, key: K, value: f64) {
todo!("Get or create group, update aggregator")
}
/// Get statistics for a group
/// Role: Query per-group stats
pub fn get(&self, key: &K) -> Option<&CsvAggregator> {
self.groups.get(key)
}
/// Create from iterator with key and value extractors
/// Role: Stream into grouped aggregates
pub fn from_records<I, KF, VF>(records: I, key_fn: KF, value_fn: VF) -> Self
where
I: Iterator<Item = CsvRecord>,
KF: Fn(&CsvRecord) -> K,
VF: Fn(&CsvRecord) -> Option<f64>,
{
todo!("Fold records into grouped aggregator")
}
}
}
Checkpoint Tests:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_aggregation() {
let csv = "value\n10\n20\n30\n40\n50";
let file = create_test_csv(csv);
let agg = CsvAggregator::from_column(
CsvFileIterator::new(file.path(), ',')
.unwrap()
.skip(1)
.filter_map(Result::ok),
0
);
assert_eq!(agg.count, 5);
assert_eq!(agg.sum, 150.0);
assert_eq!(agg.min, 10.0);
assert_eq!(agg.max, 50.0);
assert_eq!(agg.mean(), Some(30.0));
}
#[test]
fn test_grouped_aggregation() {
let csv = "category,amount\nA,100\nB,200\nA,150\nB,250\nA,50";
let file = create_test_csv(csv);
let grouped = GroupedAggregator::from_records(
CsvFileIterator::new(file.path(), ',')
.unwrap()
.skip(1)
.filter_map(Result::ok),
|rec| rec.get_field(0).unwrap().to_string(),
|rec| rec.get_typed::<f64>(1).ok()
);
let stats_a = grouped.get(&"A".to_string()).unwrap();
assert_eq!(stats_a.count, 3);
assert_eq!(stats_a.sum, 300.0);
let stats_b = grouped.get(&"B".to_string()).unwrap();
assert_eq!(stats_b.count, 2);
assert_eq!(stats_b.sum, 450.0);
}
#[test]
fn test_empty_aggregation() {
let agg = CsvAggregator::new();
assert_eq!(agg.mean(), None);
}
}
}
Milestone 6: Parallel CSV Processing with Rayon
Goal: Process large CSV files using multiple CPU cores for maximum throughput.
Why the previous milestone is not enough: Sequential processing uses only one core. For CPU-bound operations (parsing, validation, transformations), we’re leaving performance on the table.
What’s the improvement: Parallel processing with Rayon distributes work across all cores, providing near-linear speedup. For an 8-core system processing a 1GB CSV:
- Sequential: ~30 seconds
- Parallel: ~4 seconds (7.5x speedup)
This transforms “process overnight” batch jobs into “process in minutes” interactive workflows.
Optimization focus: Speed through parallelism - maximize CPU utilization.
Implementation note: CSV parsing must handle byte-level chunking (can’t split mid-record). Use parallel chunk processing where each chunk is guaranteed to contain complete records.
Architecture:
- Functions:
parallel_process_csv<F>(path, chunk_size, process_fn)- Process CSV in parallel chunksparallel_aggregate(path, column)- Parallel column aggregationparallel_group_by(path, key_col, value_col)- Parallel grouped aggregation
Starter Code:
#![allow(unused)]
fn main() {
use rayon::prelude::*;
use std::fs::File;
use std::io::{BufRead, BufReader, Seek, SeekFrom};
/// Parallel CSV processing utilities
/// Find byte offsets for chunks that align with record boundaries
/// Role: Split file into processable chunks without breaking records
fn find_chunk_boundaries(file: &mut File, num_chunks: usize) -> std::io::Result<Vec<u64>> {
todo!("Find newline-aligned chunk offsets")
}
/// Process CSV file in parallel chunks
/// Role: Distribute work across CPU cores
pub fn parallel_process_csv<F, R>(
path: &std::path::Path,
delimiter: char,
num_workers: usize,
process_chunk: F,
) -> std::io::Result<Vec<R>>
where
F: Fn(Vec<CsvRecord>) -> R + Send + Sync,
R: Send,
{
todo!("Split file, process chunks in parallel, collect results")
}
/// Parallel aggregation of numeric column
/// Role: Compute statistics using all CPU cores
pub fn parallel_aggregate_column(
path: &std::path::Path,
delimiter: char,
column: usize,
num_workers: usize,
) -> std::io::Result<CsvAggregator> {
todo!("Parallel fold into aggregators, then merge")
}
/// Parallel grouped aggregation
/// Role: Group-by with parallel processing
pub fn parallel_group_by<K>(
path: &std::path::Path,
delimiter: char,
key_column: usize,
value_column: usize,
num_workers: usize,
) -> std::io::Result<GroupedAggregator<K>>
where
K: Eq + std::hash::Hash + Send + Clone + FromCsvField,
{
todo!("Parallel group-by with merge")
}
impl CsvAggregator {
/// Merge two aggregators
/// Role: Combine partial results from parallel workers
pub fn merge(&mut self, other: CsvAggregator) {
todo!("Merge counts, sums, update min/max")
}
}
impl<K: Eq + std::hash::Hash> GroupedAggregator<K> {
/// Merge grouped aggregators
/// Role: Combine per-group results from workers
pub fn merge(&mut self, other: GroupedAggregator<K>) {
todo!("Merge each group's aggregator")
}
}
}
Checkpoint Tests:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn create_large_test_csv(rows: usize) -> NamedTempFile {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "category,value").unwrap();
for i in 0..rows {
let category = if i % 3 == 0 { "A" } else if i % 3 == 1 { "B" } else { "C" };
writeln!(file, "{},{}", category, i).unwrap();
}
file
}
#[test]
fn test_parallel_vs_sequential_correctness() {
let file = create_large_test_csv(1000);
// Sequential
let seq_agg = CsvAggregator::from_column(
CsvFileIterator::new(file.path(), ',')
.unwrap()
.skip(1)
.filter_map(Result::ok),
1
);
// Parallel
let par_agg = parallel_aggregate_column(file.path(), ',', 1, 4).unwrap();
// Results should match
assert_eq!(seq_agg.count, par_agg.count);
assert_eq!(seq_agg.sum, par_agg.sum);
assert_eq!(seq_agg.min, par_agg.min);
assert_eq!(seq_agg.max, par_agg.max);
}
#[test]
fn test_parallel_grouped_aggregation() {
let file = create_large_test_csv(900); // 300 of each category
let grouped = parallel_group_by::<String>(
file.path(),
',',
0, // key: category
1, // value: value column
4 // workers
).unwrap();
assert_eq!(grouped.get(&"A".to_string()).unwrap().count, 300);
assert_eq!(grouped.get(&"B".to_string()).unwrap().count, 300);
assert_eq!(grouped.get(&"C".to_string()).unwrap().count, 300);
}
#[test]
#[ignore] // Run with --ignored for benchmarking
fn benchmark_parallel_speedup() {
use std::time::Instant;
let file = create_large_test_csv(1_000_000);
// Sequential
let start = Instant::now();
let _ = CsvAggregator::from_column(
CsvFileIterator::new(file.path(), ',')
.unwrap()
.skip(1)
.filter_map(Result::ok),
1
);
let seq_time = start.elapsed();
// Parallel
let start = Instant::now();
let _ = parallel_aggregate_column(file.path(), ',', 1, 8).unwrap();
let par_time = start.elapsed();
println!("Sequential: {:?}", seq_time);
println!("Parallel (8 cores): {:?}", par_time);
println!("Speedup: {:.2}x", seq_time.as_secs_f64() / par_time.as_secs_f64());
assert!(par_time < seq_time);
}
}
}
Complete Working Example
#![allow(unused)]
fn main() {
use rayon::prelude::*;
use std::collections::HashMap;
use std::fs::File;
use std::hash::Hash;
use std::io::{self, BufRead, BufReader, Read, Seek, SeekFrom};
use std::path::Path;
use std::sync::Arc;
// =============================================================================
// Milestone 1: CSV record parsing
// =============================================================================
#[derive(Debug, Clone, PartialEq)]
pub struct CsvRecord {
fields: Vec<String>,
}
#[derive(Debug, PartialEq)]
pub enum ParseError {
UnterminatedQuote,
InvalidEscape,
EmptyLine,
}
impl CsvRecord {
pub fn parse_csv_line(line: &str, delimiter: char) -> Result<Self, ParseError> {
let trimmed_line = line.trim_end_matches(|c| c == '\n' || c == '\r');
if trimmed_line.trim().is_empty() {
return Err(ParseError::EmptyLine);
}
let mut fields = Vec::new();
let mut current = String::new();
let mut chars = trimmed_line.chars().peekable();
let mut in_quotes = false;
let mut field_started = false;
while let Some(ch) = chars.next() {
if in_quotes {
match ch {
'"' => {
if matches!(chars.peek(), Some('"')) {
chars.next();
current.push('"');
} else {
in_quotes = false;
field_started = true;
}
}
_ => current.push(ch),
}
} else {
match ch {
c if c == delimiter => {
fields.push(current.clone());
current.clear();
field_started = false;
}
'"' => {
if !field_started {
in_quotes = true;
} else {
return Err(ParseError::InvalidEscape);
}
}
_ => {
current.push(ch);
field_started = true;
}
}
}
}
if in_quotes {
return Err(ParseError::UnterminatedQuote);
}
fields.push(current);
Ok(CsvRecord { fields })
}
pub fn get_field(&self, index: usize) -> Option<&str> {
self.fields.get(index).map(|field| field.as_str())
}
pub fn field_count(&self) -> usize {
self.fields.len()
}
fn map_field<F>(&mut self, index: usize, mapper: F)
where
F: FnOnce(&str) -> String,
{
if let Some(field) = self.fields.get_mut(index) {
let new_value = mapper(field.as_str());
*field = new_value;
}
}
}
// =============================================================================
// Milestone 2: Streaming iterator over CSV files
// =============================================================================
pub struct CsvFileIterator {
reader: BufReader<File>,
delimiter: char,
line_number: usize,
}
#[derive(Debug)]
pub enum Error {
Io(std::io::Error),
Parse(ParseError, usize),
}
impl CsvFileIterator {
pub fn new(path: &Path, delimiter: char) -> Result<Self, std::io::Error> {
let file = File::open(path)?;
Ok(Self {
reader: BufReader::new(file),
delimiter,
line_number: 0,
})
}
}
impl Iterator for CsvFileIterator {
type Item = Result<CsvRecord, Error>;
fn next(&mut self) -> Option<Self::Item> {
let mut line = String::new();
loop {
line.clear();
match self.reader.read_line(&mut line) {
Ok(0) => return None,
Ok(_) => {
self.line_number += 1;
match CsvRecord::parse_csv_line(
line.trim_end_matches(|c| c == '\n' || c == '\r'),
self.delimiter,
) {
Ok(record) => return Some(Ok(record)),
Err(ParseError::EmptyLine) => continue,
Err(err) => return Some(Err(Error::Parse(err, self.line_number))),
}
}
Err(err) => return Some(Err(Error::Io(err))),
}
}
}
}
// =============================================================================
// Milestone 3: Type-safe column extraction
// =============================================================================
pub trait FromCsvField: Sized {
fn from_csv_field(field: &str) -> Result<Self, ConversionError>;
}
#[derive(Debug, PartialEq)]
pub enum ConversionError {
ParseInt(std::num::ParseIntError),
ParseFloat(std::num::ParseFloatError),
InvalidValue(String),
MissingField,
}
impl FromCsvField for String {
fn from_csv_field(field: &str) -> Result<Self, ConversionError> {
Ok(field.to_string())
}
}
impl FromCsvField for i64 {
fn from_csv_field(field: &str) -> Result<Self, ConversionError> {
field
.trim()
.parse::<i64>()
.map_err(ConversionError::ParseInt)
}
}
impl FromCsvField for f64 {
fn from_csv_field(field: &str) -> Result<Self, ConversionError> {
field
.trim()
.parse::<f64>()
.map_err(ConversionError::ParseFloat)
}
}
impl FromCsvField for bool {
fn from_csv_field(field: &str) -> Result<Self, ConversionError> {
match field.trim().to_lowercase().as_str() {
"true" | "yes" | "1" => Ok(true),
"false" | "no" | "0" => Ok(false),
_ => Err(ConversionError::InvalidValue(field.to_string())),
}
}
}
impl CsvRecord {
pub fn get_typed<T: FromCsvField>(&self, index: usize) -> Result<T, ConversionError> {
let field = self.get_field(index).ok_or(ConversionError::MissingField)?;
T::from_csv_field(field)
}
}
// =============================================================================
// Milestone 4: Filtering and transforming CSV streams
// =============================================================================
pub trait CsvFilterExt: Iterator<Item = Result<CsvRecord, Error>> + Sized {
fn filter_by_column<F>(self, column: usize, predicate: F) -> FilterByColumn<Self, F>
where
F: FnMut(&str) -> bool;
fn filter_valid(self) -> FilterValid<Self>;
}
impl<I> CsvFilterExt for I
where
I: Iterator<Item = Result<CsvRecord, Error>> + Sized,
{
fn filter_by_column<F>(self, column: usize, predicate: F) -> FilterByColumn<Self, F>
where
F: FnMut(&str) -> bool,
{
FilterByColumn {
iter: self,
column,
predicate,
}
}
fn filter_valid(self) -> FilterValid<Self> {
FilterValid { iter: self }
}
}
pub struct FilterByColumn<I, F> {
iter: I,
column: usize,
predicate: F,
}
impl<I, F> Iterator for FilterByColumn<I, F>
where
I: Iterator<Item = Result<CsvRecord, Error>>,
F: FnMut(&str) -> bool,
{
type Item = Result<CsvRecord, Error>;
fn next(&mut self) -> Option<Self::Item> {
while let Some(record) = self.iter.next() {
match record {
Ok(record) => {
if let Some(value) = record.get_field(self.column) {
if (self.predicate)(value) {
return Some(Ok(record));
}
}
}
Err(err) => return Some(Err(err)),
}
}
None
}
}
pub struct FilterValid<I> {
iter: I,
}
impl<I> Iterator for FilterValid<I>
where
I: Iterator<Item = Result<CsvRecord, Error>>,
{
type Item = CsvRecord;
fn next(&mut self) -> Option<Self::Item> {
while let Some(record) = self.iter.next() {
if let Ok(record) = record {
return Some(record);
}
}
None
}
}
pub trait CsvTransformExt: Iterator<Item = CsvRecord> + Sized {
fn map_column<F>(self, column: usize, f: F) -> MapColumn<Self, F>
where
F: FnMut(&str) -> String;
}
impl<I> CsvTransformExt for I
where
I: Iterator<Item = CsvRecord> + Sized,
{
fn map_column<F>(self, column: usize, mapper: F) -> MapColumn<Self, F>
where
F: FnMut(&str) -> String,
{
MapColumn {
iter: self,
column,
mapper,
}
}
}
pub struct MapColumn<I, F> {
iter: I,
column: usize,
mapper: F,
}
impl<I, F> Iterator for MapColumn<I, F>
where
I: Iterator<Item = CsvRecord>,
F: FnMut(&str) -> String,
{
type Item = CsvRecord;
fn next(&mut self) -> Option<Self::Item> {
let mut record = self.iter.next()?;
record.map_field(self.column, |value| (self.mapper)(value));
Some(record)
}
}
// =============================================================================
// Milestone 5: Streaming aggregations
// =============================================================================
#[derive(Debug, Clone)]
pub struct CsvAggregator {
pub count: usize,
pub sum: f64,
pub min: f64,
pub max: f64,
}
impl CsvAggregator {
pub fn new() -> Self {
Self {
count: 0,
sum: 0.0,
min: f64::INFINITY,
max: f64::NEG_INFINITY,
}
}
pub fn update(&mut self, value: f64) {
if self.count == 0 {
self.min = value;
self.max = value;
} else {
if value < self.min {
self.min = value;
}
if value > self.max {
self.max = value;
}
}
self.count += 1;
self.sum += value;
}
pub fn mean(&self) -> Option<f64> {
if self.count == 0 {
None
} else {
Some(self.sum / self.count as f64)
}
}
pub fn from_column<I>(records: I, column: usize) -> Self
where
I: Iterator<Item = CsvRecord>,
{
records.fold(CsvAggregator::new(), |mut agg, record| {
if let Ok(value) = record.get_typed::<f64>(column) {
agg.update(value);
}
agg
})
}
pub fn merge(&mut self, other: CsvAggregator) {
if other.count == 0 {
return;
}
if self.count == 0 {
*self = other;
return;
}
self.count += other.count;
self.sum += other.sum;
if other.min < self.min {
self.min = other.min;
}
if other.max > self.max {
self.max = other.max;
}
}
}
pub struct GroupedAggregator<K> {
groups: HashMap<K, CsvAggregator>,
}
impl<K: Eq + Hash> GroupedAggregator<K> {
pub fn new() -> Self {
Self {
groups: HashMap::new(),
}
}
pub fn update(&mut self, key: K, value: f64) {
self.groups
.entry(key)
.or_insert_with(CsvAggregator::new)
.update(value);
}
pub fn get(&self, key: &K) -> Option<&CsvAggregator> {
self.groups.get(key)
}
pub fn from_records<I, KF, VF>(records: I, key_fn: KF, value_fn: VF) -> Self
where
I: Iterator<Item = CsvRecord>,
KF: Fn(&CsvRecord) -> K,
VF: Fn(&CsvRecord) -> Option<f64>,
{
let mut agg = GroupedAggregator::new();
for record in records {
let key = key_fn(&record);
if let Some(value) = value_fn(&record) {
agg.update(key, value);
}
}
agg
}
}
impl<K: Eq + Hash + Clone> GroupedAggregator<K> {
pub fn merge(&mut self, other: GroupedAggregator<K>) {
for (key, stats) in other.groups {
self.groups
.entry(key)
.or_insert_with(CsvAggregator::new)
.merge(stats);
}
}
}
// =============================================================================
// Milestone 6: Parallel CSV processing
// =============================================================================
fn find_chunk_boundaries(file: &mut File, num_chunks: usize) -> io::Result<Vec<u64>> {
let file_size = file.metadata()?.len();
let num_chunks = num_chunks.max(1);
if file_size == 0 {
return Ok(vec![0, 0]);
}
let chunk_size = (file_size / num_chunks as u64).max(1);
let mut boundaries = vec![0];
let mut next = chunk_size;
while next < file_size {
file.seek(SeekFrom::Start(next))?;
let mut buf = [0u8; 1];
let mut pos = next;
loop {
match file.read(&mut buf) {
Ok(0) => {
pos = file_size;
break;
}
Ok(1) => {
pos += 1;
if buf[0] == b'\n' {
break;
}
}
Ok(_) => unreachable!(),
Err(err) => return Err(err),
}
if pos >= file_size {
pos = file_size;
break;
}
}
if pos >= file_size {
break;
}
boundaries.push(pos);
next = pos + chunk_size;
}
if *boundaries.last().unwrap() != file_size {
boundaries.push(file_size);
}
Ok(boundaries)
}
pub fn parallel_process_csv<F, R>(
path: &Path,
delimiter: char,
num_workers: usize,
process_chunk: F,
) -> io::Result<Vec<R>>
where
F: Fn(Vec<CsvRecord>) -> R + Send + Sync + 'static,
R: Send,
{
let num_workers = num_workers.max(1);
let path_buf = Arc::new(path.to_path_buf());
let mut file = File::open(&*path_buf)?;
let boundaries = find_chunk_boundaries(&mut file, num_workers)?;
let chunk_ranges: Vec<(u64, u64)> = boundaries
.windows(2)
.map(|window| (window[0], window[1]))
.collect();
let process_fn = Arc::new(process_chunk);
chunk_ranges
.into_par_iter()
.map({
let path_buf = Arc::clone(&path_buf);
let processor = Arc::clone(&process_fn);
move |(start, end)| -> io::Result<R> {
let mut chunk_file = File::open(&*path_buf)?;
chunk_file.seek(SeekFrom::Start(start))?;
let mut reader = BufReader::new(chunk_file);
let mut consumed = start;
let mut records = Vec::new();
let mut line = String::new();
while consumed < end {
line.clear();
let bytes_read = reader.read_line(&mut line)?;
if bytes_read == 0 {
break;
}
consumed += bytes_read as u64;
let trimmed = line.trim_end_matches(|c| c == '\n' || c == '\r');
if trimmed.trim().is_empty() {
continue;
}
match CsvRecord::parse_csv_line(trimmed, delimiter) {
Ok(record) => records.push(record),
Err(ParseError::EmptyLine) => continue,
Err(err) => {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("Failed to parse chunk at byte {}: {:?}", consumed, err),
))
}
}
}
Ok((*processor)(records))
}
})
.collect()
}
pub fn parallel_aggregate_column(
path: &Path,
delimiter: char,
column: usize,
num_workers: usize,
) -> io::Result<CsvAggregator> {
let aggregates = parallel_process_csv(path, delimiter, num_workers, move |records| {
records
.into_iter()
.fold(CsvAggregator::new(), |mut agg, record| {
if let Ok(value) = record.get_typed::<f64>(column) {
agg.update(value);
}
agg
})
})?;
let mut final_agg = CsvAggregator::new();
for agg in aggregates {
final_agg.merge(agg);
}
Ok(final_agg)
}
pub fn parallel_group_by<K>(
path: &Path,
delimiter: char,
key_column: usize,
value_column: usize,
num_workers: usize,
) -> io::Result<GroupedAggregator<K>>
where
K: Eq + Hash + Send + Clone + FromCsvField + 'static,
{
let grouped_results = parallel_process_csv(path, delimiter, num_workers, move |records| {
records
.into_iter()
.fold(GroupedAggregator::new(), |mut agg, record| {
if let (Ok(key), Ok(value)) = (
record.get_typed::<K>(key_column),
record.get_typed::<f64>(value_column),
) {
agg.update(key, value);
}
agg
})
})?;
let mut final_grouped = GroupedAggregator::new();
for grouped in grouped_results {
final_grouped.merge(grouped);
}
Ok(final_grouped)
}
// =============================================================================
// Tests
// =============================================================================
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn create_test_csv(content: &str) -> NamedTempFile {
let mut file = NamedTempFile::new().unwrap();
file.write_all(content.as_bytes()).unwrap();
file
}
fn create_large_test_csv(rows: usize) -> NamedTempFile {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "category,value").unwrap();
for i in 0..rows {
let category = match i % 3 {
0 => "A",
1 => "B",
_ => "C",
};
writeln!(file, "{},{}", category, i).unwrap();
}
file
}
#[test]
fn test_simple_csv_parsing() {
let record = CsvRecord::parse_csv_line("foo,bar,baz", ',').unwrap();
assert_eq!(record.field_count(), 3);
assert_eq!(record.get_field(0), Some("foo"));
assert_eq!(record.get_field(1), Some("bar"));
assert_eq!(record.get_field(2), Some("baz"));
}
#[test]
fn test_quoted_fields() {
let record = CsvRecord::parse_csv_line(r#"foo,"bar,baz",qux"#, ',').unwrap();
assert_eq!(record.field_count(), 3);
assert_eq!(record.get_field(1), Some("bar,baz"));
}
#[test]
fn test_escaped_quotes() {
let record = CsvRecord::parse_csv_line(r#""foo ""bar"" baz""#, ',').unwrap();
assert_eq!(record.get_field(0), Some(r#"foo "bar" baz"#));
}
#[test]
fn test_empty_fields() {
let record = CsvRecord::parse_csv_line("foo,,bar", ',').unwrap();
assert_eq!(record.field_count(), 3);
assert_eq!(record.get_field(1), Some(""));
}
#[test]
fn test_custom_delimiter() {
let record = CsvRecord::parse_csv_line("foo|bar|baz", '|').unwrap();
assert_eq!(record.field_count(), 3);
}
#[test]
fn test_iterate_simple_csv() {
let file = create_test_csv("a,b,c\n1,2,3\n4,5,6");
let mut iter = CsvFileIterator::new(file.path(), ',').unwrap();
let record1 = iter.next().unwrap().unwrap();
assert_eq!(record1.get_field(0), Some("a"));
let record2 = iter.next().unwrap().unwrap();
assert_eq!(record2.get_field(0), Some("1"));
let record3 = iter.next().unwrap().unwrap();
assert_eq!(record3.get_field(0), Some("4"));
assert!(iter.next().is_none());
}
#[test]
fn test_skip_empty_lines() {
let file = create_test_csv("a,b\n\n1,2\n");
let records: Vec<_> = CsvFileIterator::new(file.path(), ',')
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert_eq!(records.len(), 2);
}
#[test]
fn test_error_reporting_with_line_numbers() {
let file = create_test_csv("a,b\n\"unterminated\n3,4");
let mut iter = CsvFileIterator::new(file.path(), ',').unwrap();
iter.next();
let err = iter.next().unwrap().unwrap_err();
match err {
Error::Parse(ParseError::UnterminatedQuote, line) => assert_eq!(line, 2),
_ => panic!("Expected parse error"),
}
}
#[test]
fn test_extract_integers() {
let record = CsvRecord::parse_csv_line("100,200,300", ',').unwrap();
assert_eq!(record.get_typed::<i64>(0).unwrap(), 100);
assert_eq!(record.get_typed::<i64>(1).unwrap(), 200);
}
#[test]
fn test_extract_floats() {
let record = CsvRecord::parse_csv_line("3.14,2.71,1.41", ',').unwrap();
assert_eq!(record.get_typed::<f64>(0).unwrap(), 3.14);
}
#[test]
fn test_extract_booleans() {
let record = CsvRecord::parse_csv_line("true,false,yes,no,1,0", ',').unwrap();
assert_eq!(record.get_typed::<bool>(0).unwrap(), true);
assert_eq!(record.get_typed::<bool>(1).unwrap(), false);
assert_eq!(record.get_typed::<bool>(2).unwrap(), true);
assert_eq!(record.get_typed::<bool>(3).unwrap(), false);
assert_eq!(record.get_typed::<bool>(4).unwrap(), true);
assert_eq!(record.get_typed::<bool>(5).unwrap(), false);
}
#[test]
fn test_conversion_errors() {
let record = CsvRecord::parse_csv_line("not_a_number,42", ',').unwrap();
assert!(record.get_typed::<i64>(0).is_err());
assert!(record.get_typed::<i64>(1).is_ok());
}
#[test]
fn test_missing_field_error() {
let record = CsvRecord::parse_csv_line("a,b", ',').unwrap();
assert!(matches!(
record.get_typed::<String>(5),
Err(ConversionError::MissingField)
));
}
#[test]
fn test_filter_by_column() {
let csv = "status,amount\ncompleted,100\npending,200\ncompleted,300";
let file = create_test_csv(csv);
let completed: Vec<_> = CsvFileIterator::new(file.path(), ',')
.unwrap()
.skip(1)
.filter_by_column(0, |status| status == "completed")
.filter_valid()
.collect();
assert_eq!(completed.len(), 2);
assert_eq!(completed[0].get_field(1), Some("100"));
assert_eq!(completed[1].get_field(1), Some("300"));
}
#[test]
fn test_map_column_transformation() {
let csv = "name,age\nalice,30\nbob,25";
let file = create_test_csv(csv);
let uppercase: Vec<_> = CsvFileIterator::new(file.path(), ',')
.unwrap()
.filter_valid()
.map_column(0, |name| name.to_uppercase())
.collect();
assert_eq!(uppercase[1].get_field(0), Some("ALICE"));
assert_eq!(uppercase[2].get_field(0), Some("BOB"));
}
#[test]
fn test_chained_operations() {
let csv = "status,amount\ncompleted,100\npending,200\ncompleted,50\nfailed,75";
let file = create_test_csv(csv);
let result: Vec<_> = CsvFileIterator::new(file.path(), ',')
.unwrap()
.skip(1)
.filter_by_column(0, |s| s == "completed")
.filter_valid()
.collect();
assert_eq!(result.len(), 2);
}
#[test]
fn test_basic_aggregation() {
let csv = "value\n10\n20\n30\n40\n50";
let file = create_test_csv(csv);
let agg = CsvAggregator::from_column(
CsvFileIterator::new(file.path(), ',')
.unwrap()
.skip(1)
.filter_map(Result::ok),
0,
);
assert_eq!(agg.count, 5);
assert_eq!(agg.sum, 150.0);
assert_eq!(agg.min, 10.0);
assert_eq!(agg.max, 50.0);
assert_eq!(agg.mean(), Some(30.0));
}
#[test]
fn test_grouped_aggregation() {
let csv = "category,amount\nA,100\nB,200\nA,150\nB,250\nA,50";
let file = create_test_csv(csv);
let grouped = GroupedAggregator::from_records(
CsvFileIterator::new(file.path(), ',')
.unwrap()
.skip(1)
.filter_map(Result::ok),
|rec| rec.get_field(0).unwrap().to_string(),
|rec| rec.get_typed::<f64>(1).ok(),
);
let stats_a = grouped.get(&"A".to_string()).unwrap();
assert_eq!(stats_a.count, 3);
assert_eq!(stats_a.sum, 300.0);
let stats_b = grouped.get(&"B".to_string()).unwrap();
assert_eq!(stats_b.count, 2);
assert_eq!(stats_b.sum, 450.0);
}
#[test]
fn test_empty_aggregation() {
let agg = CsvAggregator::new();
assert_eq!(agg.mean(), None);
}
#[test]
fn test_parallel_vs_sequential_correctness() {
let file = create_large_test_csv(1000);
let seq_agg = CsvAggregator::from_column(
CsvFileIterator::new(file.path(), ',')
.unwrap()
.skip(1)
.filter_map(Result::ok),
1,
);
let par_agg = parallel_aggregate_column(file.path(), ',', 1, 4).unwrap();
assert_eq!(seq_agg.count, par_agg.count);
assert_eq!(seq_agg.sum, par_agg.sum);
assert_eq!(seq_agg.min, par_agg.min);
assert_eq!(seq_agg.max, par_agg.max);
}
#[test]
fn test_parallel_grouped_aggregation() {
let file = create_large_test_csv(900);
let grouped = parallel_group_by::<String>(file.path(), ',', 0, 1, 4).unwrap();
assert_eq!(grouped.get(&"A".to_string()).unwrap().count, 300);
assert_eq!(grouped.get(&"B".to_string()).unwrap().count, 300);
assert_eq!(grouped.get(&"C".to_string()).unwrap().count, 300);
}
#[test]
#[ignore]
fn benchmark_parallel_speedup() {
use std::time::Instant;
let file = create_large_test_csv(100_000);
let start = Instant::now();
let _ = CsvAggregator::from_column(
CsvFileIterator::new(file.path(), ',')
.unwrap()
.skip(1)
.filter_map(Result::ok),
1,
);
let seq_time = start.elapsed();
let start = Instant::now();
let _ = parallel_aggregate_column(file.path(), ',', 1, 4).unwrap();
let par_time = start.elapsed();
assert!(par_time < seq_time || par_time.as_secs_f64() == 0.0);
}
}
}