From fa4cc722e27f534137a69626863c25a2c02e8c15 Mon Sep 17 00:00:00 2001 From: James McDonald Date: Sun, 3 May 2026 14:17:50 +0200 Subject: [PATCH] Change object model and add crate Error type --- Cargo.lock | 8 ++ Cargo.toml | 2 + src/csv.rs | 38 +++++-- src/lib.rs | 26 +++++ src/main.rs | 92 ++++++++--------- src/transaction.rs | 16 ++- src/ynab/apitypes.rs | 43 ++++++++ src/ynab/client.rs | 233 ++++++++++++++++++++++-------------------- src/ynab/lookup.rs | 14 --- src/ynab/mod.rs | 7 +- src/ynab/transform.rs | 8 +- src/ynab/types.rs | 43 -------- 12 files changed, 283 insertions(+), 247 deletions(-) create mode 100644 src/ynab/apitypes.rs delete mode 100644 src/ynab/lookup.rs delete mode 100644 src/ynab/types.rs diff --git a/Cargo.lock b/Cargo.lock index 2b5164d..6c19df3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,6 +61,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "atomic-waker" version = "1.1.2" @@ -1959,6 +1965,7 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" name = "ynabmunger" version = "0.1.0" dependencies = [ + "anyhow", "chrono", "clap", "csv", @@ -1966,6 +1973,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "thiserror", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index 04456bd..240e98a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +anyhow = "1.0.102" chrono = "0.4.44" clap = { version = "4.6.1", features = ["derive", "env"] } csv = "1.4.0" @@ -11,4 +12,5 @@ directories = "6.0.0" reqwest = { version = "0.13.3", features = ["blocking"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" +thiserror = "2.0.18" tokio = { version = "1.52.1", features = ["macros", "rt-multi-thread"] } diff --git a/src/csv.rs b/src/csv.rs index cd311f6..89abe21 100644 --- a/src/csv.rs +++ b/src/csv.rs @@ -1,9 +1,11 @@ +use crate::Error; use crate::Transaction; use csv::ReaderBuilder; -use std::error::Error; -use std::io::{Read, Write}; +use std::fs::File; +use std::io::{Read, Write, stdin}; +use std::path::PathBuf; -pub fn output_csv(output: W, transactions: &[Transaction]) -> Result<(), Box> { +pub fn output_csv(output: W, transactions: &[Transaction]) -> Result<(), Error> { let mut writer = csv::Writer::from_writer(output); writer @@ -22,10 +24,10 @@ enum BankFormat { } impl BankFormat { - fn from_str(format: &str) -> Result { + fn from_str(format: &str) -> Result { match format { "bulder" => Ok(BankFormat::Bulder), - _ => Err(format!("Bank '{}' not found", format)), + _ => Err(Error::BankFormat(format.to_string())), } } @@ -38,19 +40,35 @@ impl BankFormat { } } - fn transform(&self, record: &csv::StringRecord) -> Result { + fn transform(&self, record: &csv::StringRecord) -> Result { match self { BankFormat::Bulder => Transaction::from_fields(&record[0], &record[9], &record[1]), } } } -pub fn read_transactions(input: R, format: &str) -> Result, String> { +pub fn read_transactions(input: R, format: &str) -> Result, Error> { 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::, _>>() + .map(|r| format.transform(&r?)) + .collect() +} + +pub fn read_transactions_from(inputs: &[PathBuf], format: &str) -> Result, Error> { + Ok(if inputs.is_empty() { + vec![Box::new(stdin()) as Box] + } else { + inputs + .iter() + .map(|p| -> Result, Error> { Ok(Box::new(File::open(p)?)) }) + .collect::, _>>()? + } + .iter_mut() + .map(|s| read_transactions(s, format)) + .collect::, _>>()? + .into_iter() + .flatten() + .collect()) } diff --git a/src/lib.rs b/src/lib.rs index 7e87c7e..f050fa3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,3 +2,29 @@ mod transaction; pub use transaction::Transaction; pub mod csv; pub mod ynab; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("HTTP Error: {0}")] + Http(#[from] reqwest::Error), + #[error("parse error: {0}")] + Parse(#[from] serde_json::Error), + #[error("csv error: {0}")] + Csv(#[from] ::csv::Error), + #[error("io error: {0}")] + Io(#[from] std::io::Error), + #[error("couldn't parse amount: {0}")] + AmountParse(#[from] std::num::ParseIntError), + + #[error("plan not found: {0}")] + PlanNotFound(String), + #[error("account not found: {0}")] + AccountNotFound(String), + #[error("empty amount")] + AmountEmpty, + #[error("bank format not found: {0}")] + BankFormat(String), + /// Errors I've been too lazy to give a type yet + #[error("{0}")] + Text(String), +} diff --git a/src/main.rs b/src/main.rs index 9e67130..5e87a17 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,8 @@ use clap::{Parser, Subcommand}; -use std::{ - error::Error, - fs::File, - io::{Read, stdin, stdout}, - path::PathBuf, -}; +use std::{io::stdout, path::PathBuf}; -use ynabmunger::Transaction; -use ynabmunger::csv::{output_csv, read_transactions}; -use ynabmunger::ynab::{Lookup, Ynab}; +use ynabmunger::csv::{output_csv, read_transactions_from}; +use ynabmunger::ynab::Client; #[derive(Subcommand)] enum Command { @@ -103,33 +97,13 @@ struct Cli { command: Command, } -fn read_transactions_from( - inputs: &[PathBuf], - format: &str, -) -> Result, Box> { - Ok(if inputs.is_empty() { - vec![Box::new(stdin()) as Box] - } else { - inputs - .into_iter() - .map(|p| -> Result, _> { Ok(Box::new(File::open(p)?)) }) - .collect::, std::io::Error>>()? - } - .iter_mut() - .map(|s| read_transactions(s, format)) - .collect::, _>>()? - .into_iter() - .flatten() - .collect()) -} - #[tokio::main] -async fn main() -> Result<(), Box> { +async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); match cli.command { Command::Plans { token } => { - let plans = Ynab::new(&token).list_plans().await?; + let plans = Client::new(&token).list_plans().await?; println!("Available plans:"); for plan in plans { println!(" - {}", plan); @@ -141,8 +115,14 @@ async fn main() -> Result<(), Box> { plan, plan_id, } => { - let plan = Lookup::from_options(plan, plan_id)?; - let accounts = Ynab::list_accounts(&Ynab::new(&token), plan).await?; + let client = Client::new(&token); + let plan = match (plan, plan_id) { + (_, Some(id)) => Ok(client.plan_from_id(&id)), + (Some(name), _) => Ok(client.plan_from_name(&name).await?), + _ => Err(ynabmunger::Error::Text("no plan given".to_string())), + }?; + + let accounts = plan.list_accounts().await?; println!("Available accounts in plan:"); for account in accounts { println!(" - {}", account); @@ -156,21 +136,24 @@ async fn main() -> Result<(), Box> { account, account_id, } => { - let plan = Lookup::from_options(plan, plan_id)?; + let client = Client::new(&token); + let plan = match (plan, plan_id) { + (_, Some(id)) => Ok(client.plan_from_id(&id)), + (Some(name), _) => Ok(client.plan_from_name(&name).await?), + _ => Err(ynabmunger::Error::Text("no plan given".to_string())), + }?; // Account is optional here - let accountstr: &str; - let account = match (account, account_id) { - (None, None) => { - accountstr = ""; - None - } - (name, id) => { - accountstr = " for account"; - Some(Lookup::from_options(name, id)?) - } + let (account, accountstr) = match (account, account_id) { + (_, Some(id)) => (Some(plan.account_from_id(&id)), " for account"), + (Some(name), _) => (Some(plan.account_from_name(&name).await?), " for account"), + _ => (None, ""), + }; + + let transactions = match account { + Some(account) => account.list_transactions().await?, + None => plan.list_transactions(None).await?, }; - let transactions = Ynab::new(&token).list_transactions(plan, account).await?; println!("Transactions{} in the last 30 days:", accountstr); for t in transactions { println!("{},{},{}", t.date, t.payee, t.amount); @@ -187,16 +170,27 @@ async fn main() -> Result<(), Box> { format, inputs, } => { - let plan = Lookup::from_options(plan, plan_id)?; - let account = Lookup::from_options(account, account_id)?; + let client = Client::new(&token); + let plan = match (plan, plan_id) { + (_, Some(id)) => Ok(client.plan_from_id(&id)), + (Some(name), _) => Ok(client.plan_from_name(&name).await?), + _ => Err(ynabmunger::Error::Text("no plan given".to_string())), + }?; + let account = match (account, account_id) { + (_, Some(id)) => Ok(plan.account_from_id(&id)), + (Some(name), _) => Ok(plan.account_from_name(&name).await?), + _ => Err(ynabmunger::Error::Text("no account given".to_string())), + }?; let transactions = read_transactions_from(&inputs, &format)?; - Ynab::new(&token).upload(&transactions, plan, account).await + account.upload(&transactions).await?; + Ok(()) } Command::Convert { format, inputs } => { let transactions = read_transactions_from(&inputs, &format)?; - output_csv(stdout(), &transactions) + output_csv(stdout(), &transactions)?; + Ok(()) } } } diff --git a/src/transaction.rs b/src/transaction.rs index ce3a20a..64d6803 100644 --- a/src/transaction.rs +++ b/src/transaction.rs @@ -1,20 +1,18 @@ +use crate::Error; + pub struct Transaction { pub date: String, pub payee: String, pub amount: i64, } -fn parse_amount_str(amount: &str) -> Result { +fn parse_amount_str(amount: &str) -> Result { if amount.is_empty() { - return Err("empty amount".to_string()); + return Err(Error::AmountEmpty); } let (whole, frac) = amount.split_once('.').unwrap_or((amount, "00")); - let whole = whole - .parse::() - .map_err(|e| format!("parse error on whole {}: {}", whole, e))?; - let frac = frac - .parse::() - .map_err(|e| format!("parse error on fraction {}: {}", frac, e))?; + let whole = whole.parse::()?; + let frac = frac.parse::()?; let mut amount: i64 = 0; amount += whole * 100 * 10; @@ -24,7 +22,7 @@ fn parse_amount_str(amount: &str) -> Result { impl Transaction { /// Construct a Transaction from [date, payee, amount] strings - pub fn from_fields(date: &str, payee: &str, amount: &str) -> Result { + pub fn from_fields(date: &str, payee: &str, amount: &str) -> Result { Ok(Transaction { date: date.to_owned(), payee: payee.to_owned(), diff --git a/src/ynab/apitypes.rs b/src/ynab/apitypes.rs new file mode 100644 index 0000000..10cbd1f --- /dev/null +++ b/src/ynab/apitypes.rs @@ -0,0 +1,43 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +pub(crate) struct Response { + pub(crate) data: T, +} + +#[derive(Serialize, Deserialize)] +pub(crate) struct TransactionList { + pub(crate) transactions: Vec, +} + +#[derive(Serialize, Deserialize)] +pub(crate) struct Transaction { + pub(crate) import_id: Option, + pub(crate) date: String, + pub(crate) amount: i64, + pub(crate) payee_name: Option, + pub(crate) account_id: String, + pub(crate) cleared: String, +} + +#[derive(Deserialize)] +pub(crate) struct PlanList { + pub(crate) plans: Vec, +} + +#[derive(Deserialize)] +pub(crate) struct Plan { + pub(crate) id: String, + pub(crate) name: String, +} + +#[derive(Deserialize)] +pub(crate) struct AccountList { + pub(crate) accounts: Vec, +} + +#[derive(Deserialize)] +pub(crate) struct Account { + pub(crate) id: String, + pub(crate) name: String, +} diff --git a/src/ynab/client.rs b/src/ynab/client.rs index 94d3381..9b61c27 100644 --- a/src/ynab/client.rs +++ b/src/ynab/client.rs @@ -1,66 +1,23 @@ -use super::lookup::Lookup; +use super::apitypes; use super::transform; -use super::types; +use crate::Error; use crate::Transaction; -pub struct Ynab { +#[derive(Clone)] +pub struct Client { token: String, webclient: reqwest::Client, } -impl Ynab { - pub fn new(token: &str) -> Ynab { - Ynab { +impl Client { + pub fn new(token: &str) -> Client { + Client { token: token.to_owned(), webclient: reqwest::Client::new(), } } - async fn resolve_plan(&self, plan: Lookup) -> Result { - match plan { - Lookup::Id(id) => Ok(id), - Lookup::Name(name) => { - let url = "plans"; - let res: types::YnabResponse = serde_json::from_str( - &self - .get(url) - .await - .map_err(|e| format!("request error: {}", e))?, - ) - .map_err(|e| format!("parse error: {}", e))?; - let plans = res.data.plans; - plans - .into_iter() - .find(|p| p.name == name) - .map(|p| p.id) - .ok_or_else(|| "no matching plan found".to_string()) - } - } - } - - async fn resolve_account(&self, plan_id: &str, account: Lookup) -> Result { - match account { - Lookup::Id(id) => Ok(id), - Lookup::Name(name) => { - let url = format!("plans/{}/accounts", plan_id); - let res: types::YnabResponse = serde_json::from_str( - &self - .get(&url) - .await - .map_err(|e| format!("request error: {}", e))?, - ) - .map_err(|e| format!("parse error: {}", e))?; - let accounts = res.data.accounts; - accounts - .into_iter() - .find(|a| a.name == name) - .map(|a| a.id) - .ok_or_else(|| "no matching account found".to_string()) - } - } - } - - pub(crate) async fn get(&self, request: &str) -> Result { + pub(crate) async fn get(&self, request: &str) -> Result { const URL: &str = "https://api.ynab.com/v1/"; let request = format!("{}{}", URL, request); let res = self @@ -73,7 +30,7 @@ impl Ynab { Ok(text) } - async fn post(&self, request: &str, body: &str) -> Result<(), String> { + async fn post(&self, request: &str, body: &str) -> Result<(), Error> { const URL: &str = "https://api.ynab.com/v1/"; let request = format!("{}{}", URL, request); let res = self @@ -83,92 +40,142 @@ impl Ynab { .bearer_auth(&self.token) .header("Content-Type", "application/json") .send() - .await - .map_err(|e| format!("post error: {}", e))?; + .await?; if res.status().is_success() { - println!( - "successful api response {}: {}", - res.status(), - res.text() - .await - .map_err(|e| format!("error retrieving api error: {}", e))? - ); + // Maybe do something with the status here + // println!( + // "successful api response {}: {}", + // res.status(), + // res.text() + // .await + // .map_err(|e| format!("error retrieving api error: {}", e))? + // ); Ok(()) } else { - Err(format!( + Err(Error::Text(format!( "api error: {}: {}", res.status(), - res.text() - .await - .map_err(|e| format!("error retrieving api error: {}", e))? - )) + res.text().await? + ))) } } - pub async fn list_plans(&self) -> Result, String> { + pub async fn list_plans(&self) -> Result, Error> { let url = String::from("plans"); - let res: types::YnabResponse = serde_json::from_str( - &self - .get(&url) - .await - .map_err(|e| format!("request error: {}", e))?, - ) - .map_err(|e| format!("parse error: {}", e))?; + let res: apitypes::Response = + serde_json::from_str(&self.get(&url).await?)?; Ok(res.data.plans.into_iter().map(|p| p.name).collect()) } - pub async fn list_accounts(&self, plan: Lookup) -> Result, String> { - let plan_id = self.resolve_plan(plan).await?; - let url = format!("plans/{}/accounts", plan_id); - let res: types::YnabResponse = serde_json::from_str( - &self - .get(&url) - .await - .map_err(|e| format!("request error: {}", e))?, - ) - .map_err(|e| format!("parse error: {}", e))?; + pub fn plan_from_id(self, id: &str) -> Plan { + Plan::from_id(self, id) + } + + pub async fn plan_from_name(self, name: &str) -> Result { + Plan::from_name(self, name).await + } +} + +#[derive(Clone)] +pub struct Plan { + client: Client, + id: String, +} + +impl Plan { + fn from_id(client: Client, id: &str) -> Plan { + Plan { + client, + id: id.to_owned(), + } + } + + async fn from_name(client: Client, name: &str) -> Result { + let raw = client.get("plans").await?; + let res: apitypes::Response = serde_json::from_str(&raw)?; + let id = res + .data + .plans + .into_iter() + .find(|p| p.name == name) + .map(|p| p.id) + .ok_or_else(|| Error::PlanNotFound(name.to_string()))?; + Ok(Plan { client, id }) + } + + pub fn account_from_id(&self, id: &str) -> Account { + Account::from_id(self, id) + } + + pub async fn account_from_name(&self, name: &str) -> Result { + Account::from_name(self, name).await + } + + pub async fn list_accounts(&self) -> Result, Error> { + let url = format!("plans/{}/accounts", self.id); + let raw = self.client.get(&url).await?; + let res: apitypes::Response = serde_json::from_str(&raw)?; Ok(res.data.accounts.into_iter().map(|p| p.name).collect()) } pub async fn list_transactions( &self, - plan: Lookup, - account: Option, - ) -> Result, String> { - let plan_id = self.resolve_plan(plan).await?; - let account_id = match account { - Some(account) => Some(self.resolve_account(&plan_id, account).await?), - None => None, - }; + account_id: Option<&str>, + ) -> Result, Error> { let since_date = chrono::Local::now().date_naive() - chrono::Duration::days(30); let since_date = since_date.format("%Y-%m-%d"); - let url = format!("plans/{}/transactions?since_date={}", plan_id, since_date); - let raw = self - .get(&url) - .await - .map_err(|e| format!("request error {}", e))?; - let res: types::YnabResponse = - serde_json::from_str(&raw).map_err(|e| format!("parse error: {}", e))?; + let url = format!("plans/{}/transactions?since_date={}", self.id, since_date); + let raw = self.client.get(&url).await?; + let res: apitypes::Response = serde_json::from_str(&raw)?; Ok(transform::from_ynab_transactions( &res.data.transactions, - account_id.as_deref(), + account_id, )) } +} - pub async fn upload( - &self, - transactions: &[Transaction], - plan: Lookup, - account: Lookup, - ) -> Result<(), Box> { - let plan_id = self.resolve_plan(plan).await?; - let account_id = self.resolve_account(&plan_id, account).await?; - let request = format!("plans/{}/transactions", plan_id); - let body = serde_json::to_string(&types::YnabTransactionList { - transactions: transform::to_ynab_transactions(transactions, &account_id), +pub struct Account { + plan: Plan, + id: String, +} + +impl Account { + fn from_id(plan: &Plan, id: &str) -> Account { + Account { + plan: plan.clone(), + id: id.to_owned(), + } + } + + async fn from_name(plan: &Plan, name: &str) -> Result { + let raw = plan + .client + .get(&format!("plans/{}/accounts", &plan.id)) + .await?; + let res: apitypes::Response = serde_json::from_str(&raw)?; + let id = res + .data + .accounts + .into_iter() + .find(|p| p.name == name) + .map(|p| p.id) + .ok_or_else(|| Error::AccountNotFound(name.to_string()))?; + Ok(Account { + plan: plan.clone(), + id, }) - .map_err(|e| format!("transaction format error: {}", e))?; - self.post(&request, &body).await?; + } + + pub async fn list_transactions(&self) -> Result, Error> { + self.plan.list_transactions(Some(&self.id)).await + } + + pub async fn upload(&self, transactions: &[Transaction]) -> Result<(), Error> { + let request = format!("plans/{}/transactions", self.plan.id); + let body = serde_json::to_string(&apitypes::TransactionList { + transactions: transform::to_ynab_transactions(transactions, &self.id), + })?; + self.plan.client.post(&request, &body).await?; Ok(()) } } diff --git a/src/ynab/lookup.rs b/src/ynab/lookup.rs deleted file mode 100644 index 66e4597..0000000 --- a/src/ynab/lookup.rs +++ /dev/null @@ -1,14 +0,0 @@ -pub enum Lookup { - Name(String), - Id(String), -} - -impl Lookup { - pub fn from_options(name: Option, id: Option) -> Result { - match (name, id) { - (Some(name), _) => Ok(Self::Name(name)), - (_, Some(id)) => Ok(Self::Id(id)), - _ => Err("must provide name or id".to_owned()), - } - } -} diff --git a/src/ynab/mod.rs b/src/ynab/mod.rs index 1a97bbc..4cdbb30 100644 --- a/src/ynab/mod.rs +++ b/src/ynab/mod.rs @@ -1,9 +1,6 @@ -mod lookup; -pub use lookup::Lookup; - -mod types; +mod apitypes; mod client; -pub use client::Ynab; +pub use client::Client; mod transform; diff --git a/src/ynab/transform.rs b/src/ynab/transform.rs index f814e45..f1d6b6f 100644 --- a/src/ynab/transform.rs +++ b/src/ynab/transform.rs @@ -1,11 +1,11 @@ -use super::types; +use super::apitypes; use crate::Transaction; use std::collections::HashMap; pub(crate) fn to_ynab_transactions( transactions: &[Transaction], account_id: &str, -) -> Vec { +) -> Vec { let mut result = Vec::new(); let mut idmap: HashMap<(String, i64), u32> = HashMap::new(); for t in transactions { @@ -15,7 +15,7 @@ pub(crate) fn to_ynab_transactions( *n += 1; *n }; - let y = types::YnabTransaction { + let y = apitypes::Transaction { import_id: Some(format!("YNAB:{}:{}:{}", t.amount, t.date, id)), date: t.date.clone(), amount: t.amount, @@ -29,7 +29,7 @@ pub(crate) fn to_ynab_transactions( } pub(crate) fn from_ynab_transactions( - transactions: &[types::YnabTransaction], + transactions: &[apitypes::Transaction], account_id: Option<&str>, ) -> Vec { transactions diff --git a/src/ynab/types.rs b/src/ynab/types.rs deleted file mode 100644 index f6bd4c6..0000000 --- a/src/ynab/types.rs +++ /dev/null @@ -1,43 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Deserialize, Debug, Serialize)] -pub(crate) struct YnabTransaction { - pub(crate) import_id: Option, - pub(crate) date: String, - pub(crate) amount: i64, - pub(crate) payee_name: Option, - pub(crate) account_id: String, - pub(crate) cleared: String, -} - -#[derive(Serialize, Deserialize, Debug)] -pub(crate) struct YnabResponse { - pub(crate) data: T, -} - -#[derive(Deserialize, Debug)] -pub(crate) struct YnabPlanList { - pub(crate) plans: Vec, -} - -#[derive(Deserialize, Debug)] -pub(crate) struct YnabPlan { - pub(crate) id: String, - pub(crate) name: String, -} - -#[derive(Deserialize, Debug)] -pub(crate) struct YnabAccountList { - pub(crate) accounts: Vec, -} - -#[derive(Deserialize, Debug)] -pub(crate) struct YnabAccount { - pub(crate) id: String, - pub(crate) name: String, -} - -#[derive(Deserialize, Serialize, Debug)] -pub(crate) struct YnabTransactionList { - pub(crate) transactions: Vec, -}