use super::error::ImportError;
use std::collections::HashMap;
use std::convert::{TryFrom, TryInto};
use std::path::{Path, PathBuf};
use log::warn;
use path_slash::PathBufExt;
use serde::de::Error;
use serde::{Deserialize, Serialize};
#[derive(Debug, PartialEq, Serialize, Deserialize)]
pub struct ConfigSet {
entries: Vec<ConfigFragment>,
}
impl ConfigSet {
pub fn select(&self, p: &Path) -> Result<Option<ConfigEntry>, ImportError> {
self.select_impl(p).transpose()
}
fn select_impl(&self, p: &Path) -> Option<Result<ConfigEntry, ImportError>> {
let fp: &str = match p.to_str() {
None => {
warn!("invalid Unicode path: {}", p.display());
None
}
Some(x) => Some(x),
}?;
fn has_matches<'a>(
entry: &'a ConfigFragment,
fp: &str,
) -> Option<(usize, &'a ConfigFragment)> {
let epb: PathBuf = PathBufExt::from_slash(&entry.path);
log::trace!("epb={} fp={}", epb.display(), fp);
match epb.to_str() {
Some(ep) if fp.contains(ep) => Some((entry.path.len(), entry)),
_ => None,
}
}
let mut matched: Vec<(usize, &ConfigFragment)> = self
.entries
.iter()
.filter_map(|x| has_matches(x, fp))
.collect();
matched.sort_by_key(|x| x.0);
matched
.into_iter()
.fold(None, |res, item| match res {
None => Some(item.1.clone()),
Some(prev) => Some(prev.merge(item.1.clone())),
})
.map(|x| x.try_into())
}
}
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct ConfigEntry {
pub path: String,
pub encoding: Encoding,
pub account: String,
pub account_type: AccountType,
pub operator: Option<String>,
pub commodity: String,
pub format: FormatSpec,
pub rewrite: Vec<RewriteRule>,
}
impl TryFrom<ConfigFragment> for ConfigEntry {
type Error = ImportError;
fn try_from(value: ConfigFragment) -> Result<Self, Self::Error> {
let encoding = value
.encoding
.ok_or(ImportError::InvalidConfig("no encoding specified"))?;
let account = value
.account
.ok_or(ImportError::InvalidConfig("no account specified"))?;
let account_type = value
.account_type
.ok_or(ImportError::InvalidConfig("no account_type specified"))?;
let commodity = value
.commodity
.ok_or(ImportError::InvalidConfig("no commodity specified"))?;
let format = value.format.unwrap_or_default();
Ok(ConfigEntry {
path: value.path,
encoding,
account,
account_type,
operator: value.operator,
commodity,
format,
rewrite: value.rewrite,
})
}
}
#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]
struct ConfigFragment {
pub path: String,
pub encoding: Option<Encoding>,
pub account: Option<String>,
pub account_type: Option<AccountType>,
pub operator: Option<String>,
pub commodity: Option<String>,
pub format: Option<FormatSpec>,
#[serde(default)]
pub rewrite: Vec<RewriteRule>,
}
impl ConfigFragment {
fn merge(self, mut other: ConfigFragment) -> ConfigFragment {
let mut rewrite = self.rewrite;
rewrite.append(&mut other.rewrite);
ConfigFragment {
path: other.path,
encoding: other.encoding.or(self.encoding),
account: other.account.or(self.account),
account_type: other.account_type.or(self.account_type),
operator: other.operator.or(self.operator),
commodity: other.commodity.or(self.commodity),
format: other.format.or(self.format),
rewrite,
}
}
}
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct Encoding(pub &'static encoding_rs::Encoding);
impl Encoding {
pub fn as_encoding(&self) -> &'static encoding_rs::Encoding {
self.0
}
}
impl Serialize for Encoding {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_bytes(self.0.name().as_bytes())
}
}
impl<'de> Deserialize<'de> for Encoding {
fn deserialize<D>(deserializer: D) -> Result<Encoding, D::Error>
where
D: serde::Deserializer<'de>,
{
let s: String = Deserialize::deserialize(deserializer)?;
encoding_rs::Encoding::for_label(s.as_bytes())
.ok_or_else(|| D::Error::custom(format!("unknown encoding {}", s)))
.map(Encoding)
}
}
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone, Copy)]
#[serde(rename_all = "snake_case")]
pub enum AccountType {
Asset,
Liability,
}
#[derive(Default, Debug, PartialEq, Eq, Serialize, Deserialize, Clone)]
pub struct FormatSpec {
#[serde(default)]
pub date: String,
#[serde(default)]
pub commodity: HashMap<String, CommodityFormatSpec>,
#[serde(default)]
pub fields: HashMap<FieldKey, FieldPos>,
#[serde(default)]
pub delimiter: String,
#[serde(default)]
pub skip: SkipSpec,
#[serde(default)]
pub row_order: RowOrder,
}
#[derive(Debug, Default, PartialEq, Eq, Serialize, Deserialize, Clone, Copy)]
pub struct SkipSpec {
pub head: i32,
}
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone, Copy)]
#[serde(rename_all = "snake_case")]
pub enum RowOrder {
OldToNew,
NewToOld,
}
impl Default for RowOrder {
fn default() -> Self {
RowOrder::OldToNew
}
}
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)]
pub struct CommodityFormatSpec {
pub precision: u8,
}
#[derive(Debug, PartialEq, Eq, Hash, Copy, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FieldKey {
Date,
Payee,
Category,
Note,
Amount,
Credit,
Debit,
Balance,
Commodity,
Rate,
EquivalentAbsolute,
}
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)]
#[serde(untagged)]
pub enum FieldPos {
Index(usize),
Label(String),
}
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)]
pub struct RewriteRule {
pub matcher: RewriteMatcher,
#[serde(default)]
pub pending: bool,
#[serde(default)]
pub payee: Option<String>,
#[serde(default)]
pub account: Option<String>,
#[serde(default)]
pub conversion: Option<CommodityConversion>,
}
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)]
#[serde(untagged)]
pub enum CommodityConversion {
Unspecified(UnspecifiedCommodityConversion),
Specified {
commodity: String,
},
}
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone, Copy)]
#[serde(rename_all = "snake_case", tag = "type")]
pub enum UnspecifiedCommodityConversion {
Primary,
}
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)]
#[serde(untagged)]
pub enum RewriteMatcher {
Or(Vec<FieldMatcher>),
Field(FieldMatcher),
}
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)]
#[serde(transparent)]
pub struct FieldMatcher {
pub fields: HashMap<RewriteField, String>,
}
#[derive(Debug, Eq, Hash, PartialEq, Clone, Copy, Serialize, Deserialize, strum::Display)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum RewriteField {
DomainCode,
DomainFamily,
DomainSubFamily,
CreditorName,
CreditorAccountId,
UltimateCreditorName,
DebtorName,
DebtorAccountId,
UltimateDebtorName,
RemittanceUnstructuredInfo,
AdditionalTransactionInfo,
Category,
Payee,
}
pub fn load_from_yaml<R: std::io::Read>(r: R) -> Result<ConfigSet, ImportError> {
let mut entries = Vec::new();
let docs = serde_yaml::Deserializer::from_reader(r);
for doc in docs {
let entry = ConfigFragment::deserialize(doc)?;
entries.push(entry);
}
Ok(ConfigSet { entries })
}
#[cfg(test)]
mod tests {
use super::*;
use indoc::indoc;
use maplit::hashmap;
use pretty_assertions::assert_eq;
#[ctor::ctor]
fn init() {
let _ = env_logger::builder().is_test(true).try_init();
}
fn create_empty_config_fragment() -> ConfigFragment {
ConfigFragment {
path: "empty/".to_string(),
account: None,
account_type: None,
commodity: None,
encoding: None,
format: None,
operator: None,
rewrite: Vec::new(),
}
}
fn create_config_fragment(path: &str) -> ConfigFragment {
ConfigFragment {
account: Some("Account".to_owned()),
encoding: Some(Encoding(encoding_rs::UTF_8)),
path: path.to_owned(),
account_type: Some(AccountType::Asset),
operator: None,
commodity: Some("JPY".to_owned()),
format: Some(FormatSpec {
date: "%Y%m%d".to_owned(),
..FormatSpec::default()
}),
rewrite: vec![],
}
}
#[test]
fn test_config_select_single_match() {
let config_set = ConfigSet {
entries: vec![
create_config_fragment("path/to/foo"),
create_config_fragment("path/to/bar"),
],
};
let cfg0: ConfigEntry = config_set.entries[0]
.clone()
.try_into()
.expect("config[0] must be valid ConfigEntry");
assert_eq!(
cfg0,
config_set
.select(&Path::new("path").join("to").join("foo").join("202109.csv"))
.expect("select must not fail")
.expect("select must have result")
);
}
#[test]
fn test_config_select_no_match() {
let config_set = ConfigSet {
entries: vec![
create_config_fragment("path/to/foo"),
create_config_fragment("path/to/bar"),
],
};
assert_eq!(
None,
config_set
.select(&Path::new("path").join("to").join("baz").join("202109.csv"))
.expect("select must not fail")
);
}
#[test]
fn test_config_select_multi_match() {
let config_set = &ConfigSet {
entries: vec![
create_config_fragment("path/to/foo"),
create_config_fragment("path/to"),
],
};
let cfg0: ConfigEntry = config_set.entries[0]
.clone()
.try_into()
.expect("config[0] must be valid ConfigEntry");
assert_eq!(
cfg0,
config_set
.select(&Path::new("path").join("to").join("foo").join("202109.csv"))
.expect("select must not fail")
.expect("select must have result")
);
}
#[test]
fn test_config_select_merge_match() {
}
#[test]
fn test_config_entry_try_from() {
let f = create_config_fragment("tmp/foo");
let tryinto = TryInto::<ConfigEntry>::try_into;
tryinto(f.clone()).expect("create_config_fragment() should return solo valid fragment");
let err = |x| tryinto(x).unwrap_err().to_string();
assert!(err(ConfigFragment {
account: None,
..f.clone()
})
.contains("account"));
assert!(err(ConfigFragment {
encoding: None,
..f.clone()
})
.contains("encoding"));
assert!(err(ConfigFragment {
account_type: None,
..f.clone()
})
.contains("account_type"));
assert!(err(ConfigFragment {
commodity: None,
..f
})
.contains("commodity"));
}
#[test]
fn test_config_fragment_merge() {
let empty = create_empty_config_fragment;
let account = || ConfigFragment {
path: "account/".to_string(),
account: Some("foo".to_string()),
..empty()
};
let account_type = || ConfigFragment {
path: "account_type/".to_string(),
account_type: Some(AccountType::Liability),
..empty()
};
let commodity = || ConfigFragment {
path: "commodity/".to_string(),
commodity: Some("FOO COMMODITY".to_string()),
..empty()
};
let operator = || ConfigFragment {
path: "fragment/".to_string(),
operator: Some("foo operator".to_string()),
..empty()
};
let cases: Vec<Box<dyn Fn() -> ConfigFragment>> = vec![
Box::new(account),
Box::new(account_type),
Box::new(commodity),
Box::new(operator),
];
for case in cases {
assert_eq!(empty().merge(case()), case());
assert_eq!(
case().merge(empty()),
ConfigFragment {
path: "empty/".to_string(),
..case()
}
);
}
}
#[test]
fn test_parse_csv_label_config() {
let input = indoc! {r#"
path: bank/okanebank/
encoding: Shift_JIS
account: Assets:Banks:Okane
account_type: asset
commodity: JPY
format:
date: "%Y年%m月%d日"
fields:
date: お取り引き日
payee: 摘要
note: 参考情報
credit: お預け入れ額
debit: お引き出し額
balance: 差し引き残高
rewrite:
- matcher:
payee: Visaデビット (?P<code>\d+) (?P<payee>.*)
- matcher:
payee: 外貨普通預金(.*)(?:へ|より)振替
conversion:
commodity: EUR
account: Assets:Wire:Okane
"#};
let config = load_from_yaml(input.as_bytes()).unwrap();
assert_eq!(
config.entries[0].account.as_deref(),
Some("Assets:Banks:Okane")
);
let date = config.entries[0]
.format
.as_ref()
.expect("format should exist")
.fields
.get(&FieldKey::Date)
.expect("format.fields.date should exist");
assert_eq!(*date, FieldPos::Label("お取り引き日".to_owned()));
let rewrite = vec![
RewriteRule {
matcher: RewriteMatcher::Field(FieldMatcher {
fields: hashmap! {
RewriteField::Payee => r#"Visaデビット (?P<code>\d+) (?P<payee>.*)"#.to_string(),
},
}),
pending: false,
payee: None,
account: None,
conversion: None,
},
RewriteRule {
matcher: RewriteMatcher::Field(FieldMatcher {
fields: hashmap! {
RewriteField::Payee => "外貨普通預金(.*)(?:へ|より)振替".to_string(),
},
}),
pending: false,
payee: None,
account: Some("Assets:Wire:Okane".to_string()),
conversion: Some(CommodityConversion::Specified {
commodity: "EUR".to_string(),
}),
},
];
assert_eq!(&rewrite, &config.entries[0].rewrite);
}
#[test]
fn test_parse_csv_index_config() {
let input = indoc! {r#"
path: card/okanecard/
encoding: UTF-8
account: Liabilities:OkaneCard
account_type: liability
commodity: JPY
format:
date: "%Y/%m/%d"
fields:
date: 0
payee: 1
note: 6
amount: 2
rewrite: []
"#};
let config = load_from_yaml(input.as_bytes()).unwrap();
assert_eq!(
config.entries[0].account.as_deref(),
Some("Liabilities:OkaneCard")
);
let field_amount = config.entries[0]
.format
.as_ref()
.expect("FormatSpec should exist")
.fields
.get(&FieldKey::Amount)
.unwrap();
assert_eq!(*field_amount, FieldPos::Index(2));
}
#[test]
fn test_parse_matcher() {
let input = indoc! {r#"
matcher:
domain_code: PMNT
account: Income:Salary
conversion:
type: primary
"#};
let de = serde_yaml::Deserializer::from_str(input);
let matcher = RewriteRule {
matcher: RewriteMatcher::Field(FieldMatcher {
fields: hashmap! {RewriteField::DomainCode => "PMNT".to_string()},
}),
pending: false,
payee: None,
account: Some("Income:Salary".to_string()),
conversion: Some(CommodityConversion::Unspecified(
UnspecifiedCommodityConversion::Primary,
)),
};
assert_eq!(matcher, RewriteRule::deserialize(de).unwrap());
}
#[test]
fn test_parse_isocamt_config() {
let input = indoc! {r#"
path: bank/okanebank/
encoding: UTF-8
account: Banks:Okane
account_type: asset
commodity: USD
rewrite:
- matcher:
domain_code: PMNT
domain_family: RCDT
domain_sub_family: SALA
account: Income:Salary
payee: Okane Co. Ltd.
- matcher:
- payee: Migros
- payee: Coop
account: Expenses:Grocery
- matcher:
additional_transaction_info: Maestro(?P<payee>.*)
"#};
let config = load_from_yaml(input.as_bytes()).unwrap();
let rewrite = vec![
RewriteRule {
matcher: RewriteMatcher::Field(FieldMatcher {
fields: hashmap! {
RewriteField::DomainCode => "PMNT".to_string(),
RewriteField::DomainFamily => "RCDT".to_string(),
RewriteField::DomainSubFamily => "SALA".to_string(),
},
}),
pending: false,
payee: Some("Okane Co. Ltd.".to_string()),
account: Some("Income:Salary".to_string()),
conversion: None,
},
RewriteRule {
matcher: RewriteMatcher::Or(vec![
FieldMatcher {
fields: hashmap! {
RewriteField::Payee => "Migros".to_string(),
},
},
FieldMatcher {
fields: hashmap! {
RewriteField::Payee => "Coop".to_string(),
},
},
]),
pending: false,
account: Some("Expenses:Grocery".to_string()),
payee: None,
conversion: None,
},
RewriteRule {
matcher: RewriteMatcher::Field(FieldMatcher {
fields: hashmap! {
RewriteField::AdditionalTransactionInfo => "Maestro(?P<payee>.*)".to_string(),
},
}),
pending: false,
payee: None,
account: None,
conversion: None,
},
];
assert_eq!(&rewrite, &config.entries[0].rewrite);
}
}