Refactor and add new commands and doc comments

This commit is contained in:
2026-05-01 17:24:29 +02:00
parent e9d6aa2e31
commit ec77c27700
4 changed files with 213 additions and 168 deletions

56
src/csv.rs Normal file
View File

@@ -0,0 +1,56 @@
use crate::Transaction;
use csv::ReaderBuilder;
use std::error::Error;
use std::io::{Read, Write};
pub fn output_csv<W: Write>(output: W, transactions: &[Transaction]) -> Result<(), Box<dyn Error>> {
let mut writer = csv::Writer::from_writer(output);
writer
.write_record(["Date", "Payee", "Memo", "Amount"])
.unwrap();
for transaction in transactions {
let output = transaction.to_record();
writer.write_record(output).unwrap();
}
writer.flush().unwrap();
Ok(())
}
enum BankFormat {
Bulder,
}
impl BankFormat {
fn from_str(format: &str) -> Result<BankFormat, String> {
match format {
"bulder" => Ok(BankFormat::Bulder),
_ => Err(format!("Bank '{}' not found", format)),
}
}
fn reader<R: Read>(&self, input: R) -> csv::Reader<R> {
match self {
BankFormat::Bulder => ReaderBuilder::new()
.has_headers(true)
.delimiter(b';')
.from_reader(input),
}
}
fn transform(&self, record: &csv::StringRecord) -> Result<Transaction, String> {
match self {
BankFormat::Bulder => Transaction::from_fields(&record[0], &record[9], &record[1]),
}
}
}
pub fn read_transactions<R: Read>(input: R, format: &str) -> Result<Vec<Transaction>, String> {
let format = BankFormat::from_str(format).unwrap();
format
.reader(input)
.records()
.map(|r| r.map_err(|e| format!("parse error: {}", e)))
.map(|r| r.and_then(|r| format.transform(&r)))
.collect::<Result<Vec<_>, _>>()
}

View File

@@ -1,43 +1,4 @@
mod transaction; mod transaction;
pub use transaction::Transaction; pub use transaction::Transaction;
pub mod csv;
pub mod ynab; pub mod ynab;
use csv::ReaderBuilder;
use std::io::Read;
enum BankFormat {
Bulder,
}
impl BankFormat {
fn from_str(format: &str) -> Result<BankFormat, String> {
match format {
"bulder" => Ok(BankFormat::Bulder),
_ => Err(format!("Bank '{}' not found", format)),
}
}
fn reader<R: Read>(&self, input: R) -> csv::Reader<R> {
match self {
BankFormat::Bulder => ReaderBuilder::new()
.has_headers(true)
.delimiter(b';')
.from_reader(input),
}
}
fn transform(&self, record: &csv::StringRecord) -> Result<Transaction, String> {
match self {
BankFormat::Bulder => Transaction::from_fields(&record[0], &record[9], &record[1]),
}
}
}
pub fn read_transactions<R: Read>(input: R, format: &str) -> Result<Vec<Transaction>, String> {
let format = BankFormat::from_str(format).unwrap();
format
.reader(input)
.records()
.map(|r| r.map_err(|e| format!("parse error: {}", e)))
.map(|r| r.and_then(|r| format.transform(&r)))
.collect::<Result<Vec<_>, _>>()
}

View File

