Add setup utility to generate configuration

master
Nick Krichevsky 2022-10-27 23:45:32 -04:00
parent 3a34b3f4fc
commit 1db6aa7d6d
6 changed files with 734 additions and 20 deletions

48
Cargo.lock generated
View File

@ -343,6 +343,17 @@ dependencies = [
"os_str_bytes",
]
[[package]]
name = "colored"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3616f750b84d8f0de8a58bda93e08e2a81ad3f523089b05f1dffecab48c6cbd"
dependencies = [
"atty",
"lazy_static",
"winapi",
]
[[package]]
name = "concurrent-queue"
version = "1.2.2"
@ -1588,6 +1599,12 @@ dependencies = [
"semver",
]
[[package]]
name = "rustversion"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97477e48b4cf8603ad5f7aaf897467cf42ab4218a38ef76fb14c2d6773a6d6a8"
[[package]]
name = "ryu"
version = "1.0.9"
@ -1844,6 +1861,28 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "strum"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.24.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn",
]
[[package]]
name = "syn"
version = "1.0.102"
@ -1911,6 +1950,12 @@ dependencies = [
"syn",
]
[[package]]
name = "text_io"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5f0c8eb2ad70c12a6a69508f499b3051c924f4b1cfeae85bfad96e6bc5bba46"
[[package]]
name = "textwrap"
version = "0.15.0"
@ -2385,6 +2430,7 @@ dependencies = [
"async-trait",
"chrono",
"clap",
"colored",
"derive-getters",
"fern",
"futures",
@ -2399,7 +2445,9 @@ dependencies = [
"serde_yaml",
"simplelog",
"stop-token",
"strum",
"test-case",
"text_io",
"textwrap",
"thiserror",
"tokio",

View File

@ -2,14 +2,14 @@
name = "ynabifier"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
default-run = "ynabifier"
[dependencies]
anyhow = "1.0"
async-imap = "0.6.0"
async-trait = "0.1.53"
clap = { version = "4.0", features = ["derive"] }
colored = "2.0"
chrono = "0.4.22"
derive-getters = "0.2.0"
log = "0.4.17"
@ -18,11 +18,13 @@ itertools = "0.10.4"
mailparse = "0.13.8"
scraper = "0.13.0"
regex = "1.6"
reqwest = { version = "0.11.12", features = ["json"] }
reqwest = { version = "0.11.12", features = ["json", "blocking"] }
serde = { version = "1.0", features = ["derive"] }
serde_yaml = "0.8.24"
serde_json = "1.0"
simplelog = "0.12.0"
text_io = "0.1.12"
strum = { version = "0.24.1", features = ["derive"] }
thiserror = "1.0"
tokio = { version = "1.18", features = ["full"] }
url = "2.2"

View File

@ -11,18 +11,13 @@ YNAB has built in functionality to import transactions from your credit cards, b
This project takes heavy inspiration from [buzzlawless/live-import-for-ynab](https://github.com/buzzlawless/live-import-for-ynab), which does effectively the same thing, but makes use of AWS Simple Email Service, if self-hosting isn't your game.
## Note
YNABifier, while functional, is still a bit of a work in progress. There are some edges that need to be cleaned up, but it works for the most part.
Planned improvements:
- [ ] Streamline the configuration process to not require fetching YNAB IDs by hand.
- [x] IMAP sessions need to be more properly cleaned up.
- [x] Various QoL features, such as allowing alternate configuration paths
## Setup
YNABifier requires a configuration file named `config.yml` placed in your present working directory. It does require some fields that you can fetch from the YNAB API about your account, as well as a [personal access token](https://api.youneedabudget.com/).
As a convenience, a setup utility is provided to generate the configuration. You can access this by running `cargo run --bin setup`, and then following the on-screen prompts.
### Config Schema
```yml
log_level: info # optional
imap:
@ -38,7 +33,7 @@ ynab:
parser: The parser to use (see below)
```
Once this is in place, you can use `cargo run --release`.
Once this is in place, you can use `cargo run --release` to run YNABifier, or `cargo build --release`, and use the built binary.
## Supported transaction providers
- TD Bank (`td` in the configuration)

316
src/bin/setup.rs Normal file
View File

@ -0,0 +1,316 @@
#![warn(clippy::all, clippy::pedantic)]
use anyhow::anyhow;
use colored::Colorize;
use itertools::Itertools;
use reqwest::{
blocking::Client,
header::{HeaderMap, HeaderValue},
Url,
};
use serde::Deserialize;
use std::{collections::HashMap, process};
use strum::IntoEnumIterator;
use text_io::read;
use ynabifier::config::{Builder as ConfigBuilder, Parser};
macro_rules! read_line {
() => {
read!("{}\n")
};
}
trait ExpectExit<T, R> {
fn expect_exit<F: FnOnce(R)>(self, exit_code: i32, f: F) -> T;
}
impl<T> ExpectExit<T, Option<T>> for Option<T> {
fn expect_exit<F: FnOnce(Option<T>)>(self, exit_code: i32, f: F) -> T {
if let Some(item) = self {
item
} else {
f(None);
process::exit(exit_code);
}
}
}
impl<T, R> ExpectExit<T, R> for Result<T, R> {
fn expect_exit<F: FnOnce(R)>(self, exit_code: i32, f: F) -> T {
match self {
Ok(item) => item,
Err(err) => {
f(err);
process::exit(exit_code);
}
}
}
}
fn main() {
eprintln!("Welcome to the YNABifier setup wizard. This will guide you through the steps needed to create a config.yml");
let error_prefix = "Error".red();
let personal_access_token = prompt_for_personal_access_token();
let client_res = setup_client(&personal_access_token);
let client = client_res.expect_exit(2, |err| {
println!("{}", err);
eprintln!("{} failed to setup YNAB client: {err}", error_prefix);
});
let maybe_budget_id = prompt_for_budget_id(&client).expect_exit(2, |err| {
eprintln!("{} failed to get budget: {}", error_prefix, err);
});
let budget_id = maybe_budget_id.expect_exit(3, |_| {
// if there's no budget id, that's fine, that's because they chose not to have one.
// no need to print anything
});
let accounts = prompt_for_accounts(&client, &budget_id).expect_exit(4, |err| {
eprintln!("{} failed to get accounts: {}", error_prefix, err);
});
let config =
build_config(&personal_access_token, &budget_id, &accounts).expect_exit(5, |err| {
eprintln!("{} failed to build config: {}", error_prefix, err);
});
eprintln!(
"\n{}",
"Configuration generated. You should place the YAML in config.yml and fill in your IMAP account details. Enjoy YNABifier!".green()
);
println!("{}", config);
}
fn prompt_for_personal_access_token() -> String {
eprintln!("Enter your YNAB personal access token");
read_line!()
}
fn prompt_for_budget_id(client: &Client) -> anyhow::Result<Option<String>> {
let budgets = find_budgets(client)?.data.budgets;
if budgets.is_empty() {
Err(anyhow!(
"No budgets were found on your account. Please set up your account to use YNABifier",
))
} else if budgets.len() == 1 {
let budget = &budgets[0];
Ok(confirm_single_choice(budget, "budget").map(|budget| budget.id.to_string()))
} else {
Ok(Some(choose_budget(&budgets)).map(|budget| budget.id.to_string()))
}
}
fn prompt_for_accounts(
client: &Client,
budget_id: &str,
) -> anyhow::Result<Vec<(NamedItem, Parser)>> {
#![allow(clippy::print_literal)]
let accounts = find_accounts(client, budget_id)?.data.accounts;
if accounts.is_empty() {
eprintln!(
"No accounts were found on your budget. Please add accounts to your budget to use YNABifier",
);
return Ok(Vec::new());
}
let accounts = {
if accounts.len() == 1 {
let account = &accounts[0];
confirm_single_choice(account, "account")
.map(|item| vec![item.clone()])
.unwrap_or_default()
} else {
choose_accounts(&accounts).into_iter().cloned().collect()
}
};
eprintln!(
"{} {}\n",
"You must now select an email parser for your account(s). This will be used to read transactions from your emails.",
"If your provider is not here, it will need to be added before you can use this account with YNABifier");
let account_parsers = accounts
.into_iter()
.map(|account| {
let parser = choose_parser_for_account(&account);
(account, parser)
})
.collect();
Ok(account_parsers)
}
fn confirm_single_choice<'a>(choice: &'a NamedItem, item_type: &str) -> Option<&'a NamedItem> {
eprintln!("One {item_type} was found on your account.");
eprintln!("Name: {}", choice.name);
eprintln!("ID: {}", choice.id);
loop {
eprint!("\nWould you like to use this {item_type}? [Y/n]: ");
let selection: String = read_line!();
match selection.to_lowercase().as_ref() {
"y" => return Some(choice),
"n" => return None,
_ => eprintln!("Invalid choice"),
}
}
}
fn choose_budget(budgets: &[NamedItem]) -> &NamedItem {
eprintln!("Multiple budgets were found on your account. Select the one you would like to use");
for (i, budget) in budgets.iter().enumerate() {
let numeric_str = format!("{}) ", i + 1);
eprintln!("{numeric_str}Name: {}", budget.name);
eprintln!("{}ID: {}\n", " ".repeat(numeric_str.len()), budget.id);
}
loop {
eprint!("Which budget would you like? (1-{}): ", budgets.len());
let selection: String = read_line!();
match selection.parse::<usize>() {
Ok(n) if n >= 1 && n <= budgets.len() => return &budgets[n - 1],
Ok(_) => eprintln!("Invalid choice: choice out of bounds"),
Err(err) => eprintln!("Invalid choice: {err}"),
}
}
}
fn choose_accounts(accounts: &[NamedItem]) -> Vec<&NamedItem> {
eprintln!("Multiple budgets were found on your account. Select the one you would like to use");
for (i, account) in accounts.iter().enumerate() {
let numeric_str = format!("{}) ", i + 1);
eprintln!("{numeric_str}Name: {}", account.name);
eprintln!("{}ID: {}\n", " ".repeat(numeric_str.len()), account.id);
}
loop {
eprint!(
"Which accounts would you like? (1-{}; comma separated): ",
accounts.len()
);
let selection_input: String = {
let mut input: String = read_line!();
input.retain(|c| !c.is_whitespace());
input
};
let selections_res = selection_input
.split(',')
.map(|raw_idx| str::parse(raw_idx).map_err(Into::into))
.map(|idx_res| {
idx_res.and_then(|idx| {
if idx >= 1 && idx <= accounts.len() {
Ok(idx)
} else {
Err(anyhow!("choice '{idx}' out of bounds"))
}
})
})
.collect::<Result<Vec<usize>, anyhow::Error>>();
match selections_res {
Ok(selections) => return selections.into_iter().map(|n| &accounts[n - 1]).collect(),
Err(err) => eprintln!("Invalid choice: {err}"),
}
}
}
fn choose_parser_for_account(account: &NamedItem) -> Parser {
let parsers = Parser::iter()
.map(|parser| {
let parser_name: &str = parser.into();
(parser_name.to_string().to_lowercase(), parser)
})
.collect::<HashMap<_, _>>();
let parser_name_str = parsers.keys().join(", ");
loop {
eprintln!(
"Which email parser would you would you like to use for the account \"{}\"? ({}): ",
account.name, parser_name_str,
);
let input: String = read_line!();
match parsers.get(&input.to_lowercase()) {
Some(&parser) => return parser,
None => eprintln!("Invalid choice, must be one of {}", parser_name_str),
}
}
}
fn setup_client(personal_access_token: &str) -> anyhow::Result<Client> {
let mut headers = HeaderMap::new();
headers.insert(
"Authorization",
HeaderValue::from_str(&format!("Bearer {}", personal_access_token))?,
);
headers.insert("Content-Type", HeaderValue::from_static("application/json"));
let client = Client::builder().default_headers(headers).build()?;
Ok(client)
}
fn build_config(
personal_access_token: &str,
budget_id: &str,
accounts: &[(NamedItem, Parser)],
) -> Result<String, anyhow::Error> {
let mut builder = ConfigBuilder::new()
.imap("imap.REPLACEME.com", "user@REPLACEME.com", "SUPER_SECRET")?
.ynab(personal_access_token, budget_id)?;
for (account, parser) in accounts {
builder = builder.ynab_account(&account.id, *parser);
}
let config = builder.build()?;
let config_yaml = serde_yaml::to_string(&config)?;
Ok(config_yaml)
}
#[derive(Deserialize, Clone)]
struct YNABData<D> {
data: D,
}
#[derive(Deserialize, Clone)]
struct Budgets {
budgets: Vec<NamedItem>,
}
#[derive(Deserialize, Clone)]
struct Accounts {
accounts: Vec<NamedItem>,
}
#[derive(Deserialize, Clone)]
struct NamedItem {
id: String,
name: String,
}
fn find_budgets(client: &Client) -> anyhow::Result<YNABData<Budgets>> {
let request = client
.get("https://api.youneedabudget.com/v1/budgets/")
.build()?;
let budgets = client
.execute(request)?
.error_for_status()?
.json::<YNABData<Budgets>>()?;
Ok(budgets)
}
fn find_accounts(client: &Client, budget_id: &str) -> anyhow::Result<YNABData<Accounts>> {
let url = Url::parse("https://api.youneedabudget.com/v1/budgets/")?
.join(&format!("{budget_id}/accounts/"))?;
let request = client.get(url).build()?;
let accounts = client
.execute(request)?
.error_for_status()?
.json::<YNABData<Accounts>>()?;
Ok(accounts)
}

View File

@ -1,9 +1,13 @@
pub use build::{Builder, Error as BuildError};
use log::LevelFilter;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use strum::{EnumIter, IntoStaticStr};
use crate::parse::{CitiEmailParser, TDEmailParser, TransactionEmailParser};
#[derive(Clone, Debug, Deserialize)]
mod build;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(test, derive(PartialEq, Eq))]
enum LogLevel {
#[serde(rename = "debug")]
@ -16,16 +20,16 @@ enum LogLevel {
Error,
}
#[derive(Clone, Debug, Deserialize)]
#[derive(Clone, Copy, Debug, Serialize, Deserialize, EnumIter, IntoStaticStr)]
#[cfg_attr(test, derive(PartialEq, Eq))]
enum Parser {
pub enum Parser {
#[serde(rename = "td")]
TD,
#[serde(rename = "citi")]
Citi,
}
#[derive(Clone, Debug, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(test, derive(PartialEq, Eq))]
pub struct Config {
#[serde(default = "defaults::log_level")]
@ -34,7 +38,7 @@ pub struct Config {
ynab: YNAB,
}
#[derive(Clone, Debug, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(test, derive(PartialEq, Eq))]
pub struct IMAP {
domain: String,
@ -45,7 +49,7 @@ pub struct IMAP {
// unfortunately, we can't configure tls because async_imap requires it
}
#[derive(Clone, Debug, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(test, derive(PartialEq, Eq))]
pub struct YNAB {
personal_access_token: String,
@ -53,7 +57,7 @@ pub struct YNAB {
accounts: Vec<YNABAccount>,
}
#[derive(Clone, Debug, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(test, derive(PartialEq, Eq))]
pub struct YNABAccount {
#[serde(rename = "account_id")]

349
src/config/build.rs Normal file
View File

@ -0,0 +1,349 @@
use super::{Config, Parser, YNABAccount, IMAP, YNAB};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {
#[error("An IMAP configuration is already present")]
AlreadyHaveIMAP,
#[error("A YNAB configuration is already present")]
AlreadyHaveYNAB,
#[error("No IMAP configuration was given")]
NoIMAP,
#[error("No YNAB configuration was given")]
NoYNAB,
#[error("No YNAB accounts were given")]
NoAccounts,
}
#[derive(Default, Debug)]
pub struct Builder {
imap: Option<IMAP>,
ynab: Option<YNAB>,
ynab_accounts: Vec<YNABAccount>,
}
impl Builder {
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Add an IMAP configuration to the configuration. Uses the default port of 993. To use a
/// different port, see [`Self::imap_port_port`].
///
/// # Errors
/// Returns [`Error::AlreadyHaveIMAP`] if IMAP has already been set on this structure.
pub fn imap<S: Into<String>>(self, domain: S, username: S, password: S) -> Result<Self, Error> {
self.imap_with_port(domain, username, password, super::defaults::port())
}
/// Add an IMAP configuration to the configuration.
///
/// # Errors
/// Returns [`Error::AlreadyHaveIMAP`] if IMAP has already been set.
pub fn imap_with_port<S: Into<String>>(
self,
domain: S,
username: S,
password: S,
port: u16,
) -> Result<Self, Error> {
if self.imap.is_some() {
return Err(Error::AlreadyHaveIMAP);
}
let imap_config = IMAP {
domain: domain.into(),
username: username.into(),
password: password.into(),
port,
};
Ok(Self {
imap: Some(imap_config),
..self
})
}
/// Add an You Need A Budget authentication and budget information to the configuration
///
/// # Errors
/// Returns [`Error::AlreadyHaveYNAB`] if YNAB configurations has already been set.
pub fn ynab<S: Into<String>>(
self,
personal_access_token: S,
budget_id: S,
) -> Result<Self, Error> {
if self.ynab.is_some() {
return Err(Error::AlreadyHaveYNAB);
}
let ynab_config = YNAB {
personal_access_token: personal_access_token.into(),
budget_id: budget_id.into(),
accounts: Vec::new(),
};
Ok(Self {
ynab: Some(ynab_config),
..self
})
}
/// Add a YNAB account to the configuration.
#[must_use]
pub fn ynab_account<S: Into<String>>(mut self, account_id: S, parser: Parser) -> Self {
let account = YNABAccount {
id: account_id.into(),
parser,
};
self.ynab_accounts.push(account);
self
}
/// Build the final configuration
///
/// # Errors
/// Returns [`Error::NoIMAP`] if an IMAP configuration was not specified
#[allow(clippy::missing_panics_doc)]
pub fn build(self) -> Result<Config, Error> {
self.validate()?;
let ynab_config = YNAB {
accounts: self.ynab_accounts,
..self.ynab.unwrap()
};
Ok(Config {
imap: self.imap.unwrap(),
ynab: ynab_config,
log_level: super::defaults::log_level(),
})
}
fn validate(&self) -> Result<(), Error> {
if self.imap.is_none() {
return Err(Error::NoIMAP);
} else if self.ynab.is_none() {
return Err(Error::NoYNAB);
} else if self.ynab_accounts.is_empty() {
return Err(Error::NoAccounts);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::config::LogLevel;
use super::*;
#[test]
fn test_builds_configuration_with_default_imap_port() {
let config = (|| -> Result<Config, Error> {
Builder::new()
.imap("imap.gmail.com", "nick@ollien.com", "hunter2")?
.ynab(
"super-secret-dont-leak-this",
"11f70ff5-ce55-4ada-abed-1ac70bac1111",
)?
.ynab_account("1eb157e5-cabe-4fee-a1d5-c011ec7ab1e5", Parser::TD)
.ynab_account("c10711de-5ea7-40e0-be1d-e1a571c51ded", Parser::Citi)
.build()
})()
.expect("failed to build config");
let expected = Config {
log_level: LogLevel::Info,
ynab: YNAB {
personal_access_token: "super-secret-dont-leak-this".to_string(),
budget_id: "11f70ff5-ce55-4ada-abed-1ac70bac1111".to_string(),
accounts: vec![
YNABAccount {
id: "1eb157e5-cabe-4fee-a1d5-c011ec7ab1e5".to_string(),
parser: Parser::TD,
},
YNABAccount {
id: "c10711de-5ea7-40e0-be1d-e1a571c51ded".to_string(),
parser: Parser::Citi,
},
],
},
imap: IMAP {
domain: "imap.gmail.com".to_string(),
username: "nick@ollien.com".to_string(),
password: "hunter2".to_string(),
port: 993,
},
};
assert_eq!(config, expected);
}
#[test]
fn test_builds_configuration_with_custom_imap_port() {
let config = (|| {
Builder::new()
.imap_with_port("imap.gmail.com", "nick@ollien.com", "hunter2", 1337)?
.ynab(
"super-secret-dont-leak-this",
"11f70ff5-ce55-4ada-abed-1ac70bac1111",
)?
.ynab_account("1eb157e5-cabe-4fee-a1d5-c011ec7ab1e5", Parser::TD)
.ynab_account("c10711de-5ea7-40e0-be1d-e1a571c51ded", Parser::Citi)
.build()
})()
.expect("failed to build config");
let expected = Config {
log_level: LogLevel::Info,
ynab: YNAB {
personal_access_token: "super-secret-dont-leak-this".to_string(),
budget_id: "11f70ff5-ce55-4ada-abed-1ac70bac1111".to_string(),
accounts: vec![
YNABAccount {
id: "1eb157e5-cabe-4fee-a1d5-c011ec7ab1e5".to_string(),
parser: Parser::TD,
},
YNABAccount {
id: "c10711de-5ea7-40e0-be1d-e1a571c51ded".to_string(),
parser: Parser::Citi,
},
],
},
imap: IMAP {
domain: "imap.gmail.com".to_string(),
username: "nick@ollien.com".to_string(),
password: "hunter2".to_string(),
port: 1337,
},
};
assert_eq!(config, expected);
}
#[test]
fn test_cannot_build_config_without_imap() {
let err = (|| {
Builder::new()
.imap("imap.gmail.com", "nick@ollien.com", "hunter2")?
.build()
})()
.expect_err("should not have been able to build without YNAB config");
assert!(matches!(err, Error::NoYNAB));
}
#[test]
fn test_cannot_build_config_without_ynab_accounts() {
let err = (|| {
Builder::new()
.imap("imap.gmail.com", "nick@ollien.com", "hunter2")?
.ynab(
"super-secret-dont-leak-this",
"11f70ff5-ce55-4ada-abed-1ac70bac1111",
)?
.build()
})()
.expect_err("should not have been able to build configuration without accounts");
assert!(matches!(err, Error::NoAccounts));
}
#[test]
fn test_cannot_build_config_without_ynab() {
let err = (|| {
Builder::new()
.ynab(
"super-secret-dont-leak-this",
"11f70ff5-ce55-4ada-abed-1ac70bac1111",
)?
.build()
})()
.expect_err("should not have been able to build without IMAP config");
assert!(matches!(err, Error::NoIMAP));
}
#[test]
fn test_cannot_call_imap_twice() {
let builder = Builder::new()
.imap("imap.gmail.com", "nick@ollien.com", "hunter2")
.expect("failed to add imap");
let err = builder
.imap(
"someotherdomain.com",
"me@someotherdomain.com",
"correcthorsebatterystaple",
)
.expect_err("should not have been able to add imap config twice");
assert!(matches!(err, Error::AlreadyHaveIMAP));
}
#[test]
fn test_cannot_call_ynab_twice() {
let builder = Builder::new()
.ynab(
"super-secret-dont-leak-this",
"11f70ff5-ce55-4ada-abed-1ac70bac1111",
)
.expect("failed to add ynab config");
let err = builder
.ynab(
"super-secret-dont-leak-this",
"11f70ff5-ce55-4ada-abed-1ac70bac1111",
)
.expect_err("should not have been able to add imap config twice");
assert!(matches!(err, Error::AlreadyHaveYNAB));
}
#[test]
fn test_can_add_accounts_before_ynab() {
let config = (|| -> Result<Config, Error> {
Builder::new()
.imap("imap.gmail.com", "nick@ollien.com", "hunter2")?
.ynab_account("1eb157e5-cabe-4fee-a1d5-c011ec7ab1e5", Parser::TD)
.ynab_account("c10711de-5ea7-40e0-be1d-e1a571c51ded", Parser::Citi)
.ynab(
"super-secret-dont-leak-this",
"11f70ff5-ce55-4ada-abed-1ac70bac1111",
)?
.build()
})()
.expect("failed to build config");
let expected = Config {
log_level: LogLevel::Info,
ynab: YNAB {
personal_access_token: "super-secret-dont-leak-this".to_string(),
budget_id: "11f70ff5-ce55-4ada-abed-1ac70bac1111".to_string(),
accounts: vec![
YNABAccount {
id: "1eb157e5-cabe-4fee-a1d5-c011ec7ab1e5".to_string(),
parser: Parser::TD,
},
YNABAccount {
id: "c10711de-5ea7-40e0-be1d-e1a571c51ded".to_string(),
parser: Parser::Citi,
},
],
},
imap: IMAP {
domain: "imap.gmail.com".to_string(),
username: "nick@ollien.com".to_string(),
password: "hunter2".to_string(),
port: 993,
},
};
assert_eq!(config, expected);
}
}