use anyhow::{Context, Result, anyhow};
use rustledger_importer::ImporterConfig;
use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Deserialize)]
pub(super) struct ImportersFile {
#[serde(default)]
pub(super) wasm_importer_dir: WasmDirSetting,
#[serde(default)]
pub(super) importers: Vec<ImporterEntry>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(untagged)]
pub(super) enum WasmDirSetting {
#[default]
None,
Single(PathBuf),
Many(Vec<PathBuf>),
}
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,
}
}
}
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) debit_column: Option<toml::Value>,
pub(super) credit_column: Option<toml::Value>,
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) 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 = builder.date_column(&col);
}
if let Some(ref fmt) = entry.date_format {
builder = builder.date_format(fmt);
}
if let Some(ref val) = entry.narration_column
&& let Some(col) = parse_column_value(val)
{
builder = builder.narration_column(&col);
}
if let Some(ref val) = entry.payee_column
&& let Some(col) = parse_column_value(val)
{
builder = builder.payee_column(&col);
}
if let Some(ref val) = entry.amount_column
&& let Some(col) = parse_column_value(val)
{
builder = builder.amount_column(&col);
}
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 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);
}
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()
}