@@ -1,5 +1,4 @@
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
//use directories::ProjectDirs;
use std::{ use std::{
error::Error, error::Error,
fs::File, fs::File,
@@ -7,102 +6,83 @@ use std::{
path::PathBuf, path::PathBuf,
}; };
use ynabmunger::ynab; use ynabmunger::Transaction;
use ynabmunger::{Transaction, read_transactions}; use ynabmunger::csv::{output_csv, read_transactions};
use ynabmunger::ynab::{Lookup, Ynab};
fn output_csv(transactions: &[Transaction]) -> Result<(), Box<dyn Error>> {
let mut writer = csv::Writer::from_writer(stdout());
writer
.write_record(["Date", "Payee", "Memo", "Amount"])
.unwrap();
for transaction in transactions {
let output = transaction.to_record();
writer.write_record(output).unwrap();
}
writer.flush().unwrap();
Ok(())
}
#[derive(Debug)]
enum Output {
Csv,
Ynab {
token: String,
plan: ynab::Lookup,
account: ynab::Lookup,
},
}
#[derive(Subcommand)] #[derive(Subcommand)]
enum Command { enum Command {
Csv { /// List available plans in YNAB
inputs: Vec<PathBuf>, Plans {
/// Your YNAB token, available from developer settings in YNAB
#[arg(short, long, env = "YNAB_API_TOKEN", hide_env_values = true)]
token: String,
}, },
Ynab { /// List available accounts in a given plan in YNAB
#[arg(short, long, env = "YNAB_API_TOKEN")] Accounts {
/// Your YNAB token, available from developer settings in YNAB
#[arg(short, long, env = "YNAB_API_TOKEN", hide_env_values = true)]
token: String, token: String,
#[arg(short, long, help = "Plan name", group = "planref")] /// The name of the YNAB plan to list
#[arg(short, long, group = "planref")]
plan: Option<String>, plan: Option<String>,
#[arg(short = 'P', long, help = "Plan ID", group = "planref")] /// Alternatively, give the YNAB plan ID directly
#[arg(short = 'P', long, group = "planref")]
plan_id: Option<String>,
},
/// Convert from a bank export to a CSV you can import manually to YNAB
Convert {
#[arg(short, long, help = "Bank export format", default_value_t = String::from("bulder"))]
format: String,
inputs: Vec<PathBuf>,
},
/// Read a bank export and import it directly to an account in ynab
Import {
/// Your YNAB token, available from developer settings in YNAB
#[arg(short, long, env = "YNAB_API_TOKEN", hide_env_values = true)]
token: String,
/// The name of the YNAB plan to import to
#[arg(short, long, group = "planref")]
plan: Option<String>,
/// Alternatively, give the YNAB plan ID directly
#[arg(short = 'P', long, group = "planref")]
plan_id: Option<String>, plan_id: Option<String>,
#[arg(short, long, help = "Account name", group = "accountref")] /// The name of the YNAB account to import to
#[arg(short, long, group = "accountref")]
account: Option<String>, account: Option<String>,
#[arg(short = 'A', long, help = "Account ID", group = "accountref")] /// Alternatively, give the YNAB account ID directly
#[arg(short = 'A', long, group = "accountref")]
account_id: Option<String>, account_id: Option<String>,
/// The format of the bank export you are importing
///
/// This is different for every bank, and sometimes even the same
/// bank can have different formats for different account types.
#[arg(short, long, default_value_t = String::from("bulder"))]
format: String,
inputs: Vec<PathBuf>, inputs: Vec<PathBuf>,
}, },
} }
#[derive(Parser)] #[derive(Parser)]
struct Cli { struct Cli {
#[arg(short, long, help = "Bank export format", default_value_t = String::from("bulder"))]
format: String,
#[command(subcommand)] #[command(subcommand)]
command: Command, command: Command,
} }
fn main() -> Result<(), Box<dyn Error>> { fn read_transactions_from(
let cli = Cli::parse(); inputs: &[PathBuf],
format: &str,
// Inject config file stuff here ) -> Result<Vec<Transaction>, Box<dyn Error>> {
Ok(if inputs.is_empty() {
let inputs: Vec<PathBuf>;
let output = match cli.command {
Command::Ynab {
plan,
plan_id,
account_id,
account,
inputs: inp,
token,
} => {
inputs = inp;
let output_plan = ynab::Lookup::try_from((plan, plan_id))?;
let output_account = ynab::Lookup::try_from((account, account_id))?;
Output::Ynab {
token,
plan: output_plan,
account: output_account,
}
}
Command::Csv { inputs: inp } => {
inputs = inp;
Output::Csv
}
};
// Config file reconciliation here?
let transactions: Vec<Transaction> = if inputs.is_empty() {
vec![Box::new(stdin()) as Box<dyn Read>] vec![Box::new(stdin()) as Box<dyn Read>]
} else { } else {
inputs inputs
@@ -111,18 +91,57 @@ fn main() -> Result<(), Box<dyn Error>> {
.collect::<Result<Vec<_>, std::io::Error>>()? .collect::<Result<Vec<_>, std::io::Error>>()?
} }
.iter_mut() .iter_mut()
.map(|s| read_transactions(s, &cli.format)) .map(|s| read_transactions(s, format))
.collect::<Result<Vec<_>, _>>()? .collect::<Result<Vec<_>, _>>()?
.into_iter() .into_iter()
.flatten() .flatten()
.collect(); .collect())
}
match output { fn main() -> Result<(), Box<dyn Error>> {
Output::Ynab { let cli = Cli::parse();
match cli.command {
Command::Plans { token } => {
let plans = Ynab::new(&token).list_plans()?;
println!("Available plans:");
for plan in plans {
println!(" - {}", plan);
}
Ok(())
}
Command::Accounts {
token, token,
plan, plan,
plan_id,
} => {
let plan = Lookup::try_from((plan, plan_id))?;
let accounts = Ynab::new(&token).list_accounts(plan)?;
println!("Available accounts in plan:");
for account in accounts {
println!(" - {}", account);
}
Ok(())
}
Command::Import {
token,
plan,
plan_id,
account_id,
account, account,
} => ynab::Ynab::new(&token, plan, account)?.upload(&transactions), format,
Output::Csv => output_csv(&transactions), inputs,
} => {
let plan = Lookup::try_from((plan, plan_id))?;
let account = Lookup::try_from((account, account_id))?;
let transactions = read_transactions_from(&inputs, &format)?;
Ynab::new(&token).upload(&transactions, plan, account)
}
Command::Convert { format, inputs } => {
let transactions = read_transactions_from(&inputs, &format)?;
output_csv(stdout(), &transactions)
}
} }
} }

View File

@@ -43,14 +43,32 @@ struct YnabTransactionList {
transactions: Vec<YnabTransaction>, transactions: Vec<YnabTransaction>,
} }
struct YnabClient { #[derive(Debug)]
pub enum Lookup {
Name(String),
Id(String),
}
impl TryFrom<(Option<String>, Option<String>)> for Lookup {
type Error = String;
fn try_from((name, id): (Option<String>, Option<String>)) -> Result<Self, Self::Error> {
match (name, id) {
(Some(name), _) => Ok(Lookup::Name(name)),
(_, Some(id)) => Ok(Lookup::Id(id)),
_ => Err("must provide name or id".to_owned()),
}
}
}
pub struct Ynab {
token: String, token: String,
webclient: reqwest::blocking::Client, webclient: reqwest::blocking::Client,
} }
impl YnabClient { impl Ynab {
fn new(token: &str) -> YnabClient { pub fn new(token: &str) -> Ynab {
YnabClient { Ynab {
token: token.to_owned(), token: token.to_owned(),
webclient: reqwest::blocking::Client::new(), webclient: reqwest::blocking::Client::new(),
} }
@@ -130,50 +148,35 @@ impl YnabClient {
)) ))
} }
} }
}
#[derive(Debug)] pub fn list_plans(&self) -> Result<Vec<String>, String> {
pub enum Lookup { let url = String::from("plans");
Name(String), let res: YnabResponse<YnabPlanList> = serde_json::from_str(
Id(String), &self
} .get(&url)
.map_err(|e| format!("request error: {}", e))?,
impl TryFrom<(Option<String>, Option<String>)> for Lookup { )
type Error = String; .map_err(|e| format!("parse error: {}", e))?;
Ok(res.data.plans.into_iter().map(|p| p.name).collect())
fn try_from((name, id): (Option<String>, Option<String>)) -> Result<Self, Self::Error> {
match (name, id) {
(Some(name), _) => Ok(Lookup::Name(name)),
(_, Some(id)) => Ok(Lookup::Id(id)),
_ => Err("must provide name or id".to_owned()),
}
}
}
pub struct Ynab {
client: YnabClient,
plan_id: String,
account_id: String,
}
impl Ynab {
pub fn new(token: &str, plan: Lookup, account: Lookup) -> Result<Ynab, String> {
let client = YnabClient::new(token);
let plan_id = client.resolve_plan(plan)?;
let account_id = client.resolve_account(&plan_id, account)?;
Ok(Ynab {
client,
plan_id,
account_id,
})
} }
pub fn dump(&self, transactions: &[Transaction]) { pub fn list_accounts(&self, plan: Lookup) -> Result<Vec<String>, String> {
println!("{:?}", self.ynab_transactions(transactions)); let plan_id = self.resolve_plan(plan)?;
let url = format!("plans/{}/accounts", plan_id);
let res: YnabResponse<YnabAccountList> = serde_json::from_str(
&self
.get(&url)
.map_err(|e| format!("request error: {}", e))?,
)
.map_err(|e| format!("parse error: {}", e))?;
Ok(res.data.accounts.into_iter().map(|p| p.name).collect())
} }
// YNAB:-294230:2015-12-30:1 fn ynab_transactions(
fn ynab_transactions(&self, transactions: &[Transaction]) -> Vec<YnabTransaction> { &self,
transactions: &[Transaction],
account_id: &str,
) -> Vec<YnabTransaction> {
let mut result = Vec::new(); let mut result = Vec::new();
let mut idmap: HashMap<(String, i64), u32> = HashMap::new(); let mut idmap: HashMap<(String, i64), u32> = HashMap::new();
for t in transactions { for t in transactions {
@@ -188,22 +191,28 @@ impl Ynab {
date: t.date.clone(), date: t.date.clone(),
amount: t.amount, amount: t.amount,
payee_name: t.payee.clone(), payee_name: t.payee.clone(),
account_id: self.account_id.clone(), account_id: account_id.to_string(),
}; };
result.push(y); result.push(y);
} }
result result
} }
pub fn upload(&self, transactions: &[Transaction]) -> Result<(), Box<dyn std::error::Error>> { pub fn upload(
let request = format!("plans/{}/transactions", self.plan_id); &self,
transactions: &[Transaction],
plan: Lookup,
account: Lookup,
) -> Result<(), Box<dyn std::error::Error>> {
let plan_id = self.resolve_plan(plan)?;
let account_id = self.resolve_account(&plan_id, account)?;
let request = format!("plans/{}/transactions", plan_id);
let body = serde_json::to_string(&YnabTransactionList { let body = serde_json::to_string(&YnabTransactionList {
transactions: self.ynab_transactions(transactions), transactions: self.ynab_transactions(transactions, &account_id),
}) })
.map_err(|e| format!("transaction format error: {}", e))?; .map_err(|e| format!("transaction format error: {}", e))?;
println!("{}", body); println!("{}", body);
self.client.post(&request, &body)?; self.post(&request, &body)?;
Ok(()) Ok(())
} }
} }