#![forbid(unsafe_code)]
#![warn(missing_docs)]
pub mod config;
pub mod csv_importer;
pub mod csv_inference;
pub mod ofx_importer;
pub mod registry;
use anyhow::Result;
use rustledger_core::Directive;
use rustledger_ops::enrichment::Enrichment;
use std::path::Path;
pub use config::ImporterConfig;
pub use ofx_importer::OfxImporter;
pub use registry::ImporterRegistry;
use rustledger_ops::fingerprint::Fingerprint;
pub(crate) fn directive_fingerprint(directive: &Directive) -> Option<Fingerprint> {
let Directive::Transaction(txn) = directive else {
return None;
};
let amount_str = txn.postings.first().and_then(|p| {
p.units
.as_ref()
.and_then(|u| u.number().map(|n| n.to_string()))
});
let mut text = String::new();
if let Some(ref payee) = txn.payee {
text.push_str(payee.as_str());
text.push(' ');
}
text.push_str(txn.narration.as_str());
Some(Fingerprint::compute(
&txn.date.to_string(),
amount_str.as_deref(),
&text,
))
}
#[derive(Debug, Clone)]
pub struct ImportResult {
pub directives: Vec<Directive>,
pub warnings: Vec<String>,
}
impl ImportResult {
pub const fn new(directives: Vec<Directive>) -> Self {
Self {
directives,
warnings: Vec::new(),
}
}
pub const fn empty() -> Self {
Self {
directives: Vec::new(),
warnings: Vec::new(),
}
}
pub fn with_warning(mut self, warning: impl Into<String>) -> Self {
self.warnings.push(warning.into());
self
}
}
#[derive(Debug, Clone)]
pub struct EnrichedImportResult {
pub entries: Vec<(Directive, Enrichment)>,
pub warnings: Vec<String>,
}
impl EnrichedImportResult {
pub const fn new(entries: Vec<(Directive, Enrichment)>) -> Self {
Self {
entries,
warnings: Vec::new(),
}
}
pub const fn empty() -> Self {
Self {
entries: Vec::new(),
warnings: Vec::new(),
}
}
pub fn with_warning(mut self, warning: impl Into<String>) -> Self {
self.warnings.push(warning.into());
self
}
#[must_use]
pub fn into_import_result(self) -> ImportResult {
ImportResult {
directives: self.entries.into_iter().map(|(d, _)| d).collect(),
warnings: self.warnings,
}
}
}
impl From<EnrichedImportResult> for ImportResult {
fn from(enriched: EnrichedImportResult) -> Self {
enriched.into_import_result()
}
}
pub trait Importer: Send + Sync {
fn name(&self) -> &str;
fn identify(&self, path: &Path) -> bool;
fn extract(&self, path: &Path) -> Result<ImportResult>;
fn description(&self) -> &str {
self.name()
}
}
pub fn extract_from_file(path: &Path, config: &ImporterConfig) -> Result<ImportResult> {
config.extract(path)
}
pub fn extract_from_string(content: &str, config: &ImporterConfig) -> Result<ImportResult> {
config.extract_from_string(content)
}
pub fn auto_extract(
path: &std::path::Path,
account: &str,
currency: &str,
) -> Result<EnrichedImportResult> {
if path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("ofx") || ext.eq_ignore_ascii_case("qfx"))
{
let ofx = ofx_importer::OfxImporter::new(account, currency);
return ofx.extract_from_string_enriched(&std::fs::read_to_string(path)?);
}
let content = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("Failed to read file {}: {e}", path.display()))?;
let inferred = csv_inference::infer_csv_config(&content)
.ok_or_else(|| anyhow::anyhow!("Could not infer CSV format from {}", path.display()))?;
let csv_config = inferred.to_csv_config();
let importer_config = config::ImporterConfig {
account: account.to_string(),
currency: Some(currency.to_string()),
amount_format: config::AmountFormat::default(),
importer_type: config::ImporterType::Csv(csv_config.clone()),
};
let importer = csv_importer::CsvImporter::new(importer_config);
importer.extract_string_enriched(&content, &csv_config)
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal::Decimal;
use rustledger_core::{Amount, Posting, Transaction};
use std::str::FromStr;
#[test]
fn test_import_result_new() {
let directives = vec![];
let result = ImportResult::new(directives);
assert!(result.directives.is_empty());
assert!(result.warnings.is_empty());
}
#[test]
fn test_import_result_empty() {
let result = ImportResult::empty();
assert!(result.directives.is_empty());
assert!(result.warnings.is_empty());
}
#[test]
fn test_import_result_with_warning() {
let result = ImportResult::empty().with_warning("Test warning");
assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0], "Test warning");
}
#[test]
fn test_import_result_multiple_warnings() {
let result = ImportResult::empty()
.with_warning("Warning 1")
.with_warning("Warning 2");
assert_eq!(result.warnings.len(), 2);
assert_eq!(result.warnings[0], "Warning 1");
assert_eq!(result.warnings[1], "Warning 2");
}
#[test]
fn test_import_result_with_directives() {
let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
let txn = Transaction::new(date, "Test transaction")
.with_posting(Posting::new(
"Assets:Bank",
Amount::new(Decimal::from_str("100").unwrap(), "USD"),
))
.with_posting(Posting::new(
"Expenses:Food",
Amount::new(Decimal::from_str("-100").unwrap(), "USD"),
));
let directives = vec![Directive::Transaction(txn)];
let result = ImportResult::new(directives);
assert_eq!(result.directives.len(), 1);
}
#[test]
fn test_extract_from_string_csv() {
let config = ImporterConfig::csv()
.account("Assets:Bank:Checking")
.currency("USD")
.date_column("Date")
.narration_column("Description")
.amount_column("Amount")
.build()
.unwrap();
let csv_content = "Date,Description,Amount\n2024-01-15,Coffee,-5.00\n";
let result = extract_from_string(csv_content, &config).unwrap();
assert_eq!(result.directives.len(), 1);
}
#[test]
fn test_extract_from_string_empty_csv() {
let config = ImporterConfig::csv()
.account("Assets:Bank:Checking")
.currency("USD")
.date_column("Date")
.narration_column("Description")
.amount_column("Amount")
.build()
.unwrap();
let csv_content = "Date,Description,Amount\n";
let result = extract_from_string(csv_content, &config).unwrap();
assert!(result.directives.is_empty());
}
#[test]
fn test_import_result_debug() {
let result = ImportResult::empty();
let debug_str = format!("{result:?}");
assert!(debug_str.contains("ImportResult"));
}
#[test]
fn test_import_result_clone() {
let result = ImportResult::empty().with_warning("Test");
let cloned = result.clone();
assert_eq!(result.warnings.len(), 1);
assert_eq!(cloned.warnings.len(), 1);
}
fn make_test_enrichment(index: usize, confidence: f64) -> Enrichment {
Enrichment {
directive_index: index,
confidence,
method: rustledger_ops::enrichment::CategorizationMethod::Rule,
alternatives: vec![],
fingerprint: None,
}
}
fn make_test_txn_directive() -> Directive {
let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
let txn = Transaction::new(date, "Test")
.with_posting(Posting::new(
"Assets:Bank",
Amount::new(Decimal::from_str("-50").unwrap(), "USD"),
))
.with_posting(Posting::new(
"Expenses:Food",
Amount::new(Decimal::from_str("50").unwrap(), "USD"),
));
Directive::Transaction(txn)
}
#[test]
fn test_enriched_import_result_new() {
let directive = make_test_txn_directive();
let enrichment = make_test_enrichment(0, 0.95);
let entries = vec![(directive, enrichment)];
let result = EnrichedImportResult::new(entries);
assert_eq!(result.entries.len(), 1);
assert!(result.warnings.is_empty());
}
#[test]
fn test_enriched_import_result_empty() {
let result = EnrichedImportResult::empty();
assert!(result.entries.is_empty());
assert!(result.warnings.is_empty());
}
#[test]
fn test_enriched_import_result_with_warning() {
let result = EnrichedImportResult::empty().with_warning("Test warning");
assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0], "Test warning");
}
#[test]
fn test_enriched_import_result_multiple_warnings() {
let result = EnrichedImportResult::empty()
.with_warning("Warning 1")
.with_warning("Warning 2");
assert_eq!(result.warnings.len(), 2);
}
#[test]
fn test_enriched_into_import_result() {
let d1 = make_test_txn_directive();
let d2 = make_test_txn_directive();
let entries = vec![
(d1, make_test_enrichment(0, 0.95)),
(d2, make_test_enrichment(1, 0.3)),
];
let enriched = EnrichedImportResult::new(entries).with_warning("A warning");
let plain = enriched.into_import_result();
assert_eq!(plain.directives.len(), 2);
assert_eq!(plain.warnings.len(), 1);
assert_eq!(plain.warnings[0], "A warning");
}
#[test]
fn test_enriched_from_into_import_result() {
let entries = vec![(make_test_txn_directive(), make_test_enrichment(0, 1.0))];
let enriched = EnrichedImportResult::new(entries);
let plain: ImportResult = enriched.into();
assert_eq!(plain.directives.len(), 1);
assert!(plain.warnings.is_empty());
}
#[test]
fn test_enriched_import_result_debug_and_clone() {
let result = EnrichedImportResult::empty().with_warning("Test");
let debug_str = format!("{result:?}");
assert!(debug_str.contains("EnrichedImportResult"));
let cloned = result;
assert_eq!(cloned.warnings.len(), 1);
}
#[test]
fn test_directive_fingerprint_for_transaction() {
let directive = make_test_txn_directive();
let fp = directive_fingerprint(&directive);
assert!(fp.is_some());
}
#[test]
fn test_directive_fingerprint_none_for_non_transaction() {
let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
let balance = rustledger_core::Balance::new(
date,
"Assets:Bank",
Amount::new(Decimal::from_str("1000").unwrap(), "USD"),
);
let directive = Directive::Balance(balance);
let fp = directive_fingerprint(&directive);
assert!(fp.is_none());
}
}