Skip to content

Commit 519738d

Browse files
committed
✨ feat: add support for header-less CSV import
1 parent a70e284 commit 519738d

2 files changed

Lines changed: 62 additions & 1 deletion

File tree

src/cli/import.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ fn read_and_parse_csv(
5555
return Err(EnvelopeError::Import(format!("File not found: {}", file)));
5656
}
5757

58+
// First, peek at the file to detect the format
5859
let mut reader = csv::Reader::from_path(path)
5960
.map_err(|e| EnvelopeError::Import(format!("Failed to open CSV file: {}", e)))?;
6061
let headers = reader
@@ -63,7 +64,16 @@ fn read_and_parse_csv(
6364
.clone();
6465
let mapping = import_service.detect_mapping_from_headers(&headers);
6566

66-
let parsed = import_service.parse_csv_from_reader(&mut reader, &mapping)?;
67+
// If no header detected, re-read without treating first row as header
68+
let parsed = if !mapping.has_header {
69+
let mut reader = csv::ReaderBuilder::new()
70+
.has_headers(false)
71+
.from_path(path)
72+
.map_err(|e| EnvelopeError::Import(format!("Failed to open CSV file: {}", e)))?;
73+
import_service.parse_csv_from_reader(&mut reader, &mapping)?
74+
} else {
75+
import_service.parse_csv_from_reader(&mut reader, &mapping)?
76+
};
6777

6878
Ok((parsed, target_account))
6979
}

src/services/import.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,22 @@ impl ColumnMapping {
114114
}
115115
}
116116

117+
/// TD Bank CSV format (no header, date/description/debit/credit/balance)
118+
pub fn td_bank() -> Self {
119+
Self {
120+
date_column: 0,
121+
amount_column: None,
122+
outflow_column: Some(2),
123+
inflow_column: Some(3),
124+
payee_column: Some(1),
125+
memo_column: None,
126+
date_format: "%Y-%m-%d".to_string(),
127+
has_header: false,
128+
delimiter: ',',
129+
invert_amounts: false,
130+
}
131+
}
132+
117133
/// Set the date format
118134
pub fn with_date_format(mut self, format: &str) -> Self {
119135
self.date_format = format.to_string();
@@ -346,8 +362,43 @@ impl<'a> ImportService<'a> {
346362
Err(format!("Could not parse date: '{}'", s))
347363
}
348364

365+
/// Check if a record looks like data (not headers)
366+
/// Returns true if first column parses as a date
367+
fn looks_like_data_row(&self, record: &StringRecord) -> bool {
368+
if let Some(first) = record.get(0) {
369+
let first = first.trim();
370+
// Try to parse as a date - if it succeeds, this is data not a header
371+
let date_formats = [
372+
"%Y-%m-%d", "%m/%d/%Y", "%m/%d/%y", "%d/%m/%Y", "%d/%m/%y",
373+
];
374+
for format in date_formats {
375+
if NaiveDate::parse_from_str(first, format).is_ok() {
376+
return true;
377+
}
378+
}
379+
}
380+
false
381+
}
382+
349383
/// Detect column mapping from CSV header record
350384
pub fn detect_mapping_from_headers(&self, headers: &StringRecord) -> ColumnMapping {
385+
// First, check if this looks like a data row (no headers)
386+
if self.looks_like_data_row(headers) {
387+
// This is likely a headerless CSV like TD Bank
388+
// Check if it matches TD Bank format: date, desc, debit, credit, balance
389+
if headers.len() >= 4 {
390+
// Verify column 2 or 3 looks like a number (debit/credit)
391+
let col2 = headers.get(2).map(|s| s.trim()).unwrap_or("");
392+
let col3 = headers.get(3).map(|s| s.trim()).unwrap_or("");
393+
let col2_is_num = col2.is_empty() || col2.parse::<f64>().is_ok();
394+
let col3_is_num = col3.is_empty() || col3.parse::<f64>().is_ok();
395+
396+
if col2_is_num && col3_is_num {
397+
return ColumnMapping::td_bank();
398+
}
399+
}
400+
}
401+
351402
let mut mapping = ColumnMapping::new();
352403

353404
for (idx, header) in headers.iter().enumerate() {

0 commit comments

Comments
 (0)