mod config;
mod duplicate;
mod suggest;
use crate::cmd::completions::ShellType;
use anyhow::{Context, Result, anyhow};
use clap::Parser;
use config::{
apply_column, build_config_from_entry, find_importers_config, find_matching_importers,
load_importers_config,
};
#[cfg(feature = "python-plugin-wasm")]
use config::expand_tilde;
use duplicate::load_existing_transactions;
use format_num_pattern::Locale;
use rustledger_core::{Directive, FormatConfig};
use rustledger_importer::config::CsvConfigBuilder;
use rustledger_importer::{Importer, ImporterConfig, ImporterRegistry, csv_importer::CsvImporter};
use rustledger_parser::format::canonicalize_directives;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::Arc;
#[derive(Parser, Debug)]
#[command(name = "extract")]
#[command(author, version, about, long_about = None)]
pub struct Args {
#[arg(long, value_name = "SHELL", hide = true)]
pub generate_completions: Option<ShellType>,
#[arg(value_name = "FILE")]
pub file: Option<PathBuf>,
#[arg(long, short = 'i')]
pub importer: Option<String>,
#[arg(long, alias = "importers-config")]
pub config: Option<PathBuf>,
#[arg(long = "list-importers")]
pub list_importers: bool,
#[arg(short, long, default_value = "Assets:Bank:Checking")]
pub account: String,
#[arg(short, long, default_value = "USD")]
pub currency: String,
#[arg(long, default_value = "Date")]
pub date_column: String,
#[arg(long, default_value = "%Y-%m-%d")]
pub date_format: String,
#[arg(long, default_value = "Description")]
pub narration_column: String,
#[arg(long)]
pub payee_column: Option<String>,
#[arg(long, default_value = "Amount")]
pub amount_column: String,
#[arg(long)]
pub currency_column: Option<String>,
#[arg(long)]
pub amount_locale: Option<String>,
#[arg(long)]
pub amount_format: Option<String>,
#[arg(long)]
pub debit_column: Option<String>,
#[arg(long)]
pub credit_column: Option<String>,
#[arg(long, default_value = ",")]
pub delimiter: char,
#[arg(long, default_value = "0")]
pub skip_rows: usize,
#[arg(long)]
pub invert_sign: bool,
#[arg(long)]
pub include_zero_amounts: bool,
#[arg(long, conflicts_with_all = [
"date_column", "date_format", "narration_column", "amount_column",
"delimiter", "skip_rows", "no_header", "debit_column", "credit_column",
"payee_column", "currency_column",
])]
pub auto: bool,
#[arg(long)]
pub no_header: bool,
#[arg(long)]
pub use_merchant_dict: bool,
#[arg(short, long, value_name = "FILE")]
pub output: Option<PathBuf>,
#[arg(long, value_name = "FILE")]
pub existing: Option<PathBuf>,
#[arg(long, requires = "existing")]
pub suggest_categories: bool,
#[arg(long, value_name = "AMOUNT")]
pub balance: Option<String>,
#[arg(long, value_name = "DATE")]
pub balance_date: Option<String>,
#[arg(long, value_name = "PATH")]
pub wasm_importer: Vec<PathBuf>,
#[arg(long, value_name = "DIR")]
pub wasm_importer_dir: Vec<PathBuf>,
}
pub fn list_importers(args: &Args) -> Result<()> {
let mut stdout = io::stdout().lock();
list_importers_with_writer(args, &mut stdout)
}
pub fn list_importers_with_writer<W: Write>(args: &Args, out: &mut W) -> Result<()> {
if let Some(config_path) = find_importers_config(args.config.as_deref())? {
let config = load_importers_config(&config_path)?;
if config.importers.is_empty() {
writeln!(out, "No TOML profiles in {}", config_path.display())?;
} else {
writeln!(out, "TOML profiles in {}:", config_path.display())?;
for imp in &config.importers {
if let Some(pattern) = &imp.filename_pattern {
writeln!(
out,
" {} (pattern: {}) -> {}",
imp.name,
pattern,
imp.account.as_deref().unwrap_or("(default)")
)?;
} else {
writeln!(
out,
" {} -> {}",
imp.name,
imp.account.as_deref().unwrap_or("(default)")
)?;
}
}
}
} else {
writeln!(
out,
"(no importers.toml found — listing registered engines only)"
)?;
}
writeln!(out)?;
let registry = build_registry(args)?;
writeln!(out, "Registered importer engines:")?;
for (name, description) in registry.list_importers() {
writeln!(out, " {name} - {description}")?;
}
Ok(())
}
fn select_importer(registry: &ImporterRegistry, file: &Path, args: &Args) -> Arc<dyn Importer> {
if args.importer.is_some() {
Arc::new(CsvImporter)
} else {
registry
.identify(file)
.unwrap_or_else(|| Arc::new(CsvImporter) as Arc<dyn Importer>)
}
}
#[cfg(feature = "python-plugin-wasm")]
fn resolve_scan_dirs(args: &Args) -> Result<Vec<PathBuf>> {
if !args.wasm_importer_dir.is_empty() {
return Ok(args.wasm_importer_dir.clone());
}
match args.config.as_deref() {
Some(path) => resolve_scan_dirs_explicit(path),
None => Ok(resolve_scan_dirs_implicit()),
}
}
#[cfg(feature = "python-plugin-wasm")]
fn resolve_scan_dirs_explicit(path: &Path) -> Result<Vec<PathBuf>> {
let cfg_path = find_importers_config(Some(path))?
.ok_or_else(|| anyhow!("Importers config not found: {}", path.display()))?;
let cfg = load_importers_config(&cfg_path)?;
Ok(cfg
.wasm_importer_dir
.into_vec()
.into_iter()
.map(|p| expand_tilde(&p))
.collect())
}
#[cfg(feature = "python-plugin-wasm")]
fn resolve_scan_dirs_implicit() -> Vec<PathBuf> {
let cfg_path = match find_importers_config(None) {
Ok(Some(p)) => p,
Ok(None) | Err(_) => return Vec::new(),
};
match load_importers_config(&cfg_path) {
Ok(cfg) => cfg
.wasm_importer_dir
.into_vec()
.into_iter()
.map(|p| expand_tilde(&p))
.collect(),
Err(e) => {
eprintln!(
"warning: implicit importers.toml at {} failed to parse: {e:#}; ignoring wasm_importer_dir",
cfg_path.display()
);
Vec::new()
}
}
}
#[cfg_attr(not(feature = "python-plugin-wasm"), allow(unused_variables))]
fn build_registry(args: &Args) -> Result<ImporterRegistry> {
let mut registry = ImporterRegistry::new();
#[cfg(feature = "python-plugin-wasm")]
{
for path in &args.wasm_importer {
let name = registry
.register_wasm_from_path(path)
.with_context(|| format!("failed to load WASM importer {}", path.display()))?;
eprintln!("loaded WASM importer `{name}` from {}", path.display());
}
let scan_dirs: Vec<PathBuf> = resolve_scan_dirs(args)?;
for dir in &scan_dirs {
let report = registry.register_wasm_dir(dir).with_context(|| {
format!("failed to scan WASM importer directory {}", dir.display())
})?;
if !report.loaded.is_empty() || !report.failures.is_empty() {
eprintln!(
"WASM importer scan {}: loaded {}, failed {}",
dir.display(),
report.loaded.len(),
report.failures.len(),
);
}
for (failed_path, err) in &report.failures {
eprintln!(" warning: failed to load {}: {err}", failed_path.display());
}
}
}
registry.register(rustledger_importer::OfxImporter);
registry.register(rustledger_importer::csv_importer::CsvImporter);
Ok(registry)
}
fn parse_amount_locale(name: &str) -> Result<Locale> {
Locale::from_str(name).map_err(|_| anyhow!("{name} is not a valid locale"))
}
pub fn run(args: &Args, file: &Path) -> Result<()> {
let mut stdout = io::stdout().lock();
run_with_writer(args, file, &mut stdout)
}
pub fn run_with_writer<W: Write>(args: &Args, file: &Path, out: &mut W) -> Result<()> {
let registry = build_registry(args)?;
let importer = select_importer(®istry, file, args);
let dispatcher_needs_minimal_config = importer.name() != "CSV";
let (config, fallback_accounts) = if dispatcher_needs_minimal_config {
let cfg = rustledger_importer::ImporterConfig {
account: args.account.clone(),
currency: Some(args.currency.clone()),
importer_type: rustledger_importer::config::ImporterType::Csv(
rustledger_importer::config::CsvConfig::default(),
),
};
(
cfg,
vec!["Expenses:Unknown".to_string(), "Income:Unknown".to_string()],
)
} else {
let config = if let Some(ref importer_name) = args.importer {
let config_path = find_importers_config(args.config.as_deref())?
.ok_or_else(|| anyhow!(
"No importers.toml found. Create one in the current directory or at ~/.config/rledger/importers.toml"
))?;
let importers_file = load_importers_config(&config_path)?;
let entry = importers_file
.importers
.iter()
.find(|e| e.name == *importer_name)
.ok_or_else(|| {
let available: Vec<&str> = importers_file
.importers
.iter()
.map(|e| e.name.as_str())
.collect();
anyhow!(
"Importer '{}' not found in {}. Available: {}",
importer_name,
config_path.display(),
available.join(", ")
)
})?;
eprintln!(
"Using importer '{}' from {}",
importer_name,
config_path.display()
);
build_config_from_entry(entry)?
} else if args.config.is_some() {
let config_path = find_importers_config(args.config.as_deref())?
.ok_or_else(|| anyhow!(
"No importers.toml found. Create one in the current directory or at ~/.config/rledger/importers.toml"
))?;
let importers_file = load_importers_config(&config_path)?;
if importers_file.importers.is_empty() {
return Err(anyhow!("No importers defined in {}", config_path.display()));
}
let filename = file
.file_name()
.map(|s| s.to_string_lossy())
.unwrap_or_default();
let matches = find_matching_importers(&importers_file, &filename);
let entry = match matches.len() {
1 => {
eprintln!(
"Auto-identified importer '{}' from filename pattern",
matches[0].name
);
matches[0]
}
0 if importers_file.importers.len() == 1 => {
&importers_file.importers[0]
}
0 => {
let available: Vec<&str> = importers_file
.importers
.iter()
.map(|e| e.name.as_str())
.collect();
return Err(anyhow!(
"No importer matches file '{}'. Use --importer to select one: {}",
filename,
available.join(", ")
));
}
_ => {
let names: Vec<&str> = matches.iter().map(|e| e.name.as_str()).collect();
return Err(anyhow!(
"Multiple importers match file '{}': {}. Use --importer to select one.",
filename,
names.join(", ")
));
}
};
eprintln!(
"Using importer '{}' from {}",
entry.name,
config_path.display()
);
build_config_from_entry(entry)?
} else if args.auto {
let content = std::fs::read_to_string(file)
.with_context(|| format!("Failed to read file: {}", file.display()))?;
let inferred = rustledger_importer::csv_inference::infer_csv_config(&content)
.ok_or_else(|| anyhow!(
"Could not auto-detect CSV format for {}. Try specifying columns explicitly.",
file.display()
))?;
eprintln!(
"Auto-detected format (confidence: {:.0}%):",
inferred.confidence * 100.0
);
eprintln!(" delimiter: {:?}", inferred.delimiter);
eprintln!(" date_format: {}", inferred.date_format);
eprintln!(" has_header: {}", inferred.has_header);
let mut csv_config = inferred.to_csv_config();
if args.include_zero_amounts {
csv_config.skip_zero_amounts = false;
}
if args.use_merchant_dict {
csv_config.use_merchant_dict = true;
}
if let Some(locale) = &args.amount_locale {
let locale = parse_amount_locale(locale)?;
csv_config.amount_locale = Some(locale);
eprintln!(" amount_locale: {locale:?} (from --amount-locale)");
} else if let Some(locale) = inferred.amount_locale {
eprintln!(" amount_locale: {locale:?} (inferred)");
}
if let Some(format) = &args.amount_format {
csv_config.amount_format = Some(format.clone());
}
ImporterConfig {
account: args.account.clone(),
currency: Some(args.currency.clone()),
importer_type: rustledger_importer::config::ImporterType::Csv(csv_config),
}
} else {
let mut builder = ImporterConfig::csv()
.account(&args.account)
.currency(&args.currency)
.date_format(&args.date_format)
.delimiter(args.delimiter)
.skip_rows(args.skip_rows)
.invert_sign(args.invert_sign)
.skip_zero_amounts(!args.include_zero_amounts)
.has_header(!args.no_header)
.use_merchant_dict(args.use_merchant_dict);
builder = apply_column(
builder,
&args.date_column,
CsvConfigBuilder::date_column_index,
|b, n| b.date_column(n),
);
builder = apply_column(
builder,
&args.narration_column,
CsvConfigBuilder::narration_column_index,
|b, n| b.narration_column(n),
);
builder = apply_column(
builder,
&args.amount_column,
CsvConfigBuilder::amount_column_index,
|b, n| b.amount_column(n),
);
if let Some(payee) = &args.payee_column {
builder = apply_column(
builder,
payee,
CsvConfigBuilder::payee_column_index,
|b, n| b.payee_column(n),
);
}
if let Some(currency_col) = &args.currency_column {
builder = apply_column(
builder,
currency_col,
CsvConfigBuilder::currency_column_index,
|b, n| b.currency_column(n),
);
}
if let Some(debit) = &args.debit_column {
builder = builder.debit_column(debit);
}
if let Some(credit) = &args.credit_column {
builder = builder.credit_column(credit);
}
if let Some(locale) = &args.amount_locale {
builder = builder.amount_locale(parse_amount_locale(locale)?);
}
if let Some(format) = &args.amount_format {
builder = builder.amount_format(format);
}
builder.build()?
};
let config = if args.include_zero_amounts {
let mut config = config;
let rustledger_importer::config::ImporterType::Csv(csv) = &mut config.importer_type;
csv.skip_zero_amounts = false;
config
} else {
config
};
let rustledger_importer::config::ImporterType::Csv(csv) = &config.importer_type;
let fallbacks = vec![
csv.default_expense
.clone()
.unwrap_or_else(|| "Expenses:Unknown".to_string()),
csv.default_income
.clone()
.unwrap_or_else(|| "Income:Unknown".to_string()),
];
(config, fallbacks)
};
let result = importer.extract(file, &config)?;
for warning in &result.warnings {
eprintln!("warning: {warning}");
}
let extracted_txns = result
.directives
.iter()
.filter(|d| matches!(d, Directive::Transaction(_)))
.count();
if extracted_txns == 0 {
anyhow::bail!(
"no transactions were extracted from {}\n \
the file may not match a recognized importer format, be empty, or \
use unexpected columns\n \
try: --auto, an explicit importer (--importer), or column flags \
(--date-column, --amount-column, …)",
file.display()
);
}
let directives = if let Some(ref existing_path) = args.existing {
let existing_txns = load_existing_transactions(existing_path)?;
let before_count = result.directives.len();
let dedup_config = rustledger_ops::dedup::FuzzyDedupConfig::default();
let mut filtered: Vec<_> = result
.directives
.into_iter()
.filter(|d| {
if let Directive::Transaction(txn) = d {
!rustledger_ops::dedup::is_duplicate(txn, &existing_txns, &dedup_config)
} else {
true
}
})
.collect();
let dupes = before_count - filtered.len();
if dupes > 0 {
eprintln!("Filtered {dupes} duplicate transaction(s)");
}
if args.suggest_categories {
suggest::apply_ml_suggestions_with_summary(
&mut filtered,
&existing_txns,
&fallback_accounts,
)?;
}
filtered
} else {
result.directives
};
let directives = if let Some(ref balance_amount) = args.balance {
use rust_decimal::Decimal;
use std::str::FromStr;
let amount = Decimal::from_str(balance_amount)
.with_context(|| format!("Invalid balance amount: {balance_amount}"))?;
let date_str = args
.balance_date
.clone()
.unwrap_or_else(|| jiff::Zoned::now().date().to_string());
let date = date_str
.parse::<rustledger_core::NaiveDate>()
.with_context(|| format!("Invalid balance date: {date_str}"))?;
let balance = rustledger_ops::reconcile::StatementBalance {
date,
account: args.account.clone(),
number: amount,
currency: args.currency.clone(),
};
let balance_directive = rustledger_ops::reconcile::create_balance_directive(&balance);
let mut with_balance = directives;
with_balance.push(balance_directive);
with_balance
} else {
directives
};
let fmt_config = FormatConfig::default();
let formatted = canonicalize_directives(directives.iter(), &fmt_config)
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
if let Some(ref output_path) = args.output {
let mut out_file = fs::File::create(output_path)
.with_context(|| format!("Failed to create output file: {}", output_path.display()))?;
out_file.write_all(formatted.as_bytes())?;
eprintln!("Wrote output to {}", output_path.display());
} else {
out.write_all(formatted.as_bytes())?;
}
let written_txns = directives
.iter()
.filter(|d| matches!(d, Directive::Transaction(_)))
.count();
eprintln!(
"Extracted {written_txns} transactions from {}",
file.display()
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::config::{ImporterEntry, parse_column_value};
use super::*;
use rustledger_importer::config::ImporterType;
use std::collections::HashMap;
fn write_temp_config(content: &str) -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("importers.toml");
std::fs::write(&path, content).unwrap();
(dir, path)
}
#[test]
fn test_load_importers_config_basic() {
let (_dir, path) = write_temp_config(
r#"
[[importers]]
name = "chase"
account = "Assets:Bank:Chase"
date_column = "Transaction Date"
amount_column = "Amount"
"#,
);
let config = load_importers_config(&path).unwrap();
assert_eq!(config.importers.len(), 1);
assert_eq!(config.importers[0].name, "chase");
assert_eq!(
config.importers[0].account.as_deref(),
Some("Assets:Bank:Chase")
);
}
#[test]
fn test_load_importers_config_with_mappings() {
let (_dir, path) = write_temp_config(
r#"
[[importers]]
name = "checking"
account = "Assets:Bank:Checking"
[importers.mappings]
"AMAZON" = "Expenses:Shopping"
"WHOLE FOODS" = "Expenses:Groceries"
"#,
);
let config = load_importers_config(&path).unwrap();
assert_eq!(config.importers[0].mappings.len(), 2);
assert_eq!(
config.importers[0].mappings.get("AMAZON"),
Some(&"Expenses:Shopping".to_string())
);
}
#[test]
fn test_load_importers_config_multiple_importers() {
let (_dir, path) = write_temp_config(
r#"
[[importers]]
name = "checking"
account = "Assets:Bank:Checking"
[[importers]]
name = "credit_card"
account = "Liabilities:CreditCard"
invert_amounts = true
"#,
);
let config = load_importers_config(&path).unwrap();
assert_eq!(config.importers.len(), 2);
assert_eq!(config.importers[1].name, "credit_card");
assert_eq!(config.importers[1].invert_amounts, Some(true));
}
#[test]
fn test_load_importers_config_integer_columns() {
let (_dir, path) = write_temp_config(
r#"
[[importers]]
name = "noheader"
account = "Assets:Bank"
date_column = 0
amount_column = 3
narration_column = 1
"#,
);
let config = load_importers_config(&path).unwrap();
let entry = &config.importers[0];
assert_eq!(
parse_column_value(entry.date_column.as_ref().unwrap()),
Some("0".to_string())
);
assert_eq!(
parse_column_value(entry.amount_column.as_ref().unwrap()),
Some("3".to_string())
);
}
#[test]
fn test_cli_numeric_column_args_extract_by_index() {
use clap::Parser;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("noheader.csv");
std::fs::write(&path, "2024-01-15,Coffee,-5.00\n2024-01-16,Lunch,-12.00\n").unwrap();
let args = Args::parse_from([
"extract",
"--no-header",
"--date-column",
"0",
"--payee-column",
"1",
"--amount-column",
"2",
path.to_str().unwrap(),
]);
let mut out = Vec::new();
run_with_writer(&args, &path, &mut out).unwrap();
let text = String::from_utf8(out).unwrap();
assert!(text.contains("Coffee"), "first row not imported: {text}");
assert!(text.contains("-5.00"), "first amount missing: {text}");
assert!(text.contains("Lunch"), "second row not imported: {text}");
assert_eq!(
text.matches("2024-01-").count(),
2,
"both rows should import via positional indices: {text}"
);
}
#[test]
fn test_load_importers_config_invalid_toml() {
let (_dir, path) = write_temp_config("this is not valid toml [[[");
assert!(load_importers_config(&path).is_err());
}
#[test]
fn test_load_importers_config_missing_file() {
let path = PathBuf::from("/nonexistent/importers.toml");
assert!(load_importers_config(&path).is_err());
}
#[test]
fn test_build_config_from_entry_basic() {
let entry = ImporterEntry {
name: "test".to_string(),
account: Some("Assets:Bank:Test".to_string()),
currency: Some("EUR".to_string()),
date_column: Some(toml::Value::String("Date".to_string())),
date_format: Some("%m/%d/%Y".to_string()),
narration_column: Some(toml::Value::String("Description".to_string())),
payee_column: None,
amount_column: Some(toml::Value::String("Amount".to_string())),
currency_column: None,
debit_column: None,
credit_column: None,
secondary_date_column: None,
secondary_date_format: None,
secondary_date_key: None,
amount_locale: None,
amount_format: None,
delimiter: None,
skip_rows: None,
skip_header: None,
invert_amounts: None,
default_expense: None,
default_income: None,
mappings: HashMap::new(),
filename_pattern: None,
use_merchant_dict: None,
};
let config = build_config_from_entry(&entry).unwrap();
assert_eq!(config.account, "Assets:Bank:Test");
assert_eq!(config.currency, Some("EUR".to_string()));
}
#[test]
fn test_build_config_from_entry_with_mappings() {
let mut mappings = HashMap::new();
mappings.insert("AMAZON".to_string(), "Expenses:Shopping".to_string());
mappings.insert("WHOLE FOODS".to_string(), "Expenses:Groceries".to_string());
let entry = ImporterEntry {
name: "test".to_string(),
account: Some("Assets:Bank".to_string()),
currency: None,
date_column: None,
date_format: None,
narration_column: None,
payee_column: None,
amount_column: None,
currency_column: None,
debit_column: None,
credit_column: None,
secondary_date_column: None,
secondary_date_format: None,
secondary_date_key: None,
amount_locale: None,
amount_format: None,
delimiter: None,
skip_rows: None,
skip_header: None,
invert_amounts: None,
default_expense: None,
default_income: None,
mappings,
filename_pattern: None,
use_merchant_dict: None,
};
let config = build_config_from_entry(&entry).unwrap();
let ImporterType::Csv(csv_config) = &config.importer_type;
assert_eq!(csv_config.mappings.len(), 2);
assert_eq!(csv_config.mappings[0].0, "whole foods");
assert_eq!(csv_config.mappings[1].0, "amazon");
}
#[test]
fn test_build_config_from_entry_with_default_expense() {
let entry = ImporterEntry {
name: "test".to_string(),
account: Some("Assets:Bank".to_string()),
currency: None,
date_column: None,
date_format: None,
narration_column: None,
payee_column: None,
amount_column: None,
currency_column: None,
debit_column: None,
credit_column: None,
secondary_date_column: None,
secondary_date_format: None,
secondary_date_key: None,
amount_locale: None,
amount_format: None,
delimiter: None,
skip_rows: None,
skip_header: None,
invert_amounts: None,
default_expense: Some("Expenses:Uncategorized".to_string()),
default_income: Some("Income:Other".to_string()),
mappings: HashMap::new(),
filename_pattern: None,
use_merchant_dict: None,
};
let config = build_config_from_entry(&entry).unwrap();
let ImporterType::Csv(csv_config) = &config.importer_type;
assert_eq!(
csv_config.default_expense.as_deref(),
Some("Expenses:Uncategorized")
);
assert_eq!(csv_config.default_income.as_deref(), Some("Income:Other"));
}
#[test]
fn test_build_config_from_entry_all_options() {
let entry = ImporterEntry {
name: "full".to_string(),
account: Some("Assets:Bank".to_string()),
currency: Some("GBP".to_string()),
date_column: Some(toml::Value::Integer(0)),
date_format: Some("%d/%m/%Y".to_string()),
narration_column: Some(toml::Value::Integer(2)),
payee_column: Some(toml::Value::String("Payee".to_string())),
amount_column: None,
currency_column: None,
debit_column: Some(toml::Value::String("Debit".to_string())),
credit_column: Some(toml::Value::String("Credit".to_string())),
secondary_date_column: Some("Settle Date".to_string()),
secondary_date_format: None,
secondary_date_key: None,
amount_locale: None,
amount_format: None,
delimiter: Some(";".to_string()),
skip_rows: Some(2),
skip_header: Some(true),
invert_amounts: Some(true),
default_expense: None,
default_income: None,
mappings: HashMap::new(),
filename_pattern: None,
use_merchant_dict: None,
};
let config = build_config_from_entry(&entry).unwrap();
assert_eq!(config.currency, Some("GBP".to_string()));
let ImporterType::Csv(csv_config) = &config.importer_type;
assert_eq!(csv_config.delimiter, ';');
assert_eq!(csv_config.skip_rows, 2);
assert!(!csv_config.has_header); assert!(csv_config.invert_sign);
let sd = csv_config
.secondary_date
.as_ref()
.expect("secondary_date_column should produce a secondary date");
assert_eq!(sd.format, "%d/%m/%Y");
assert_eq!(sd.meta_key, "settle_date");
}
#[test]
fn test_find_importers_config_explicit_missing_returns_error() {
let result = find_importers_config(Some(Path::new("/nonexistent/importers.toml")));
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("Importers config not found"));
}
#[test]
fn test_find_importers_config_explicit_exists() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("importers.toml");
std::fs::write(&path, "[[importers]]\nname = \"test\"\n").unwrap();
let result = find_importers_config(Some(&path)).unwrap();
assert_eq!(result, Some(path));
}
#[test]
fn test_find_importers_config_none_returns_ok() {
let result = find_importers_config(None);
assert!(result.is_ok());
}
#[test]
fn test_end_to_end_extract_with_config() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("importers.toml");
std::fs::write(
&config_path,
r#"
[[importers]]
name = "mybank"
account = "Assets:Bank:MyBank"
currency = "USD"
date_column = "Date"
narration_column = "Description"
amount_column = "Amount"
default_expense = "Expenses:Uncategorized"
[importers.mappings]
"GROCERY" = "Expenses:Food"
"#,
)
.unwrap();
let csv_path = dir.path().join("statement.csv");
std::fs::write(
&csv_path,
"Date,Description,Amount\n\
2024-01-15,GROCERY STORE,-50.00\n\
2024-01-16,RANDOM PURCHASE,-25.00\n",
)
.unwrap();
let importers_file = load_importers_config(&config_path).unwrap();
let entry = importers_file
.importers
.iter()
.find(|e| e.name == "mybank")
.unwrap();
let config = build_config_from_entry(entry).unwrap();
let result = rustledger_importer::csv_importer::CsvImporter
.extract_file(&csv_path, &config)
.unwrap();
assert_eq!(result.directives.len(), 2);
if let rustledger_core::Directive::Transaction(txn) = &result.directives[0] {
assert_eq!(txn.postings[0].account.as_str(), "Assets:Bank:MyBank");
assert_eq!(txn.postings[1].account.as_str(), "Expenses:Food");
} else {
panic!("Expected transaction");
}
if let rustledger_core::Directive::Transaction(txn) = &result.directives[1] {
assert_eq!(txn.postings[1].account.as_str(), "Expenses:Uncategorized");
} else {
panic!("Expected transaction");
}
}
#[test]
fn test_select_importer_csv_extension_picks_csv() {
let registry = ImporterRegistry::with_builtins();
let args = Args::parse_from(["extract", "ignored.csv"]);
let imp = select_importer(®istry, Path::new("foo.csv"), &args);
assert_eq!(imp.name(), "CSV");
}
#[test]
fn test_select_importer_ofx_extension_picks_ofx() {
let registry = ImporterRegistry::with_builtins();
let args = Args::parse_from(["extract", "ignored.ofx"]);
let imp = select_importer(®istry, Path::new("foo.ofx"), &args);
assert_eq!(imp.name(), "OFX/QFX");
}
#[test]
fn test_select_importer_explicit_importer_flag_forces_csv_even_on_ofx_file() {
let registry = ImporterRegistry::with_builtins();
let args = Args::parse_from(["extract", "ignored.ofx", "--importer", "chase"]);
let imp = select_importer(®istry, Path::new("foo.ofx"), &args);
assert_eq!(
imp.name(),
"CSV",
"TOML --importer entries must force CSV dispatch regardless of file extension"
);
}
#[test]
fn test_select_importer_unknown_extension_falls_back_to_csv() {
let registry = ImporterRegistry::with_builtins();
let args = Args::parse_from(["extract", "ignored.qbo"]);
let imp = select_importer(®istry, Path::new("foo.qbo"), &args);
assert_eq!(imp.name(), "CSV");
}
#[test]
fn test_select_importer_config_alone_does_not_force_csv() {
use rustledger_importer::test_fixtures::identifying_wat;
let tmp = tempfile::tempdir().unwrap();
let wasm_path = tmp.path().join("mt.wasm");
std::fs::write(
&wasm_path,
wat::parse_str(identifying_wat("mt9")).expect("WAT parses"),
)
.unwrap();
let cfg_dir = tempfile::tempdir().unwrap();
let cfg_path = cfg_dir.path().join("importers.toml");
std::fs::write(&cfg_path, "").unwrap();
let args = Args::parse_from([
"extract",
"foo.mt940",
"--config",
cfg_path.to_str().unwrap(),
"--wasm-importer",
wasm_path.to_str().unwrap(),
]);
let registry = build_registry(&args).expect("builds");
let imp = select_importer(®istry, Path::new("foo.mt940"), &args);
assert_eq!(
imp.name(),
"mt9",
"WASM importer should win when --config is set alone (no --importer)"
);
}
#[test]
#[cfg(feature = "python-plugin-wasm")]
fn resolve_scan_dirs_propagates_error_for_explicit_missing_config() {
let args = Args::parse_from([
"extract",
"--config",
"/this/path/does/not/exist/importers.toml",
]);
let result = resolve_scan_dirs(&args);
let Err(err) = result else {
panic!("explicit missing --config should error");
};
let msg = format!("{err:#}");
assert!(
msg.contains("does/not/exist"),
"error should name the missing path: {msg}"
);
}
#[test]
#[cfg(feature = "python-plugin-wasm")]
fn resolve_scan_dirs_soft_fails_for_implicit_missing_config() {
let args = Args::parse_from(["extract"]);
let dirs = resolve_scan_dirs(&args).expect("implicit missing is soft-fail");
let _ = dirs;
}
#[test]
fn run_dispatches_to_wasm_importer_with_config_set_but_no_toml_profiles() {
use rustledger_importer::test_fixtures::identifying_wat;
let tmp = tempfile::tempdir().unwrap();
let wasm_path = tmp.path().join("my.wasm");
std::fs::write(
&wasm_path,
wat::parse_str(identifying_wat("mt9")).expect("WAT"),
)
.unwrap();
let cfg_path = tmp.path().join("wasm-only.toml");
std::fs::write(&cfg_path, "").unwrap();
let src_path = tmp.path().join("statement.mt940");
std::fs::write(&src_path, b"any bytes").unwrap();
let out_path = tmp.path().join("out.beancount");
let args = Args::parse_from([
"extract",
src_path.to_str().unwrap(),
"--config",
cfg_path.to_str().unwrap(),
"--wasm-importer",
wasm_path.to_str().unwrap(),
"--output",
out_path.to_str().unwrap(),
]);
if let Err(e) = run(&args, &src_path) {
let msg = format!("{e:#}");
assert!(
!msg.contains("No importers defined"),
"regression: CSV-branch error fired before WASM dispatch: {msg}"
);
}
}
#[test]
fn test_load_existing_transactions() {
let dir = tempfile::tempdir().unwrap();
let ledger_path = dir.path().join("ledger.beancount");
std::fs::write(
&ledger_path,
r#"2024-01-15 * "GROCERY STORE" "Weekly groceries"
Assets:Bank:Checking -50.00 USD
Expenses:Food 50.00 USD
2024-01-16 * "NETFLIX" "Monthly subscription"
Assets:Bank:Checking -15.99 USD
Expenses:Entertainment 15.99 USD
"#,
)
.unwrap();
let txns = load_existing_transactions(&ledger_path).unwrap();
assert_eq!(txns.len(), 2);
assert_eq!(
txns[0].date,
rustledger_core::naive_date(2024, 1, 15).unwrap()
);
assert_eq!(
txns[1].date,
rustledger_core::naive_date(2024, 1, 16).unwrap()
);
}
#[test]
fn test_load_existing_resolves_includes_and_interpolates() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("sub.beancount"),
"2024-02-01 * \"PHONE BILL\" \"Monthly\"\n \
Assets:Bank:Checking -40.00 USD\n Expenses:Phone\n",
)
.unwrap();
let main_path = dir.path().join("main.beancount");
std::fs::write(
&main_path,
"include \"sub.beancount\"\n\n2024-01-15 * \"GROCERY STORE\" \"Weekly\"\n \
Assets:Bank:Checking -50.00 USD\n Expenses:Food 50.00 USD\n",
)
.unwrap();
let txns = load_existing_transactions(&main_path).unwrap();
assert_eq!(txns.len(), 2, "included transaction must be loaded");
let phone = txns
.iter()
.find(|t| t.narration.as_str() == "Monthly")
.expect("included PHONE BILL transaction must be present");
let amount = phone
.postings
.iter()
.find(|p| p.account.as_str() == "Expenses:Phone")
.and_then(|p| p.units.as_ref())
.and_then(rustledger_core::IncompleteAmount::number);
assert_eq!(
amount,
Some("40.00".parse::<rust_decimal::Decimal>().unwrap()),
"elided posting must be interpolated by booking",
);
}
#[test]
fn test_end_to_end_output_file() {
let dir = tempfile::tempdir().unwrap();
let csv_path = dir.path().join("statement.csv");
std::fs::write(
&csv_path,
"Date,Description,Amount\n2024-01-15,Coffee,5.00\n",
)
.unwrap();
let output_path = dir.path().join("output.beancount");
let args = Args::parse_from([
"extract",
csv_path.to_str().unwrap(),
"-o",
output_path.to_str().unwrap(),
]);
run(&args, &csv_path).unwrap();
let output = std::fs::read_to_string(&output_path).unwrap();
assert!(output.contains("2024-01-15"));
assert!(output.contains("Coffee"));
}
#[test]
fn test_end_to_end_existing_dedup() {
let dir = tempfile::tempdir().unwrap();
let ledger_path = dir.path().join("ledger.beancount");
std::fs::write(
&ledger_path,
r#"2024-01-15 * "Coffee"
Assets:Bank:Checking 5.00 USD
Expenses:Unknown -5.00 USD
"#,
)
.unwrap();
let csv_path = dir.path().join("statement.csv");
std::fs::write(
&csv_path,
"Date,Description,Amount\n\
2024-01-15,Coffee,5.00\n\
2024-01-16,Lunch,12.00\n",
)
.unwrap();
let output_path = dir.path().join("output.beancount");
let args = Args::parse_from([
"extract",
csv_path.to_str().unwrap(),
"--existing",
ledger_path.to_str().unwrap(),
"-o",
output_path.to_str().unwrap(),
]);
run(&args, &csv_path).unwrap();
let output = std::fs::read_to_string(&output_path).unwrap();
assert!(!output.contains("Coffee"));
assert!(output.contains("Lunch"));
}
#[test]
fn test_parse_column_value_unsupported_type() {
assert_eq!(parse_column_value(&toml::Value::Boolean(true)), None);
assert_eq!(parse_column_value(&toml::Value::Float(1.5)), None);
}
#[test]
fn test_run_with_importer_config() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("importers.toml");
std::fs::write(
&config_path,
r#"
[[importers]]
name = "mybank"
account = "Assets:Bank:MyBank"
currency = "USD"
date_column = "Date"
narration_column = "Description"
amount_column = "Amount"
"#,
)
.unwrap();
let csv_path = dir.path().join("statement.csv");
std::fs::write(
&csv_path,
"Date,Description,Amount\n2024-01-15,Coffee,5.00\n",
)
.unwrap();
let output_path = dir.path().join("output.beancount");
let args = Args::parse_from([
"extract",
csv_path.to_str().unwrap(),
"--importer",
"mybank",
"--config",
config_path.to_str().unwrap(),
"-o",
output_path.to_str().unwrap(),
]);
run(&args, &csv_path).unwrap();
let output = std::fs::read_to_string(&output_path).unwrap();
assert!(output.contains("Assets:Bank:MyBank"));
assert!(output.contains("Coffee"));
}
#[test]
fn test_run_with_importer_not_found() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("importers.toml");
std::fs::write(
&config_path,
"[[importers]]\nname = \"other\"\naccount = \"Assets:Bank\"\n",
)
.unwrap();
let csv_path = dir.path().join("statement.csv");
std::fs::write(&csv_path, "Date,Description,Amount\n").unwrap();
let args = Args::parse_from([
"extract",
csv_path.to_str().unwrap(),
"--importer",
"nonexistent",
"--config",
config_path.to_str().unwrap(),
]);
let err = run(&args, &csv_path).unwrap_err();
assert!(err.to_string().contains("not found"));
assert!(err.to_string().contains("other"));
}
#[test]
fn test_run_with_importer_no_config_file() {
let dir = tempfile::tempdir().unwrap();
let csv_path = dir.path().join("statement.csv");
std::fs::write(&csv_path, "Date,Description,Amount\n").unwrap();
let config_path = dir.path().join("nonexistent.toml");
let args = Args::parse_from([
"extract",
csv_path.to_str().unwrap(),
"--importer",
"mybank",
"--config",
config_path.to_str().unwrap(),
]);
let err = run(&args, &csv_path).unwrap_err();
assert!(err.to_string().contains("Importers config not found"));
}
#[test]
fn test_run_stdout_output() {
let dir = tempfile::tempdir().unwrap();
let csv_path = dir.path().join("statement.csv");
std::fs::write(
&csv_path,
"Date,Description,Amount\n2024-01-15,Coffee,5.00\n",
)
.unwrap();
let args = Args::parse_from(["extract", csv_path.to_str().unwrap()]);
run(&args, &csv_path).unwrap();
}
#[test]
fn test_run_with_optional_cli_args() {
let dir = tempfile::tempdir().unwrap();
let csv_path = dir.path().join("statement.csv");
std::fs::write(
&csv_path,
"Date,Payee,Description,Debit,Credit\n\
2024-01-15,Store,Coffee,5.00,\n\
2024-01-16,Employer,Salary,,1000.00\n",
)
.unwrap();
let output_path = dir.path().join("output.beancount");
let args = Args::parse_from([
"extract",
csv_path.to_str().unwrap(),
"--payee-column",
"Payee",
"--debit-column",
"Debit",
"--credit-column",
"Credit",
"-o",
output_path.to_str().unwrap(),
]);
run(&args, &csv_path).unwrap();
let output = std::fs::read_to_string(&output_path).unwrap();
assert!(output.contains("2024-01-15"));
assert!(output.contains("Coffee"));
}
#[test]
fn test_load_existing_transactions_nonexistent_file() {
let result = load_existing_transactions(Path::new("/nonexistent/ledger.beancount"));
assert!(result.is_err());
}
#[test]
fn test_load_existing_transactions_with_non_txn_directives() {
let dir = tempfile::tempdir().unwrap();
let ledger_path = dir.path().join("ledger.beancount");
std::fs::write(
&ledger_path,
r#"2024-01-01 open Assets:Bank:Checking USD
2024-01-15 * "Coffee"
Assets:Bank:Checking -5.00 USD
Expenses:Food 5.00 USD
2024-01-31 balance Assets:Bank:Checking 1000.00 USD
"#,
)
.unwrap();
let txns = load_existing_transactions(&ledger_path).unwrap();
assert_eq!(txns.len(), 1);
}
#[test]
fn test_end_to_end_dedup_no_duplicates() {
let dir = tempfile::tempdir().unwrap();
let ledger_path = dir.path().join("ledger.beancount");
std::fs::write(
&ledger_path,
r#"2024-01-10 * "Old transaction"
Assets:Bank:Checking 10.00 USD
Expenses:Unknown -10.00 USD
"#,
)
.unwrap();
let csv_path = dir.path().join("statement.csv");
std::fs::write(
&csv_path,
"Date,Description,Amount\n2024-01-15,Coffee,5.00\n",
)
.unwrap();
let output_path = dir.path().join("output.beancount");
let args = Args::parse_from([
"extract",
csv_path.to_str().unwrap(),
"--existing",
ledger_path.to_str().unwrap(),
"-o",
output_path.to_str().unwrap(),
]);
run(&args, &csv_path).unwrap();
let output = std::fs::read_to_string(&output_path).unwrap();
assert!(output.contains("Coffee"));
}
#[test]
fn test_run_with_importers_config_alias() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("importers.toml");
std::fs::write(
&config_path,
r#"
[[importers]]
name = "test"
account = "Assets:Bank"
date_column = "Date"
narration_column = "Description"
amount_column = "Amount"
"#,
)
.unwrap();
let csv_path = dir.path().join("statement.csv");
std::fs::write(&csv_path, "Date,Description,Amount\n2024-01-15,Test,5.00\n").unwrap();
let output_path = dir.path().join("output.beancount");
let args = Args::parse_from([
"extract",
csv_path.to_str().unwrap(),
"--importer",
"test",
"--importers-config",
config_path.to_str().unwrap(),
"-o",
output_path.to_str().unwrap(),
]);
run(&args, &csv_path).unwrap();
let output = std::fs::read_to_string(&output_path).unwrap();
assert!(output.contains("Assets:Bank"));
}
#[test]
fn test_run_with_ofx_file() {
let dir = tempfile::tempdir().unwrap();
let ofx_path = dir.path().join("statement.ofx");
std::fs::write(
&ofx_path,
r"OFXHEADER:100
DATA:OFXSGML
VERSION:102
SECURITY:NONE
ENCODING:USASCII
CHARSET:1252
COMPRESSION:NONE
OLDFILEUID:NONE
NEWFILEUID:NONE
<OFX>
<SIGNONMSGSRSV1>
<SONRS>
<STATUS>
<CODE>0
<SEVERITY>INFO
</STATUS>
<DTSERVER>20240115120000
<LANGUAGE>ENG
</SONRS>
</SIGNONMSGSRSV1>
<BANKMSGSRSV1>
<STMTTRNRS>
<TRNUID>1001
<STATUS>
<CODE>0
<SEVERITY>INFO
</STATUS>
<STMTRS>
<CURDEF>USD
<BANKACCTFROM>
<BANKID>123456789
<ACCTID>987654321
<ACCTTYPE>CHECKING
</BANKACCTFROM>
<BANKTRANLIST>
<DTSTART>20240101
<DTEND>20240131
<STMTTRN>
<TRNTYPE>DEBIT
<DTPOSTED>20240115
<TRNAMT>-50.00
<FITID>2024011501
<NAME>GROCERY STORE
<MEMO>Weekly groceries
</STMTTRN>
</BANKTRANLIST>
<LEDGERBAL>
<BALAMT>5000.00
<DTASOF>20240131
</LEDGERBAL>
</STMTRS>
</STMTTRNRS>
</BANKMSGSRSV1>
</OFX>",
)
.unwrap();
let output_path = dir.path().join("output.beancount");
let args = Args::parse_from([
"extract",
ofx_path.to_str().unwrap(),
"-o",
output_path.to_str().unwrap(),
]);
run(&args, &ofx_path).unwrap();
let output = std::fs::read_to_string(&output_path).unwrap();
assert!(output.contains("2024-01-15"));
assert!(output.contains("GROCERY STORE"));
}
#[test]
fn test_run_with_amount_format_arg() {
let dir = tempfile::tempdir().unwrap();
let csv_path = dir.path().join("statement.tsv");
std::fs::write(
&csv_path,
"Date\tDescription\tAmount\n2024-01-15\tCoffee\t1.234,56\n",
)
.unwrap();
let output_path = dir.path().join("output.beancount");
let args = Args::parse_from([
"extract",
csv_path.to_str().unwrap(),
"--amount-format",
"#.##0,00",
"--delimiter",
"\t",
"-o",
output_path.to_str().unwrap(),
]);
run(&args, &csv_path).unwrap();
let output = std::fs::read_to_string(&output_path).unwrap();
assert!(output.contains("Coffee"));
}
#[test]
fn test_run_with_amount_locale_arg() {
let dir = tempfile::tempdir().unwrap();
let csv_path = dir.path().join("statement.csv");
std::fs::write(
&csv_path,
"Date,Description,Amount\n2024-01-15,Coffee,5.00\n",
)
.unwrap();
let output_path = dir.path().join("output.beancount");
let args = Args::parse_from([
"extract",
csv_path.to_str().unwrap(),
"--amount-locale",
"en_US",
"-o",
output_path.to_str().unwrap(),
]);
run(&args, &csv_path).unwrap();
let output = std::fs::read_to_string(&output_path).unwrap();
assert!(output.contains("5.00"));
}
#[test]
fn test_run_with_invalid_locale() {
let dir = tempfile::tempdir().unwrap();
let csv_path = dir.path().join("statement.csv");
std::fs::write(
&csv_path,
"Date,Description,Amount\n2024-01-15,Coffee,5.00\n",
)
.unwrap();
let args = Args::parse_from([
"extract",
csv_path.to_str().unwrap(),
"--amount-locale",
"invalid_LOCALE_xyz",
]);
let err = run(&args, &csv_path).unwrap_err();
assert!(err.to_string().contains("not a valid locale"));
}
#[test]
fn test_run_with_csv_that_generates_warnings() {
let dir = tempfile::tempdir().unwrap();
let csv_path = dir.path().join("statement.csv");
std::fs::write(
&csv_path,
"Date,Description,Amount\n\
2024-01-15,Coffee,5.00\n\
not-a-date,Bad Row,10.00\n",
)
.unwrap();
let output_path = dir.path().join("output.beancount");
let args = Args::parse_from([
"extract",
csv_path.to_str().unwrap(),
"-o",
output_path.to_str().unwrap(),
]);
run(&args, &csv_path).unwrap();
let output = std::fs::read_to_string(&output_path).unwrap();
assert!(output.contains("Coffee"));
}
#[test]
fn test_run_auto_select_sole_importer() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("importers.toml");
std::fs::write(
&config_path,
r#"
[[importers]]
name = "mybank"
account = "Assets:Bank:Auto"
date_column = "Date"
narration_column = "Description"
amount_column = "Amount"
"#,
)
.unwrap();
let csv_path = dir.path().join("statement.csv");
std::fs::write(
&csv_path,
"Date,Description,Amount\n2024-01-15,Coffee,-5.00\n",
)
.unwrap();
let output_path = dir.path().join("output.beancount");
let args = Args::parse_from([
"extract",
csv_path.to_str().unwrap(),
"--config",
config_path.to_str().unwrap(),
"-o",
output_path.to_str().unwrap(),
]);
run(&args, &csv_path).unwrap();
let output = std::fs::read_to_string(&output_path).unwrap();
assert!(output.contains("Assets:Bank:Auto"));
assert!(output.contains("Coffee"));
}
#[test]
fn test_run_auto_select_errors_on_multiple_importers() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("importers.toml");
std::fs::write(
&config_path,
r#"
[[importers]]
name = "checking"
account = "Assets:Bank:Checking"
filename_pattern = "*.csv"
[[importers]]
name = "credit"
account = "Liabilities:CreditCard"
filename_pattern = "statement*"
"#,
)
.unwrap();
let csv_path = dir.path().join("statement.csv");
std::fs::write(&csv_path, "Date,Description,Amount\n").unwrap();
let args = Args::parse_from([
"extract",
csv_path.to_str().unwrap(),
"--config",
config_path.to_str().unwrap(),
]);
let err = run(&args, &csv_path).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Multiple importers"));
assert!(msg.contains("checking"));
assert!(msg.contains("credit"));
}
#[test]
fn test_run_auto_select_errors_on_empty_config() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("importers.toml");
std::fs::write(&config_path, "importers = []\n").unwrap();
let csv_path = dir.path().join("statement.csv");
std::fs::write(&csv_path, "Date,Description,Amount\n").unwrap();
let args = Args::parse_from([
"extract",
csv_path.to_str().unwrap(),
"--config",
config_path.to_str().unwrap(),
]);
let err = run(&args, &csv_path).unwrap_err();
assert!(err.to_string().contains("No importers defined"));
}
fn wasm_importer_with_name(name: &str) -> Vec<u8> {
let wat = rustledger_importer::test_fixtures::metadata_wat(name);
wat::parse_str(&wat).expect("WAT parses")
}
#[test]
fn build_registry_defaults_to_builtins_only() {
let args = Args::parse_from(["extract"]);
let registry = build_registry(&args).expect("builds");
assert_eq!(registry.len(), 2);
assert!(registry.find_by_name("CSV").is_some());
assert!(registry.find_by_name("OFX").is_some());
}
#[test]
fn build_registry_loads_cli_wasm_importer_ahead_of_builtins() {
let tmp = tempfile::tempdir().unwrap();
let wasm_path = tmp.path().join("ad-hoc.wasm");
std::fs::write(&wasm_path, wasm_importer_with_name("usr")).unwrap();
let args = Args::parse_from(["extract", "--wasm-importer", wasm_path.to_str().unwrap()]);
let registry = build_registry(&args).expect("builds");
assert_eq!(registry.len(), 3);
assert!(registry.find_by_name("usr").is_some());
assert!(registry.find_by_name("CSV").is_some());
assert!(registry.find_by_name("OFX").is_some());
}
#[test]
fn build_registry_scans_directory_from_cli_flag() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("aaa.wasm"), wasm_importer_with_name("aaa")).unwrap();
std::fs::write(tmp.path().join("bbb.wasm"), wasm_importer_with_name("bbb")).unwrap();
let args = Args::parse_from([
"extract",
"--wasm-importer-dir",
tmp.path().to_str().unwrap(),
]);
let registry = build_registry(&args).expect("builds");
assert_eq!(registry.len(), 4);
assert!(registry.find_by_name("aaa").is_some());
assert!(registry.find_by_name("bbb").is_some());
}
#[test]
fn build_registry_reads_wasm_importer_dir_from_importers_toml() {
let wasm_dir = tempfile::tempdir().unwrap();
std::fs::write(
wasm_dir.path().join("xyz.wasm"),
wasm_importer_with_name("xyz"),
)
.unwrap();
let cfg_dir = tempfile::tempdir().unwrap();
let cfg_path = cfg_dir.path().join("importers.toml");
std::fs::write(
&cfg_path,
format!("wasm_importer_dir = \"{}\"\n", wasm_dir.path().display()),
)
.unwrap();
let args = Args::parse_from(["extract", "--config", cfg_path.to_str().unwrap()]);
let registry = build_registry(&args).expect("builds");
assert!(
registry.find_by_name("xyz").is_some(),
"xyz should be loaded via importers.toml's wasm_importer_dir"
);
}
#[test]
fn build_registry_cli_dir_flag_overrides_importers_toml_setting() {
let toml_only_dir = tempfile::tempdir().unwrap();
std::fs::write(
toml_only_dir.path().join("tom.wasm"),
wasm_importer_with_name("tom"),
)
.unwrap();
let cli_dir = tempfile::tempdir().unwrap();
std::fs::write(
cli_dir.path().join("cli.wasm"),
wasm_importer_with_name("cli"),
)
.unwrap();
let cfg_dir = tempfile::tempdir().unwrap();
let cfg_path = cfg_dir.path().join("importers.toml");
std::fs::write(
&cfg_path,
format!(
"wasm_importer_dir = \"{}\"\n",
toml_only_dir.path().display()
),
)
.unwrap();
let args = Args::parse_from([
"extract",
"--config",
cfg_path.to_str().unwrap(),
"--wasm-importer-dir",
cli_dir.path().to_str().unwrap(),
]);
let registry = build_registry(&args).expect("builds");
assert!(
registry.find_by_name("cli").is_some(),
"CLI-flag dir should be scanned"
);
assert!(
registry.find_by_name("tom").is_none(),
"toml-setting dir should be skipped when CLI flag is set"
);
}
#[test]
fn build_registry_propagates_cli_wasm_importer_load_errors() {
let tmp = tempfile::tempdir().unwrap();
let bad_path = tmp.path().join("bogus.wasm");
std::fs::write(&bad_path, b"not valid wasm").unwrap();
let args = Args::parse_from(["extract", "--wasm-importer", bad_path.to_str().unwrap()]);
let Err(err) = build_registry(&args) else {
panic!("bogus wasm should fail to load");
};
let msg = format!("{err:#}");
assert!(
msg.contains("bogus.wasm"),
"error should name the failing path: {msg}"
);
}
#[test]
fn build_registry_scans_multiple_cli_dirs_in_order() {
let dir_a = tempfile::tempdir().unwrap();
std::fs::write(
dir_a.path().join("aaa.wasm"),
wasm_importer_with_name("aaa"),
)
.unwrap();
let dir_b = tempfile::tempdir().unwrap();
std::fs::write(
dir_b.path().join("bbb.wasm"),
wasm_importer_with_name("bbb"),
)
.unwrap();
let args = Args::parse_from([
"extract",
"--wasm-importer-dir",
dir_a.path().to_str().unwrap(),
"--wasm-importer-dir",
dir_b.path().to_str().unwrap(),
]);
let registry = build_registry(&args).expect("builds");
assert!(registry.find_by_name("aaa").is_some(), "first dir loaded");
assert!(registry.find_by_name("bbb").is_some(), "second dir loaded");
}
#[test]
fn build_registry_accepts_toml_dir_as_list() {
let dir_a = tempfile::tempdir().unwrap();
std::fs::write(
dir_a.path().join("one.wasm"),
wasm_importer_with_name("one"),
)
.unwrap();
let dir_b = tempfile::tempdir().unwrap();
std::fs::write(
dir_b.path().join("two.wasm"),
wasm_importer_with_name("two"),
)
.unwrap();
let cfg_dir = tempfile::tempdir().unwrap();
let cfg_path = cfg_dir.path().join("importers.toml");
std::fs::write(
&cfg_path,
format!(
"wasm_importer_dir = [\"{}\", \"{}\"]\n",
dir_a.path().display(),
dir_b.path().display()
),
)
.unwrap();
let args = Args::parse_from(["extract", "--config", cfg_path.to_str().unwrap()]);
let registry = build_registry(&args).expect("builds");
assert!(registry.find_by_name("one").is_some());
assert!(registry.find_by_name("two").is_some());
}
#[test]
fn build_registry_skip_and_collect_loads_good_modules_past_failures() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("good.wasm"), wasm_importer_with_name("aaa")).unwrap();
std::fs::write(tmp.path().join("bad-zzz.wasm"), b"not valid wasm").unwrap();
let args = Args::parse_from([
"extract",
"--wasm-importer-dir",
tmp.path().to_str().unwrap(),
]);
let registry = build_registry(&args).expect("scan continues past failure");
assert!(
registry.find_by_name("aaa").is_some(),
"good module loaded despite sibling failure"
);
}
#[test]
fn build_registry_cli_wasm_importer_wins_over_dir_scanned_same_name() {
let cli_dir = tempfile::tempdir().unwrap();
let cli_path = cli_dir.path().join("cli.wasm");
std::fs::write(&cli_path, wasm_importer_with_name("dup")).unwrap();
let scan_dir = tempfile::tempdir().unwrap();
std::fs::write(
scan_dir.path().join("scanned.wasm"),
wasm_importer_with_name("dup"),
)
.unwrap();
let args = Args::parse_from([
"extract",
"--wasm-importer",
cli_path.to_str().unwrap(),
"--wasm-importer-dir",
scan_dir.path().to_str().unwrap(),
]);
let registry = build_registry(&args).expect("builds");
assert_eq!(registry.len(), 4, "1 CLI + 1 dir-scanned + 2 builtins");
assert!(registry.find_by_name("dup").is_some());
let dup_count = registry
.list_importers()
.iter()
.filter(|(name, _)| *name == "dup")
.count();
assert_eq!(dup_count, 2, "both same-named modules are registered");
}
#[test]
#[cfg(feature = "python-plugin-wasm")]
fn expand_tilde_resolves_tilde_prefix() {
use super::config::expand_tilde;
if let Some(home) = dirs::home_dir() {
assert_eq!(expand_tilde(Path::new("~")), home);
assert_eq!(
expand_tilde(Path::new("~/foo/bar")),
home.join("foo").join("bar")
);
}
assert_eq!(expand_tilde(Path::new("/abs/path")), Path::new("/abs/path"));
assert_eq!(expand_tilde(Path::new("rel/path")), Path::new("rel/path"));
assert_eq!(
expand_tilde(Path::new("~other/foo")),
Path::new("~other/foo")
);
}
}