use serde::Deserialize;
use std::fmt;
#[derive(Debug, Deserialize)]
pub struct UserRecord {
pub name: String,
pub email: String,
pub age: u32,
}
impl UserRecord {
pub fn validate(&self) -> Vec<String> {
let mut errors = Vec::new();
if self.name.trim().is_empty() {
errors.push("name must not be empty".to_string());
}
if !is_valid_email(&self.email) {
errors.push(format!("invalid email: {:?}", self.email));
}
if self.age < 18 || self.age > 120 {
errors.push(format!("age {} out of range (18-120)", self.age));
}
errors
}
}
fn is_valid_email(value: &str) -> bool {
let mut parts = value.split('@');
match (parts.next(), parts.next(), parts.next()) {
(Some(local), Some(domain), None) => {
!local.is_empty() && domain.contains('.') && !domain.starts_with('.')
}
_ => false,
}
}
#[derive(Debug)]
pub struct RowError {
pub line: u64,
pub errors: Vec<String>,
}
impl fmt::Display for RowError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "line {}: {}", self.line, self.errors.join("; "))
}
}
use crate::record::{RowError, UserRecord};
use std::io::Read;
pub fn import<R: Read>(source: R) -> Result<Vec<UserRecord>, Vec<RowError>> {
let mut reader = csv::ReaderBuilder::new()
.has_headers(true)
.flexible(false)
.trim(csv::Trim::All)
.from_reader(source);
let mut records = Vec::new();
let mut errors = Vec::new();
for (index, result) in reader.records().enumerate() {
// header is line 1, first data row is line 2
let fallback_line = index as u64 + 2;
let raw = match result {
Ok(raw) => raw,
Err(err) => {
let line = err.position().map(|p| p.line()).unwrap_or(fallback_line);
errors.push(RowError { line, errors: vec![err.to_string()] });
continue;
}
};
let line = raw.position().map(|p| p.line()).unwrap_or(fallback_line);
match raw.deserialize::<UserRecord>(None) {
Ok(record) => {
let issues = record.validate();
if issues.is_empty() {
records.push(record);
} else {
errors.push(RowError { line, errors: issues });
}
}
Err(err) => {
errors.push(RowError { line, errors: vec![err.to_string()] });
}
}
}
if errors.is_empty() {
Ok(records)
} else {
Err(errors)
}
}
mod importer;
mod record;
use std::fs::File;
use std::process::exit;
fn main() {
let path = match std::env::args().nth(1) {
Some(path) => path,
None => {
eprintln!("usage: csvimport <file.csv>");
exit(2);
}
};
let file = match File::open(&path) {
Ok(file) => file,
Err(err) => {
eprintln!("cannot open {}: {}", path, err);
exit(2);
}
};
match importer::import(file) {
Ok(records) => {
println!("imported {} valid records", records.len());
}
Err(errors) => {
eprintln!("import failed with {} invalid row(s):", errors.len());
for error in &errors {
eprintln!(" {}", error);
}
exit(1);
}
}
}
This snippet shows how to validate a CSV import in Rust while preserving the physical line number for every problem, so a user can jump straight to the offending row in their file. The core insight is that serde deserialization and business validation are two distinct failure modes, and both need to be attributed to the exact spreadsheet line the data came from.
In record.rs, the UserRecord struct derives Deserialize so the csv crate can map header-named columns onto typed fields. Validation lives in validate, which returns a Vec<String> of human-readable messages rather than short-circuiting on the first error — collecting all issues per row is friendlier for bulk imports than failing fast. Small helpers like is_valid_email keep the rules explicit and testable. The RowError type pairs a line number with its errors, and its Display impl formats a compact one-line summary.
The subtlety handled in importer.rs is line numbering. A csv::Reader yields records starting after the header, so a naive zero-based index is off by the header row and by one for human counting. The ReaderBuilder uses flexible(false) so rows with the wrong column count are rejected by the parser itself. The code reads through records() and uses the reader's own position().line() where available, falling back to a computed index + 2 (one for the header, one for 1-based counting). Each iteration distinguishes a parse-level Err from a successfully deserialized record that still fails validate, funneling both into a RowError with the correct line.
Accumulating into errors lets import return Result<Vec<UserRecord>, Vec<RowError>>: either the fully valid set or the complete list of problems. This all-or-nothing contract avoids partially importing dirty data.
In main.rs, the CLI opens the path from args, calls import, and on failure prints every RowError to stderr before exiting non-zero. Because each error already knows its line, the output is directly actionable. The trade-off is memory: buffering all records suits typical import sizes but a streaming, chunked approach would be preferable for very large files. This pattern is worth reaching for whenever imported data must be trustworthy and errors must be traceable back to source.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
use clap::Parser;
#[derive(Parser, Debug)]
#[command(author, version, about)]
struct Args {
#[arg(short, long)]
clap for CLI argument parsing with derive macros
Share this code
Here's the card — post it anywhere.