use anyhow::{Context, Result, anyhow};
use rustledger_importer::ImporterConfig;
use rustledger_importer::config::CsvConfigBuilder;
use serde::Deserialize;
use std::collections::HashMap;
use std::path::Path;
#[cfg(feature = "python-plugin-wasm")]
use std::path::PathBuf;
#[derive(Debug, Deserialize)]
pub(super) struct ImportersFile {
#[cfg(feature = "python-plugin-wasm")]
#[serde(default)]
pub(super) wasm_importer_dir: WasmDirSetting,
#[serde(default)]
pub(super) importers: Vec<ImporterEntry>,
}
#[cfg(feature = "python-plugin-wasm")]
#[derive(Debug, Default, Deserialize)]
#[serde(untagged)]
pub(super) enum WasmDirSetting {
#[default]
None,
Single(PathBuf),
Many(Vec<PathBuf>),
}
#[cfg(feature = "python-plugin-wasm")]
impl WasmDirSetting {
pub(super) fn into_vec(self) -> Vec<PathBuf> {
match self {
Self::None => Vec::new(),
Self::Single(p) => vec![p],
Self::Many(v) => v,
}
}
}
#[cfg(feature = "python-plugin-wasm")]
pub(super) fn expand_tilde(path: &Path) -> PathBuf {
let s = path.to_string_lossy();
if s == "~" {
return dirs::home_dir().unwrap_or_else(|| path.to_path_buf());
}
if let Some(rest) = s.strip_prefix("~/")
&& let Some(home) = dirs::home_dir()
{
return home.join(rest);
}
path.to_path_buf()
}
#[derive(Debug, Deserialize)]
pub(super) struct ImporterEntry {
pub(super) name: String,
pub(super) filename_pattern: Option<String>,
pub(super) account: Option<String>,
pub(super) currency: Option<String>,
pub(super) date_column: Option<toml::Value>,
pub(super) date_format: Option<String>,
pub(super) narration_column: Option<toml::Value>,
pub(super) payee_column: Option<toml::Value>,
pub(super) amount_column: Option<toml::Value>,
pub(super) currency_column: Option<toml::Value>,
pub(super) debit_column: Option<toml::Value>,
pub(super) credit_column: Option<toml::Value>,
pub(super) secondary_date_column: Option<String>,
pub(super) secondary_date_format: Option<String>,
pub(super) secondary_date_key: Option<String>,
pub(super) amount_locale: Option<String>,
pub(super) amount_format: Option<String>,
pub(super) delimiter: Option<String>,
pub(super) skip_rows: Option<usize>,
#[serde(default)]
pub(super) skip_header: Option<bool>,
#[serde(default)]
pub(super) invert_amounts: Option<bool>,
pub(super) default_expense: Option<String>,
pub(super) default_income: Option<String>,
#[serde(default)]
pub(super) mappings: HashMap<String, String>,
pub(super) use_merchant_dict: Option<bool>,
}
pub(super) fn apply_column(
builder: CsvConfigBuilder,
col: &str,
by_index: impl FnOnce(CsvConfigBuilder, usize) -> CsvConfigBuilder,
by_name: impl FnOnce(CsvConfigBuilder, &str) -> CsvConfigBuilder,
) -> CsvConfigBuilder {
match col.parse::<usize>() {
Ok(i) => by_index(builder, i),
Err(_) => by_name(builder, col),
}
}
pub(super) fn parse_column_value(value: &toml::Value) -> Option<String> {
match value {
toml::Value::String(s) => Some(s.clone()),
toml::Value::Integer(i) => Some(i.to_string()),
_ => None,
}
}
pub(super) fn find_importers_config(
explicit_path: Option<&Path>,
) -> Result<Option<std::path::PathBuf>> {
if let Some(path) = explicit_path {
if path.exists() {
return Ok(Some(path.to_path_buf()));
}
return Err(anyhow!("Importers config not found: {}", path.display()));
}
if let Ok(cwd) = std::env::current_dir() {
let local = cwd.join("importers.toml");
if local.exists() {
return Ok(Some(local));
}
}
if let Some(config_dir) = dirs::config_dir() {
let user_path = config_dir.join("rledger").join("importers.toml");
if user_path.exists() {
return Ok(Some(user_path));
}
}
Ok(None)
}
pub(super) fn load_importers_config(path: &Path) -> Result<ImportersFile> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read importers config: {}", path.display()))?;
let config: ImportersFile = toml::from_str(&content)
.with_context(|| format!("Failed to parse importers config: {}", path.display()))?;
Ok(config)
}
pub(super) fn build_config_from_entry(entry: &ImporterEntry) -> Result<ImporterConfig> {
let mut builder = ImporterConfig::csv();
if let Some(ref account) = entry.account {
builder = builder.account(account);
}
if let Some(ref currency) = entry.currency {
builder = builder.currency(currency);
}
if let Some(ref val) = entry.date_column
&& let Some(col) = parse_column_value(val)
{
builder = apply_column(
builder,
&col,
CsvConfigBuilder::date_column_index,
|b, n| b.date_column(n),
);
}
if let Some(ref fmt) = entry.date_format {
builder = builder.date_format(fmt);
}
if let Some(ref col) = entry.secondary_date_column {
let fmt = entry
.secondary_date_format
.clone()
.or_else(|| entry.date_format.clone())
.unwrap_or_else(|| "%Y-%m-%d".to_string());
let key = entry.secondary_date_key.clone().unwrap_or_else(|| {
col.trim()
.to_lowercase()
.replace(|c: char| !c.is_ascii_alphanumeric(), "_")
});
builder = builder.secondary_date(col, fmt, key);
}
if let Some(ref val) = entry.narration_column
&& let Some(col) = parse_column_value(val)
{
builder = apply_column(
builder,
&col,
CsvConfigBuilder::narration_column_index,
|b, n| b.narration_column(n),
);
}
if let Some(ref val) = entry.payee_column
&& let Some(col) = parse_column_value(val)
{
builder = apply_column(
builder,
&col,
CsvConfigBuilder::payee_column_index,
|b, n| b.payee_column(n),
);
}
if let Some(ref val) = entry.amount_column
&& let Some(col) = parse_column_value(val)
{
builder = apply_column(
builder,
&col,
CsvConfigBuilder::amount_column_index,
|b, n| b.amount_column(n),
);
}
if let Some(ref val) = entry.currency_column
&& let Some(col) = parse_column_value(val)
{
builder = apply_column(
builder,
&col,
CsvConfigBuilder::currency_column_index,
|b, n| b.currency_column(n),
);
}
if let Some(ref val) = entry.debit_column
&& let Some(col) = parse_column_value(val)
{
builder = builder.debit_column(&col);
}
if let Some(ref val) = entry.credit_column
&& let Some(col) = parse_column_value(val)
{
builder = builder.credit_column(&col);
}
if let Some(ref locale) = entry.amount_locale {
builder = builder.amount_locale(super::parse_amount_locale(locale)?);
}
if let Some(ref format) = entry.amount_format {
builder = builder.amount_format(format);
}
if let Some(ref delim) = entry.delimiter
&& let Some(c) = delim.chars().next()
{
builder = builder.delimiter(c);
}
if let Some(skip) = entry.skip_rows {
builder = builder.skip_rows(skip);
}
if let Some(skip_header) = entry.skip_header {
builder = builder.has_header(!skip_header);
}
if let Some(invert) = entry.invert_amounts {
builder = builder.invert_sign(invert);
}
if let Some(ref account) = entry.default_expense {
builder = builder.default_expense(account);
}
if let Some(ref account) = entry.default_income {
builder = builder.default_income(account);
}
if !entry.mappings.is_empty() {
let mut mappings: Vec<(String, String)> = entry
.mappings
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
mappings.sort_by_key(|a| std::cmp::Reverse(a.0.len()));
builder = builder.mappings(mappings);
}
if let Some(enable) = entry.use_merchant_dict {
builder = builder.use_merchant_dict(enable);
}
builder.build()
}
pub(super) fn importer_matches_filename(entry: &ImporterEntry, filename: &str) -> bool {
if let Some(pattern) = &entry.filename_pattern {
glob::Pattern::new(pattern).is_ok_and(|p| p.matches(filename))
} else {
false
}
}
pub(super) fn find_matching_importers<'a>(
config: &'a ImportersFile,
filename: &str,
) -> Vec<&'a ImporterEntry> {
config
.importers
.iter()
.filter(|imp| importer_matches_filename(imp, filename))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use rustledger_importer::config::ImporterType;
#[test]
fn build_config_from_entry_applies_amount_locale_and_format() {
let src = r##"
[[importers]]
name = "de-locale"
account = "Assets:Bank"
amount_locale = "de_DE"
[[importers]]
name = "de-format"
account = "Assets:Bank"
amount_locale = "de_DE"
amount_format = "#.##0,00"
"##;
let file: ImportersFile = toml::from_str(src).expect("toml parses");
let cfg = build_config_from_entry(&file.importers[0]).expect("config builds");
let ImporterType::Csv(csv) = &cfg.importer_type;
let fmt = csv.compile_amount_format().expect("format compiles");
assert_eq!(
fmt.parse("21,12").expect("amount parses").to_string(),
"21.12"
);
let cfg = build_config_from_entry(&file.importers[1]).expect("config builds");
let ImporterType::Csv(csv) = &cfg.importer_type;
assert_eq!(csv.amount_format.as_deref(), Some("#.##0,00"));
}
}