//! 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, } /// 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, /// 0-based index of this row among identical rows earlier in the file (SPEC §10.8 ``). 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, } /// 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, pub totals: Vec, pub blank_rows: usize, pub problems: Vec, } /// 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 { 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 = 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 { 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 = 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, 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 = 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 { 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(_)))); } }