50339aef6d
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
289 lines
9.7 KiB
Rust
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(_))));
|
|
}
|
|
}
|