use crate::config::{ColumnSpec, CsvConfig, ImporterConfig};
use crate::{EnrichedImportResult, ImportResult};
use anyhow::{Context, Result};
use rust_decimal::Decimal;
use rustledger_core::{Amount, Directive, Posting, Transaction};
use rustledger_ops::enrichment::{CategorizationMethod, Enrichment};
use std::collections::HashMap;
use std::fs::File;
use std::io::{BufReader, Read};
use std::path::Path;
pub struct CsvImporter {
config: ImporterConfig,
}
impl CsvImporter {
pub const fn new(config: ImporterConfig) -> Self {
Self { config }
}
pub fn extract_file(&self, path: &Path, csv_config: &CsvConfig) -> Result<ImportResult> {
let file =
File::open(path).with_context(|| format!("Failed to open file: {}", path.display()))?;
let mut reader = BufReader::new(file);
let mut content = String::new();
reader.read_to_string(&mut content)?;
self.extract_string(&content, csv_config)
}
pub fn extract_string(&self, content: &str, csv_config: &CsvConfig) -> Result<ImportResult> {
let mut reader = csv::ReaderBuilder::new()
.has_headers(csv_config.has_header)
.delimiter(csv_config.delimiter as u8)
.from_reader(content.as_bytes());
let header_map: HashMap<String, usize> = if csv_config.has_header {
reader
.headers()?
.iter()
.enumerate()
.map(|(i, h)| (h.to_string(), i))
.collect()
} else {
HashMap::new()
};
let mut directives = Vec::new();
let mut warnings = Vec::new();
let mut row_num = csv_config.skip_rows;
for result in reader.records().skip(csv_config.skip_rows) {
row_num += 1;
let record = match result {
Ok(r) => r,
Err(e) => {
warnings.push(format!("Row {row_num}: parse error: {e}"));
continue;
}
};
match self.parse_row(&record, csv_config, &header_map, row_num) {
Ok(Some(txn)) => directives.push(Directive::Transaction(txn)),
Ok(None) => {} Err(e) => {
warnings.push(format!("Row {row_num}: {e}"));
}
}
}
let mut result = ImportResult::new(directives);
for warning in warnings {
result = result.with_warning(warning);
}
Ok(result)
}
pub fn extract_file_enriched(
&self,
path: &Path,
csv_config: &CsvConfig,
) -> Result<EnrichedImportResult> {
let file =
File::open(path).with_context(|| format!("Failed to open file: {}", path.display()))?;
let mut reader = BufReader::new(file);
let mut content = String::new();
reader.read_to_string(&mut content)?;
self.extract_string_enriched(&content, csv_config)
}
pub fn extract_string_enriched(
&self,
content: &str,
csv_config: &CsvConfig,
) -> Result<EnrichedImportResult> {
let result = self.extract_string(content, csv_config)?;
let mut engine = rustledger_ops::categorize::RulesEngine::new();
engine.load_from_mappings(&csv_config.mappings);
if !csv_config.regex_mappings.is_empty() {
engine.load_from_regex_mappings(&csv_config.regex_mappings);
}
if csv_config.use_merchant_dict {
engine.load_merchant_dict();
}
let entries = result
.directives
.into_iter()
.enumerate()
.map(|(i, directive)| {
let enrichment = Self::enrich_directive(&directive, &engine, i);
(directive, enrichment)
})
.collect();
let mut enriched = EnrichedImportResult::new(entries);
for warning in result.warnings {
enriched = enriched.with_warning(warning);
}
Ok(enriched)
}
fn enrich_directive(
directive: &Directive,
engine: &rustledger_ops::categorize::RulesEngine,
index: usize,
) -> Enrichment {
let (confidence, method) = if let Directive::Transaction(txn) = directive {
let payee = txn.payee.as_ref().map(rustledger_core::InternedStr::as_str);
if let Some(rule_match) = engine.categorize(payee, txn.narration.as_str()) {
(rule_match.confidence, rule_match.method)
} else {
(0.0, CategorizationMethod::Default)
}
} else {
(1.0, CategorizationMethod::Manual)
};
let fingerprint = crate::directive_fingerprint(directive);
Enrichment {
directive_index: index,
confidence,
method,
alternatives: vec![],
fingerprint,
}
}
fn parse_row(
&self,
record: &csv::StringRecord,
csv_config: &CsvConfig,
header_map: &HashMap<String, usize>,
row_num: usize,
) -> Result<Option<Transaction>> {
let date_str = self
.get_column(record, &csv_config.date_column, header_map)
.with_context(|| format!("Row {row_num}: missing date column"))?;
if date_str.trim().is_empty() {
return Ok(None); }
let date = jiff::fmt::strtime::parse(&csv_config.date_format, date_str.trim())
.and_then(|tm| tm.to_date())
.with_context(|| {
format!(
"Row {}: failed to parse date '{}' with format '{}'",
row_num, date_str, csv_config.date_format
)
})?;
let narration = csv_config
.narration_column
.as_ref()
.and_then(|col| self.get_column(record, col, header_map).ok())
.map(|s| s.trim().to_string())
.unwrap_or_default();
let payee = csv_config
.payee_column
.as_ref()
.and_then(|col| self.get_column(record, col, header_map).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let amount = self.parse_amount(record, csv_config, header_map)?;
if amount == Decimal::ZERO {
return Ok(None);
}
let final_amount = if csv_config.invert_sign {
-amount
} else {
amount
};
let currency = self
.config
.currency
.clone()
.unwrap_or_else(|| "USD".to_string());
let amount = Amount::new(final_amount, ¤cy);
let posting = Posting::new(&self.config.account, amount);
let default_contra = if final_amount < Decimal::ZERO {
csv_config
.default_expense
.as_deref()
.unwrap_or("Expenses:Unknown")
} else {
csv_config
.default_income
.as_deref()
.unwrap_or("Income:Unknown")
};
let contra_account = self
.match_mapping(csv_config, payee.as_deref(), &narration)
.unwrap_or(default_contra);
let contra_posting = Posting::auto(contra_account);
let mut txn = Transaction::new(date, &narration)
.with_flag('*')
.with_posting(posting)
.with_posting(contra_posting);
if let Some(p) = payee {
txn = txn.with_payee(p);
}
Ok(Some(txn))
}
fn match_mapping<'a>(
&self,
csv_config: &'a CsvConfig,
payee: Option<&str>,
narration: &str,
) -> Option<&'a str> {
if csv_config.mappings.is_empty() {
return None;
}
let payee_lower = payee.map(str::to_lowercase);
let narration_lower = narration.to_lowercase();
for (pattern, account) in &csv_config.mappings {
if let Some(ref p) = payee_lower
&& p.contains(pattern.as_str())
{
return Some(account);
}
if narration_lower.contains(pattern.as_str()) {
return Some(account);
}
}
None
}
fn get_column<'a>(
&self,
record: &'a csv::StringRecord,
spec: &ColumnSpec,
header_map: &HashMap<String, usize>,
) -> Result<&'a str> {
let index = match spec {
ColumnSpec::Index(i) => *i,
ColumnSpec::Name(name) => *header_map
.get(name)
.with_context(|| format!("Column '{name}' not found in header"))?,
};
record
.get(index)
.with_context(|| format!("Column index {index} out of bounds"))
}
fn parse_amount(
&self,
record: &csv::StringRecord,
csv_config: &CsvConfig,
header_map: &HashMap<String, usize>,
) -> Result<Decimal> {
if csv_config.debit_column.is_some() || csv_config.credit_column.is_some() {
let mut amount = Decimal::ZERO;
if let Some(debit_col) = &csv_config.debit_column
&& let Ok(debit_str) = self.get_column(record, debit_col, header_map)
&& let Ok(val) = self.config.amount_format.parse(debit_str)
{
amount -= val; }
if let Some(credit_col) = &csv_config.credit_column
&& let Ok(credit_str) = self.get_column(record, credit_col, header_map)
&& let Ok(val) = self.config.amount_format.parse(credit_str)
{
amount += val; }
return Ok(amount);
}
let amount_col = csv_config
.amount_column
.as_ref()
.context("No amount column configured")?;
let amount_str = self.get_column(record, amount_col, header_map)?;
self.config
.amount_format
.parse(amount_str)
.context("Failed to parse amount")
}
}
#[cfg(test)]
mod tests {
use format_num_pattern::{Locale, NumberFormat};
use super::*;
use crate::config::{AmountFormat, ImporterType};
use std::str::FromStr;
#[test]
fn test_parse_money_string() {
let amount_format = AmountFormat::default();
assert_eq!(amount_format.parse("100.00").unwrap(), Decimal::from(100));
assert_eq!(amount_format.parse("$100.00").unwrap(), Decimal::from(100));
assert_eq!(
amount_format.parse("1,234.56").unwrap(),
Decimal::from_str("1234.56").unwrap()
);
assert_eq!(amount_format.parse("-50.00").unwrap(), Decimal::from(-50));
assert_eq!(amount_format.parse("(50.00)").unwrap(), Decimal::from(-50));
assert!(amount_format.parse("").is_err());
assert!(amount_format.parse("N/A").is_err());
}
#[test]
fn test_parse_custom_format() {
let amount_format = AmountFormat::Format(NumberFormat::new("0,0,0,0.0").unwrap());
assert_eq!(
amount_format.parse("1,2,3,4.0").unwrap(),
Decimal::from(1234)
);
assert_eq!(amount_format.parse("1,2,3,4").unwrap(), Decimal::from(1234));
assert_eq!(amount_format.parse("1,2,3").unwrap(), Decimal::from(123));
assert!(amount_format.parse("1,2,3.0").is_err(),);
}
#[test]
fn test_csv_import_basic() {
let config = ImporterConfig::csv()
.account("Assets:Bank:Checking")
.currency("USD")
.date_column("Date")
.narration_column("Description")
.amount_column("Amount")
.date_format("%m/%d/%Y")
.build()
.unwrap();
let csv_content = r"Date,Description,Amount
01/15/2024,Coffee Shop,-4.50
01/16/2024,Salary Deposit,2500.00
01/17/2024,Grocery Store,-85.23
";
let result = config.extract_from_string(csv_content).unwrap();
assert_eq!(result.directives.len(), 3);
assert!(result.warnings.is_empty());
}
#[test]
fn test_csv_import_debit_credit_columns() {
let config = ImporterConfig::csv()
.account("Assets:Bank:Checking")
.currency("USD")
.date_column("Date")
.narration_column("Description")
.debit_column("Debit")
.credit_column("Credit")
.date_format("%Y-%m-%d")
.build()
.unwrap();
let csv_content = r"Date,Description,Debit,Credit
2024-01-15,Coffee Shop,4.50,
2024-01-16,Salary Deposit,,2500.00
";
let result = config.extract_from_string(csv_content).unwrap();
assert_eq!(result.directives.len(), 2);
if let Directive::Transaction(txn) = &result.directives[0] {
let amount = txn.postings[0].amount().unwrap();
assert_eq!(amount.number, Decimal::from_str("-4.50").unwrap());
}
if let Directive::Transaction(txn) = &result.directives[1] {
let amount = txn.postings[0].amount().unwrap();
assert_eq!(amount.number, Decimal::from_str("2500.00").unwrap());
}
}
#[test]
fn test_csv_import_skip_rows() {
let config = ImporterConfig::csv()
.account("Assets:Bank")
.currency("USD")
.date_column("Date")
.narration_column("Description")
.amount_column("Amount")
.skip_rows(2)
.build()
.unwrap();
let csv_content = r"Date,Description,Amount
Some header info
More info
2024-01-15,Coffee,-5.00
2024-01-16,Lunch,-10.00
";
let result = config.extract_from_string(csv_content).unwrap();
assert_eq!(result.directives.len(), 2);
}
#[test]
fn test_csv_import_invert_sign() {
let config = ImporterConfig::csv()
.account("Liabilities:CreditCard")
.currency("USD")
.date_column("Date")
.narration_column("Description")
.amount_column("Amount")
.invert_sign(true)
.build()
.unwrap();
let csv_content = r"Date,Description,Amount
2024-01-15,Purchase,50.00
";
let result = config.extract_from_string(csv_content).unwrap();
assert_eq!(result.directives.len(), 1);
if let Directive::Transaction(txn) = &result.directives[0] {
let amount = txn.postings[0].amount().unwrap();
assert_eq!(amount.number, Decimal::from_str("-50.00").unwrap());
}
}
#[test]
fn test_csv_import_semicolon_delimiter() {
let config = ImporterConfig::csv()
.account("Assets:Bank")
.currency("EUR")
.date_column("Date")
.narration_column("Description")
.amount_column("Amount")
.delimiter(';')
.build()
.unwrap();
let csv_content = r"Date;Description;Amount
2024-01-15;Coffee;-5.00
";
let result = config.extract_from_string(csv_content).unwrap();
assert_eq!(result.directives.len(), 1);
}
#[test]
fn test_csv_import_column_by_index() {
let config = ImporterConfig::csv()
.account("Assets:Bank")
.currency("USD")
.date_column_index(0)
.narration_column_index(1)
.amount_column_index(2)
.has_header(false)
.build()
.unwrap();
let csv_content = r"2024-01-15,Coffee,-5.00
2024-01-16,Lunch,-10.00
";
let result = config.extract_from_string(csv_content).unwrap();
assert_eq!(result.directives.len(), 2);
}
#[test]
fn test_csv_import_with_payee() {
let config = ImporterConfig::csv()
.account("Assets:Bank")
.currency("USD")
.date_column("Date")
.payee_column("Payee")
.narration_column("Description")
.amount_column("Amount")
.build()
.unwrap();
let csv_content = r"Date,Payee,Description,Amount
2024-01-15,Coffee Shop,Morning coffee,-5.00
";
let result = config.extract_from_string(csv_content).unwrap();
assert_eq!(result.directives.len(), 1);
if let Directive::Transaction(txn) = &result.directives[0] {
assert_eq!(txn.payee.as_deref(), Some("Coffee Shop"));
assert_eq!(txn.narration.as_str(), "Morning coffee");
}
}
#[test]
fn test_csv_import_empty_csv() {
let config = ImporterConfig::csv()
.account("Assets:Bank")
.currency("USD")
.date_column("Date")
.narration_column("Description")
.amount_column("Amount")
.build()
.unwrap();
let csv_content = "Date,Description,Amount\n";
let result = config.extract_from_string(csv_content).unwrap();
assert!(result.directives.is_empty());
}
#[test]
fn test_csv_import_with_currency_symbol() {
let config = ImporterConfig::csv()
.account("Assets:Bank")
.currency("USD")
.date_column("Date")
.narration_column("Description")
.amount_column("Amount")
.build()
.unwrap();
let csv_content = r"Date,Description,Amount
2024-01-15,Purchase,$100.00
2024-01-16,Refund,-$25.00
";
let result = config.extract_from_string(csv_content).unwrap();
assert_eq!(result.directives.len(), 2);
if let Directive::Transaction(txn) = &result.directives[0] {
let amount = txn.postings[0].amount().unwrap();
assert_eq!(amount.number, Decimal::from(100));
}
}
#[test]
fn test_csv_import_with_special_locale() {
let config = ImporterConfig::csv()
.account("Assets:Bank")
.currency("USD")
.date_column("Date")
.narration_column("Description")
.amount_column("Amount")
.amount_locale(Locale::da_DK)
.delimiter(';')
.build()
.unwrap();
let csv_content = r"Date;Description;Amount
2024-01-15;Purchase;1.000,00
2024-01-16;Refund;-25.000.000,00
";
let result = config.extract_from_string(csv_content).unwrap();
assert_eq!(result.directives.len(), 2);
if let Directive::Transaction(txn) = &result.directives[0] {
let amount = txn.postings[0].amount().unwrap();
assert_eq!(amount.number, Decimal::from(1000));
}
if let Directive::Transaction(txn) = &result.directives[1] {
let amount = txn.postings[0].amount().unwrap();
assert_eq!(amount.number, Decimal::from(-25_000_000));
}
}
#[test]
fn test_csv_import_parentheses_negative() {
let config = ImporterConfig::csv()
.account("Assets:Bank")
.currency("USD")
.date_column("Date")
.narration_column("Description")
.amount_column("Amount")
.build()
.unwrap();
let csv_content = r"Date,Description,Amount
2024-01-15,Withdrawal,(50.00)
";
let result = config.extract_from_string(csv_content).unwrap();
assert_eq!(result.directives.len(), 1);
if let Directive::Transaction(txn) = &result.directives[0] {
let amount = txn.postings[0].amount().unwrap();
assert_eq!(amount.number, Decimal::from(-50));
}
}
#[test]
fn test_csv_import_comma_thousands() {
let config = ImporterConfig::csv()
.account("Assets:Bank")
.currency("USD")
.date_column("Date")
.narration_column("Description")
.amount_column("Amount")
.build()
.unwrap();
let csv_content = r#"Date,Description,Amount
2024-01-15,Large deposit,"1,234.56"
"#;
let result = config.extract_from_string(csv_content).unwrap();
assert_eq!(result.directives.len(), 1);
if let Directive::Transaction(txn) = &result.directives[0] {
let amount = txn.postings[0].amount().unwrap();
assert_eq!(amount.number, Decimal::from_str("1234.56").unwrap());
}
}
#[test]
fn test_csv_importer_new() {
let config = ImporterConfig::csv()
.account("Assets:Bank")
.build()
.unwrap();
let importer = CsvImporter::new(config);
let empty_result = importer.extract_string("Date,Amount\n", &CsvConfig::default());
assert!(empty_result.is_ok());
}
#[test]
fn test_parse_money_string_edge_cases() {
let amount_format = AmountFormat::default();
assert_eq!(
amount_format.parse(" 100.00 ").unwrap(),
Decimal::from(100)
);
assert!(amount_format.parse(" ").is_err());
assert!(amount_format.parse("$").is_err());
assert_eq!(
amount_format.parse("-$100.00").unwrap(),
Decimal::from(-100)
);
}
#[test]
fn test_csv_import_invalid_date_generates_warning() {
let config = ImporterConfig::csv()
.account("Assets:Bank")
.currency("USD")
.date_column("Date")
.narration_column("Description")
.amount_column("Amount")
.build()
.unwrap();
let csv_content = r"Date,Description,Amount
not-a-date,Coffee,-5.00
2024-01-15,Valid,-10.00
";
let result = config.extract_from_string(csv_content).unwrap();
assert_eq!(result.directives.len(), 1);
assert_eq!(result.warnings.len(), 1);
assert!(result.warnings[0].contains("failed to parse date"));
}
#[test]
fn test_csv_import_empty_date_skips_row() {
let config = ImporterConfig::csv()
.account("Assets:Bank")
.currency("USD")
.date_column("Date")
.narration_column("Description")
.amount_column("Amount")
.build()
.unwrap();
let csv_content = r"Date,Description,Amount
,Empty date row,-5.00
2024-01-15,Valid,-10.00
";
let result = config.extract_from_string(csv_content).unwrap();
assert_eq!(result.directives.len(), 1);
assert!(result.warnings.is_empty());
}
#[test]
fn test_csv_import_zero_amount_skips_row() {
let config = ImporterConfig::csv()
.account("Assets:Bank")
.currency("USD")
.date_column("Date")
.narration_column("Description")
.amount_column("Amount")
.build()
.unwrap();
let csv_content = r"Date,Description,Amount
2024-01-15,Zero amount,0.00
2024-01-16,Valid,-10.00
";
let result = config.extract_from_string(csv_content).unwrap();
assert_eq!(result.directives.len(), 1);
if let Directive::Transaction(txn) = &result.directives[0] {
assert_eq!(txn.narration.as_str(), "Valid");
}
}
#[test]
fn test_csv_import_default_currency() {
let config = ImporterConfig::csv()
.account("Assets:Bank")
.date_column("Date")
.narration_column("Description")
.amount_column("Amount")
.build()
.unwrap();
let csv_content = r"Date,Description,Amount
2024-01-15,Coffee,-5.00
";
let result = config.extract_from_string(csv_content).unwrap();
assert_eq!(result.directives.len(), 1);
if let Directive::Transaction(txn) = &result.directives[0] {
let amount = txn.postings[0].amount().unwrap();
assert_eq!(amount.currency.as_str(), "USD");
}
}
#[test]
fn test_csv_import_income_contra_account() {
let config = ImporterConfig::csv()
.account("Assets:Bank")
.currency("USD")
.date_column("Date")
.narration_column("Description")
.amount_column("Amount")
.build()
.unwrap();
let csv_content = r"Date,Description,Amount
2024-01-15,Salary,2500.00
2024-01-16,Coffee,-5.00
";
let result = config.extract_from_string(csv_content).unwrap();
assert_eq!(result.directives.len(), 2);
if let Directive::Transaction(txn) = &result.directives[0] {
assert_eq!(txn.postings[1].account.as_str(), "Income:Unknown");
}
if let Directive::Transaction(txn) = &result.directives[1] {
assert_eq!(txn.postings[1].account.as_str(), "Expenses:Unknown");
}
}
#[test]
fn test_csv_import_empty_payee_filtered() {
let config = ImporterConfig::csv()
.account("Assets:Bank")
.currency("USD")
.date_column("Date")
.payee_column("Payee")
.narration_column("Description")
.amount_column("Amount")
.build()
.unwrap();
let csv_content = r"Date,Payee,Description,Amount
2024-01-15,,Empty payee,-5.00
2024-01-16, ,Whitespace payee,-10.00
";
let result = config.extract_from_string(csv_content).unwrap();
assert_eq!(result.directives.len(), 2);
if let Directive::Transaction(txn) = &result.directives[0] {
assert!(txn.payee.is_none());
}
if let Directive::Transaction(txn) = &result.directives[1] {
assert!(txn.payee.is_none());
}
}
#[test]
fn test_csv_import_missing_column_error() {
let config = ImporterConfig::csv()
.account("Assets:Bank")
.currency("USD")
.date_column("NonExistentColumn")
.narration_column("Description")
.amount_column("Amount")
.build()
.unwrap();
let csv_content = r"Date,Description,Amount
2024-01-15,Coffee,-5.00
";
let result = config.extract_from_string(csv_content).unwrap();
assert!(result.directives.is_empty());
assert_eq!(result.warnings.len(), 1);
assert!(result.warnings[0].contains("missing date column"));
}
#[test]
fn test_csv_import_column_index_out_of_bounds() {
let config = ImporterConfig::csv()
.account("Assets:Bank")
.currency("USD")
.date_column_index(0)
.narration_column_index(1)
.amount_column_index(99) .has_header(false)
.build().unwrap();
let csv_content = r"2024-01-15,Coffee,-5.00
";
let result = config.extract_from_string(csv_content).unwrap();
assert!(result.directives.is_empty());
assert_eq!(result.warnings.len(), 1);
assert!(result.warnings[0].contains("out of bounds"));
}
#[test]
fn test_csv_import_no_amount_column_error() {
let csv_config = CsvConfig {
date_column: ColumnSpec::Name("Date".to_string()),
date_format: "%Y-%m-%d".to_string(),
narration_column: Some(ColumnSpec::Name("Description".to_string())),
payee_column: None,
amount_column: None,
amount_format: None,
amount_locale: None,
debit_column: None,
credit_column: None,
has_header: true,
delimiter: ',',
skip_rows: 0,
invert_sign: false,
default_expense: None,
default_income: None,
mappings: Vec::new(),
regex_mappings: Vec::new(),
use_merchant_dict: false,
};
let importer = CsvImporter::new(ImporterConfig {
account: "Assets:Bank".to_string(),
amount_format: AmountFormat::default(),
currency: Some("USD".to_string()),
importer_type: ImporterType::Csv(csv_config.clone()),
});
let csv_content = r"Date,Description
2024-01-15,Coffee
";
let result = importer.extract_string(csv_content, &csv_config).unwrap();
assert!(result.directives.is_empty());
assert_eq!(result.warnings.len(), 1);
assert!(result.warnings[0].contains("No amount column"));
}
#[test]
fn test_csv_import_debit_only_column() {
let config = ImporterConfig::csv()
.account("Assets:Bank")
.currency("USD")
.date_column("Date")
.narration_column("Description")
.debit_column("Debit")
.build().unwrap();
let csv_content = r"Date,Description,Debit
2024-01-15,Withdrawal,100.00
";
let result = config.extract_from_string(csv_content).unwrap();
assert_eq!(result.directives.len(), 1);
if let Directive::Transaction(txn) = &result.directives[0] {
let amount = txn.postings[0].amount().unwrap();
assert_eq!(amount.number, Decimal::from_str("-100.00").unwrap());
}
}
#[test]
fn test_csv_import_credit_only_column() {
let config = ImporterConfig::csv()
.account("Assets:Bank")
.currency("USD")
.date_column("Date")
.narration_column("Description")
.credit_column("Credit")
.build().unwrap();
let csv_content = r"Date,Description,Credit
2024-01-15,Deposit,100.00
";
let result = config.extract_from_string(csv_content).unwrap();
assert_eq!(result.directives.len(), 1);
if let Directive::Transaction(txn) = &result.directives[0] {
let amount = txn.postings[0].amount().unwrap();
assert_eq!(amount.number, Decimal::from_str("100.00").unwrap());
}
}
#[test]
fn test_csv_import_empty_debit_credit() {
let config = ImporterConfig::csv()
.account("Assets:Bank")
.currency("USD")
.date_column("Date")
.narration_column("Description")
.debit_column("Debit")
.credit_column("Credit")
.build()
.unwrap();
let csv_content = r"Date,Description,Debit,Credit
2024-01-15,Empty both,,
";
let result = config.extract_from_string(csv_content).unwrap();
assert!(result.directives.is_empty());
}
#[test]
fn test_csv_import_with_positive_amount_sign() {
let config = ImporterConfig::csv()
.account("Assets:Bank")
.currency("USD")
.date_column("Date")
.narration_column("Description")
.amount_column("Amount")
.build()
.unwrap();
let csv_content = r"Date,Description,Amount
2024-01-15,Deposit,+100.00
";
let result = config.extract_from_string(csv_content).unwrap();
assert_eq!(result.directives.len(), 1);
if let Directive::Transaction(txn) = &result.directives[0] {
let amount = txn.postings[0].amount().unwrap();
assert_eq!(amount.number, Decimal::from(100));
}
}
#[test]
fn test_csv_import_with_mappings() {
let config = ImporterConfig::csv()
.account("Assets:Bank")
.currency("USD")
.date_column("Date")
.narration_column("Description")
.amount_column("Amount")
.mappings(vec![
("WHOLE FOODS".to_string(), "Expenses:Groceries".to_string()),
("NETFLIX".to_string(), "Expenses:Entertainment".to_string()),
])
.build()
.unwrap();
let csv_content = "Date,Description,Amount\n\
2024-01-15,WHOLE FOODS MARKET #123,-50.00\n\
2024-01-16,NETFLIX SUBSCRIPTION,-15.99\n\
2024-01-17,RANDOM STORE,-25.00\n";
let result = config.extract_from_string(csv_content).unwrap();
assert_eq!(result.directives.len(), 3);
if let Directive::Transaction(txn) = &result.directives[0] {
assert_eq!(txn.postings[1].account.as_str(), "Expenses:Groceries");
} else {
panic!("Expected transaction");
}
if let Directive::Transaction(txn) = &result.directives[1] {
assert_eq!(txn.postings[1].account.as_str(), "Expenses:Entertainment");
} else {
panic!("Expected transaction");
}
if let Directive::Transaction(txn) = &result.directives[2] {
assert_eq!(txn.postings[1].account.as_str(), "Expenses:Unknown");
} else {
panic!("Expected transaction");
}
}
#[test]
fn test_csv_import_mappings_case_insensitive() {
let config = ImporterConfig::csv()
.account("Assets:Bank")
.currency("USD")
.date_column("Date")
.narration_column("Description")
.amount_column("Amount")
.mappings(vec![(
"amazon".to_string(),
"Expenses:Shopping".to_string(),
)])
.build()
.unwrap();
let csv_content = "Date,Description,Amount\n\
2024-01-15,AMAZON MARKETPLACE,-30.00\n";
let result = config.extract_from_string(csv_content).unwrap();
assert_eq!(result.directives.len(), 1);
if let Directive::Transaction(txn) = &result.directives[0] {
assert_eq!(txn.postings[1].account.as_str(), "Expenses:Shopping");
} else {
panic!("Expected transaction");
}
}
#[test]
fn test_csv_import_mappings_payee_priority() {
let config = ImporterConfig::csv()
.account("Assets:Bank")
.currency("USD")
.date_column("Date")
.payee_column("Payee")
.narration_column("Description")
.amount_column("Amount")
.mappings(vec![(
"WALMART".to_string(),
"Expenses:Shopping".to_string(),
)])
.build()
.unwrap();
let csv_content = "Date,Payee,Description,Amount\n\
2024-01-15,Walmart,STORE #1234 PURCHASE,-75.00\n";
let result = config.extract_from_string(csv_content).unwrap();
assert_eq!(result.directives.len(), 1);
if let Directive::Transaction(txn) = &result.directives[0] {
assert_eq!(txn.postings[1].account.as_str(), "Expenses:Shopping");
} else {
panic!("Expected transaction");
}
}
#[test]
fn test_csv_import_custom_default_expense() {
let config = ImporterConfig::csv()
.account("Assets:Bank")
.currency("USD")
.date_column("Date")
.narration_column("Description")
.amount_column("Amount")
.default_expense("Expenses:Uncategorized")
.build()
.unwrap();
let csv_content = "Date,Description,Amount\n\
2024-01-15,Coffee Shop,-5.00\n";
let result = config.extract_from_string(csv_content).unwrap();
assert_eq!(result.directives.len(), 1);
if let Directive::Transaction(txn) = &result.directives[0] {
assert_eq!(txn.postings[1].account.as_str(), "Expenses:Uncategorized");
} else {
panic!("Expected transaction");
}
}
#[test]
fn test_csv_import_custom_default_income() {
let config = ImporterConfig::csv()
.account("Assets:Bank")
.currency("USD")
.date_column("Date")
.narration_column("Description")
.amount_column("Amount")
.default_income("Income:Other")
.build()
.unwrap();
let csv_content = "Date,Description,Amount\n\
2024-01-15,Deposit,100.00\n";
let result = config.extract_from_string(csv_content).unwrap();
assert_eq!(result.directives.len(), 1);
if let Directive::Transaction(txn) = &result.directives[0] {
assert_eq!(txn.postings[1].account.as_str(), "Income:Other");
} else {
panic!("Expected transaction");
}
}
#[test]
fn test_enriched_extraction_basic() {
let config = ImporterConfig::csv()
.account("Assets:Bank")
.currency("USD")
.date_column("Date")
.narration_column("Description")
.amount_column("Amount")
.build()
.unwrap();
let ImporterType::Csv(csv_config) = &config.importer_type;
let csv_config = csv_config.clone();
let importer = CsvImporter::new(config);
let csv_content =
"Date,Description,Amount\n2024-01-15,Coffee Shop,-5.00\n2024-01-16,Salary,2500.00\n";
let result = importer
.extract_string_enriched(csv_content, &csv_config)
.unwrap();
assert_eq!(result.entries.len(), 2);
for (directive, enrichment) in &result.entries {
assert!(matches!(
directive,
rustledger_core::Directive::Transaction(_)
));
assert!(enrichment.fingerprint.is_some());
}
}
#[test]
fn test_enriched_confidence_mapping_match_vs_default() {
let config = ImporterConfig::csv()
.account("Assets:Bank")
.currency("USD")
.date_column("Date")
.narration_column("Description")
.amount_column("Amount")
.mappings(vec![("coffee".to_string(), "Expenses:Dining".to_string())])
.build()
.unwrap();
let ImporterType::Csv(csv_config) = &config.importer_type;
let csv_config = csv_config.clone();
let importer = CsvImporter::new(config);
let csv_content = "Date,Description,Amount\n2024-01-15,Coffee Shop,-5.00\n2024-01-16,Random Store,-10.00\n";
let result = importer
.extract_string_enriched(csv_content, &csv_config)
.unwrap();
assert_eq!(result.entries.len(), 2);
let (_, enrichment0) = &result.entries[0];
assert!((enrichment0.confidence - 1.0).abs() < f64::EPSILON);
assert_eq!(
enrichment0.method,
rustledger_ops::enrichment::CategorizationMethod::Rule
);
let (_, enrichment1) = &result.entries[1];
assert!((enrichment1.confidence - 0.0).abs() < f64::EPSILON);
assert_eq!(
enrichment1.method,
rustledger_ops::enrichment::CategorizationMethod::Default
);
}
#[test]
fn test_enriched_merchant_dict_categorization() {
let config = ImporterConfig::csv()
.account("Assets:Bank")
.currency("USD")
.date_column("Date")
.narration_column("Description")
.amount_column("Amount")
.use_merchant_dict(true)
.build()
.unwrap();
let ImporterType::Csv(csv_config) = &config.importer_type;
let csv_config = csv_config.clone();
let importer = CsvImporter::new(config);
let csv_content = "Date,Description,Amount\n2024-01-15,AMAZON,-50.00\n";
let result = importer
.extract_string_enriched(csv_content, &csv_config)
.unwrap();
assert_eq!(result.entries.len(), 1);
let (_, enrichment) = &result.entries[0];
assert!(enrichment.fingerprint.is_some());
assert!(enrichment.confidence >= 0.0 && enrichment.confidence <= 1.0);
}
#[test]
fn test_enriched_warnings_propagated() {
let config = ImporterConfig::csv()
.account("Assets:Bank")
.currency("USD")
.date_column("Date")
.narration_column("Description")
.amount_column("Amount")
.build()
.unwrap();
let ImporterType::Csv(csv_config) = &config.importer_type;
let csv_config = csv_config.clone();
let importer = CsvImporter::new(config);
let csv_content =
"Date,Description,Amount\nnot-a-date,Coffee,-5.00\n2024-01-15,Valid,-10.00\n";
let result = importer
.extract_string_enriched(csv_content, &csv_config)
.unwrap();
assert_eq!(result.entries.len(), 1);
assert_eq!(result.warnings.len(), 1);
assert!(result.warnings[0].contains("failed to parse date"));
}
#[test]
fn test_csv_import_empty_mappings() {
let config = ImporterConfig::csv()
.account("Assets:Bank")
.currency("USD")
.date_column("Date")
.narration_column("Description")
.amount_column("Amount")
.mappings(vec![])
.build()
.unwrap();
let csv_content = "Date,Description,Amount\n\
2024-01-15,Test,-10.00\n";
let result = config.extract_from_string(csv_content).unwrap();
assert_eq!(result.directives.len(), 1);
if let Directive::Transaction(txn) = &result.directives[0] {
assert_eq!(txn.postings[1].account.as_str(), "Expenses:Unknown");
} else {
panic!("Expected transaction");
}
}
}