use crate::AdapterError;
use calamine::{open_workbook, Data, Range, Reader, Xlsx};
use std::collections::BTreeMap;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TableRole {
Single,
SwanTrades,
SwanTransfers,
SwanWithdrawals,
}
#[derive(Debug, Clone)]
pub struct RawRow {
pub role: TableRole,
pub line: usize,
pub cells: BTreeMap<String, String>,
}
impl RawRow {
pub fn get(&self, source: &'static str, col: &str) -> Result<&str, AdapterError> {
self.cells
.get(col)
.map(|s| s.as_str())
.ok_or_else(|| AdapterError::MissingColumn {
adapter: source,
line: self.line,
column: col.to_string(),
})
}
pub fn opt(&self, col: &str) -> Option<&str> {
self.cells
.get(col)
.map(|s| s.as_str())
.filter(|s| !s.trim().is_empty())
}
}
#[derive(Debug, Clone, Default)]
pub struct ReadOpts {
pub skip_preamble_lines: usize,
pub header_signature: &'static [&'static str],
}
pub fn read_csv(
path: &Path,
role: TableRole,
adapter: &'static str,
opts: &ReadOpts,
) -> Result<Vec<RawRow>, AdapterError> {
let text = std::fs::read_to_string(path).map_err(|e| AdapterError::Io {
path: path.display().to_string(),
source: e,
})?;
read_csv_str(&text, role, adapter, opts).map_err(|e| with_path(e, path))
}
pub fn read_csv_str(
text: &str,
role: TableRole,
adapter: &'static str,
opts: &ReadOpts,
) -> Result<Vec<RawRow>, AdapterError> {
let start = if !opts.header_signature.is_empty() {
text.lines()
.position(|l| opts.header_signature.iter().all(|t| l.contains(t)))
.ok_or(AdapterError::HeaderNotFound { adapter })?
} else {
opts.skip_preamble_lines
};
let body: String = text.split_inclusive('\n').skip(start).collect();
let mut rdr = csv::ReaderBuilder::new()
.has_headers(true)
.flexible(true)
.from_reader(body.as_bytes());
let headers: Vec<String> = rdr
.headers()
.map_err(csv_err)?
.iter()
.map(|h| h.trim().to_string())
.collect();
let mut out = Vec::new();
for (i, rec) in rdr.records().enumerate() {
let rec = rec.map_err(csv_err)?;
let mut cells = BTreeMap::new();
for (h, v) in headers.iter().zip(rec.iter()) {
if !h.is_empty() {
cells.insert(h.clone(), v.trim().to_string());
}
}
out.push(RawRow {
role,
line: i + 1,
cells,
});
}
Ok(out)
}
pub fn read_xlsx(path: &Path, role: TableRole) -> Result<Vec<RawRow>, AdapterError> {
let mut wb: Xlsx<_> =
open_workbook(path).map_err(|e: calamine::XlsxError| AdapterError::Xlsx {
path: path.display().to_string(),
source: e.into(),
})?;
let range = wb
.worksheet_range_at(0)
.ok_or_else(|| AdapterError::EmptyXlsx {
path: path.display().to_string(),
})?
.map_err(|e| AdapterError::Xlsx {
path: path.display().to_string(),
source: e.into(),
})?;
Ok(rows_from_range(&range, role))
}
pub fn read_table(
path: &Path,
role: TableRole,
adapter: &'static str,
opts: &ReadOpts,
) -> Result<Vec<RawRow>, AdapterError> {
match path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.as_deref()
{
Some("xlsx") | Some("xls") => read_xlsx(path, role),
_ => read_csv(path, role, adapter, opts),
}
}
pub fn peek_text(path: &Path, max_bytes: usize) -> Result<String, AdapterError> {
use std::io::Read;
let mut f = std::fs::File::open(path).map_err(|e| AdapterError::Io {
path: path.display().to_string(),
source: e,
})?;
let mut buf = vec![0u8; max_bytes];
let n = f.read(&mut buf).map_err(|e| AdapterError::Io {
path: path.display().to_string(),
source: e,
})?;
buf.truncate(n);
Ok(String::from_utf8_lossy(&buf).into_owned())
}
fn cell_to_string(d: &Data) -> String {
match d {
Data::Empty => String::new(),
Data::String(s) => s.trim().to_string(),
Data::Float(f) => format!("{f}"),
Data::Int(i) => i.to_string(),
Data::Bool(b) => b.to_string(),
Data::DateTime(dt) => format!("{}", dt.as_f64()),
Data::DateTimeIso(s) | Data::DurationIso(s) => s.clone(),
Data::Error(e) => format!("#ERR:{e:?}"),
}
}
fn rows_from_range(range: &Range<Data>, role: TableRole) -> Vec<RawRow> {
let mut iter = range.rows();
let header: Vec<String> = match iter.next() {
Some(h) => h.iter().map(cell_to_string).collect(),
None => return Vec::new(),
};
let mut out = Vec::new();
for (i, r) in iter.enumerate() {
let mut cells = BTreeMap::new();
for (h, c) in header.iter().zip(r.iter()) {
if !h.is_empty() {
cells.insert(h.clone(), cell_to_string(c));
}
}
if cells.values().all(|v| v.is_empty()) {
continue;
}
out.push(RawRow {
role,
line: i + 1,
cells,
});
}
out
}
fn csv_err(e: csv::Error) -> AdapterError {
AdapterError::Csv {
path: "<csv>".to_string(),
source: e,
}
}
fn with_path(e: AdapterError, path: &Path) -> AdapterError {
match e {
AdapterError::Csv { source, .. } => AdapterError::Csv {
path: path.display().to_string(),
source,
},
other => other,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn skips_preamble_via_header_signature_and_handles_crlf() {
let text = "Transactions\r\nUser,acct\r\n\r\nID,Amount,Total\r\nX1,0.5,100.00\r\nX2,0.25,50.00\r\n";
let opts = ReadOpts {
header_signature: &["ID", "Total"],
..Default::default()
};
let rows = read_csv_str(text, TableRole::Single, "test", &opts).unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].get("t", "ID").unwrap(), "X1");
assert_eq!(rows[0].get("t", "Total").unwrap(), "100.00");
assert_eq!(rows[1].line, 2);
}
#[test]
fn missing_column_is_a_typed_error() {
let rows = read_csv_str(
"A,B\n1,2\n",
TableRole::Single,
"test",
&ReadOpts::default(),
)
.unwrap();
let e = rows[0].get("t", "C").unwrap_err();
assert!(matches!(e, crate::AdapterError::MissingColumn { .. }));
assert_eq!(rows[0].opt("B"), Some("2"));
assert_eq!(rows[0].opt("missing"), None);
}
#[test]
fn fixed_skip_preamble_count_works_when_no_signature() {
let text = "junk1\njunk2\nA,B\n1,2\n";
let opts = ReadOpts {
skip_preamble_lines: 2,
..Default::default()
};
let rows = read_csv_str(text, TableRole::Single, "test", &opts).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].get("t", "A").unwrap(), "1");
}
#[test]
fn nonmatching_header_signature_returns_error() {
let text = "ID,Amount\n1,100\n2,200\n";
let opts = ReadOpts {
header_signature: &["NonExistentToken"],
..Default::default()
};
let err = read_csv_str(text, TableRole::Single, "test", &opts).unwrap_err();
assert!(
matches!(err, crate::AdapterError::HeaderNotFound { adapter } if adapter == "test")
);
}
#[test]
fn xlsx_numeric_serial_date_roundtrip() {
use crate::parse::parse_timestamp_flex;
use rust_xlsxwriter::Workbook;
use time::macros::datetime;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("gemini_fixture.xlsx");
let mut wb = Workbook::new();
let ws = wb.add_worksheet();
ws.write_string(0, 0, "Date").unwrap();
ws.write_string(0, 1, "Amount").unwrap();
let serial = 25569.5_f64;
ws.write_number(1, 0, serial).unwrap();
ws.write_string(1, 1, "0.5").unwrap();
wb.save(&path).unwrap();
let rows = read_xlsx(&path, TableRole::Single).unwrap();
assert_eq!(rows.len(), 1);
let date_str = rows[0].get("t", "Date").unwrap();
assert_eq!(date_str, "25569.5");
let (utc, _) = parse_timestamp_flex("gemini", 1, date_str).unwrap();
assert_eq!(utc, datetime!(1970-01-01 12:00:00 UTC));
}
}