// Shared CSV reader for the expense-export tests (tests/expenses-export.test.js // and tests/expense-csv.test.js). Not a test file — it registers no tests and // is therefore not listed in tests/index.js. import assert from 'node:assert/strict'; // U+FEFF. buildExpenseCsv returns the complete file including the BOM, so both // the pure function's output and the HTTP body must carry it. export const BOM = ''; // A strict RFC 4180 reader. It doubles as an assertion: it rejects a bare LF or // CR outside a quoted field, so a file with LF endings fails here. export function parseCsv(text) { assert.ok(text.startsWith(BOM), 'CSV must be prefixed with the UTF-8 BOM'); const body = text.slice(1); const rows = []; let row = []; let field = ''; let quoted = false; for (let i = 0; i < body.length; i += 1) { const ch = body[i]; if (quoted) { if (ch !== '"') { field += ch; continue; } if (body[i + 1] === '"') { field += '"'; i += 1; continue; } quoted = false; } else if (ch === '"') { quoted = true; } else if (ch === ',') { row.push(field); field = ''; } else if (ch === '\r' && body[i + 1] === '\n') { row.push(field); field = ''; rows.push(row); row = []; i += 1; } else if (ch === '\n' || ch === '\r') { assert.fail('bare CR/LF outside a quoted field — line endings must be CRLF'); } else { field += ch; } } if (field !== '' || row.length) { row.push(field); rows.push(row); } assert.equal(quoted, false, 'unterminated quoted field'); return rows; } // Turn the parsed grid into header + row objects keyed by column name. export function readCsv(text) { const grid = parseCsv(text); const header = grid[0]; const rows = grid.slice(1).map((cells) => { assert.equal(cells.length, header.length, `row has ${cells.length} cells, header has ${header.length}`); return Object.fromEntries(cells.map((c, i) => [header[i], c])); }); return { header, rows }; }