Files
kvitt/server/crates/kvitt-import/src/parse.rs
T
jacobh2 50339aef6d M9: kvitt-import Splitwise importer (SPEC §10.8, FR-17)
CLI per §10.8 with --help, --dry-run and a summary; English and Swedish
headers with member columns matched by name; CSV quoting; payments as
settlements with one allocation; trailing totals row used to reconcile
the account balance (mismatch rolls back, exit 4); non-SEK rows abort
unless --fx; committed category table data/splitwise_categories.csv;
idempotent re-runs through import_key; rows validated with the server's
push checks, audited with source import, no pushes; one BEGIN IMMEDIATE
transaction. Tests over synthetic fixtures only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-18 07:45:37 +02:00

289 lines
9.7 KiB
Rust

//! Reading a Splitwise export (SPEC §10.8): CSV quoting, the English or Swedish header with the member columns
//! named by full Splitwise name, and row classification (data rows, the trailing totals row, blank rows).
use std::collections::BTreeMap;
/// The fixed leading columns of the English export.
pub const ENGLISH_COLUMNS: [&str; 5] = ["Date", "Description", "Category", "Cost", "Currency"];
/// The fixed leading columns of the Swedish export.
pub const SWEDISH_COLUMNS: [&str; 5] = ["Datum", "Beskrivning", "Kategori", "Kostnad", "Valuta"];
/// `Description` of the trailing totals row, per language.
pub const TOTAL_DESCRIPTIONS: [&str; 2] = ["Total balance", "Totalsumma"];
/// Member columns per file: the household has exactly two users.
pub const MEMBER_COLUMNS: usize = 2;
/// The export's UI language.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Language {
English,
Swedish,
}
impl Language {
pub fn as_str(self) -> &'static str {
match self {
Self::English => "English",
Self::Swedish => "Swedish",
}
}
}
/// The parsed header.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Header {
pub language: Language,
/// Member names in header order.
pub members: Vec<String>,
}
/// One data row (an expense or a payment), its fields as in the file (after CSV unquoting).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DataRow {
/// 1-based line number in the file.
pub line: u64,
pub date: String,
pub description: String,
pub category: String,
pub cost: String,
pub currency: String,
/// Member nets in header order.
pub nets: Vec<String>,
/// 0-based index of this row among identical rows earlier in the file (SPEC §10.8 `<n>`).
pub occurrence: usize,
}
/// A totals row: the member nets of the whole group, per currency.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TotalRow {
pub line: u64,
pub currency: String,
/// Member nets in header order.
pub nets: Vec<String>,
}
/// A row that cannot be read.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RowProblem {
pub line: u64,
pub reason: String,
}
/// A whole parsed file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedFile {
pub header: Header,
pub rows: Vec<DataRow>,
pub totals: Vec<TotalRow>,
pub blank_rows: usize,
pub problems: Vec<RowProblem>,
}
/// Why a file cannot be read at all.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileError {
/// Wrong or missing header (SPEC §10.8: fails the import, writing nothing).
Header(String),
/// Not CSV at all (bad UTF-8, broken quoting).
Csv(String),
}
impl std::fmt::Display for FileError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Header(m) | Self::Csv(m) => f.write_str(m),
}
}
}
fn expected_headers() -> String {
format!(
"expected \"{}\" (English) or \"{}\" (Swedish), then exactly {MEMBER_COLUMNS} member columns named by full \
Splitwise name",
ENGLISH_COLUMNS.join(","),
SWEDISH_COLUMNS.join(",")
)
}
/// Checks the header line.
pub fn parse_header(fields: &[String]) -> Result<Header, FileError> {
let fixed: Vec<&str> = fields.iter().take(5).map(String::as_str).collect();
let language = if fixed == ENGLISH_COLUMNS {
Language::English
} else if fixed == SWEDISH_COLUMNS {
Language::Swedish
} else {
return Err(FileError::Header(format!(
"unrecognised header; {}",
expected_headers()
)));
};
let members: Vec<String> = fields.iter().skip(5).map(|m| m.trim().to_owned()).collect();
if members.len() != MEMBER_COLUMNS {
return Err(FileError::Header(format!(
"the header has {} member columns; {}",
members.len(),
expected_headers()
)));
}
if members.iter().any(String::is_empty) {
return Err(FileError::Header("a member column has no name".into()));
}
if members[0] == members[1] {
return Err(FileError::Header(
"the two member columns have the same name".into(),
));
}
Ok(Header { language, members })
}
/// The 1-based line number of the record csv reports at byte `offset`. csv reports the offset where it started
/// reading, before the empty lines it skips, so those are stepped over first.
fn line_at(bytes: &[u8], offset: u64) -> u64 {
let mut end = usize::try_from(offset)
.unwrap_or(usize::MAX)
.min(bytes.len());
while end < bytes.len() && (bytes[end] == b'\n' || bytes[end] == b'\r') {
end += 1;
}
let newlines = bytes[..end].iter().filter(|b| **b == b'\n').count();
u64::try_from(newlines)
.unwrap_or(u64::MAX)
.saturating_add(1)
}
/// Parses a whole export. Rows that do not have the header's column count are problems, not errors, so every bad
/// row is reported at once.
pub fn parse(bytes: &[u8]) -> Result<ParsedFile, FileError> {
let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
let mut reader = csv::ReaderBuilder::new()
.has_headers(false)
.flexible(true)
.from_reader(bytes);
let mut records = reader.records();
let header_fields: Vec<String> = match records.next() {
Some(Ok(record)) => record.iter().map(str::to_owned).collect(),
Some(Err(e)) => return Err(FileError::Csv(format!("cannot read the header: {e}"))),
None => {
return Err(FileError::Header(format!(
"the file is empty; {}",
expected_headers()
)));
}
};
let header = parse_header(&header_fields)?;
let width = 5 + header.members.len();
let mut rows = Vec::new();
let mut totals = Vec::new();
let mut problems = Vec::new();
let mut blank_rows = 0;
let mut occurrences: BTreeMap<Vec<String>, usize> = BTreeMap::new();
for record in records {
let record = record.map_err(|e| FileError::Csv(format!("CSV error: {e}")))?;
// csv's own line counter does not count the empty lines it skips.
let line = record.position().map_or(0, |p| line_at(bytes, p.byte()));
let fields: Vec<String> = record.iter().map(str::to_owned).collect();
if fields.iter().all(|f| f.trim().is_empty()) {
blank_rows += 1;
continue;
}
if fields.len() != width {
problems.push(RowProblem {
line,
reason: format!("{} fields, the header has {width}", fields.len()),
});
continue;
}
let nets = fields[5..].to_vec();
if TOTAL_DESCRIPTIONS.contains(&fields[1].trim()) && fields[3].trim().is_empty() {
totals.push(TotalRow {
line,
currency: fields[4].trim().to_owned(),
nets,
});
continue;
}
let count = occurrences.entry(fields.clone()).or_insert(0);
let occurrence = *count;
*count += 1;
rows.push(DataRow {
line,
date: fields[0].clone(),
description: fields[1].clone(),
category: fields[2].clone(),
cost: fields[3].clone(),
currency: fields[4].clone(),
nets,
occurrence,
});
}
Ok(ParsedFile {
header,
rows,
totals,
blank_rows,
problems,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn fields(line: &str) -> Vec<String> {
line.split(',').map(str::to_owned).collect()
}
#[test]
fn headers() {
let en = parse_header(&fields("Date,Description,Category,Cost,Currency,A B,C D")).unwrap();
assert_eq!(en.language, Language::English);
assert_eq!(en.members, ["A B", "C D"]);
let sv =
parse_header(&fields("Datum,Beskrivning,Kategori,Kostnad,Valuta,C D,A B")).unwrap();
assert_eq!(sv.language, Language::Swedish);
assert_eq!(sv.members, ["C D", "A B"]);
for bad in [
"Date,Description,Category,Cost",
"Date,Description,Category,Cost,Currency,A B",
"Date,Description,Category,Cost,Currency,A B,C D,E F",
"Datum,Description,Category,Cost,Currency,A B,C D",
"Date,Description,Category,Cost,Valuta,A B,C D",
"Date,Description,Category,Cost,Currency,A B,A B",
"Date,Description,Category,Cost,Currency,A B,",
] {
assert!(
matches!(parse_header(&fields(bad)), Err(FileError::Header(_))),
"{bad}"
);
}
}
#[test]
fn rows_and_occurrences() {
let text = "\u{feff}Date,Description,Category,Cost,Currency,A B,C D\n\n\
2025-01-01,\"Milk, bread\",Groceries,10.00,SEK,5.00,-5.00\n\
2025-01-01,\"Milk, bread\",Groceries,10.00,SEK,5.00,-5.00\n\
,,,,,,\n\
2025-01-02,Short,Groceries,10.00,SEK\n\
\n\
2025-01-03,Total balance, , ,SEK,5.00,-5.00\n";
let parsed = parse(text.as_bytes()).unwrap();
assert_eq!(parsed.rows.len(), 2);
assert_eq!(parsed.rows[0].description, "Milk, bread");
assert_eq!(parsed.rows[0].occurrence, 0);
assert_eq!(parsed.rows[1].occurrence, 1);
assert_eq!(parsed.rows[0].line, 3);
assert_eq!(parsed.totals.len(), 1);
assert_eq!(parsed.totals[0].nets, ["5.00", "-5.00"]);
assert_eq!(parsed.problems.len(), 1);
assert_eq!(parsed.problems[0].line, 6);
assert!(parsed.blank_rows >= 1);
}
#[test]
fn empty_and_broken_files() {
assert!(matches!(parse(b""), Err(FileError::Header(_))));
assert!(matches!(parse(b"a,b\n"), Err(FileError::Header(_))));
}
}