Add setup utility to generate configuration
parent
3a34b3f4fc
commit
1db6aa7d6d
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
13
README.md
13
README.md
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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")]
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue