Skip to main content

accounts_cli/
lib.rs

1mod account;
2mod charts;
3mod script;
4mod transaction;
5
6use account::Account;
7use clap::Subcommand;
8use transaction::{Item, Transaction, TransactionRhai};
9
10const TIME_FORMAT: &[time::format_description::FormatItem<'_>] =
11    time_macros::format_description!("[year]-[month]-[day]");
12const TIME_FORMAT_MONTH: &[time::format_description::FormatItem<'_>] =
13    time_macros::format_description!("[month]");
14const TIME_FORMAT_YEAR: &[time::format_description::FormatItem<'_>] =
15    time_macros::format_description!("[year]");
16const TIME_FORMAT_DAY: &[time::format_description::FormatItem<'_>] =
17    time_macros::format_description!("[day]");
18
19// TODO: thiserror
20#[derive(Debug)]
21pub enum Error {
22    Account(String, account::Error),
23    Operation(transaction::Error),
24    InvalidDate(time::error::Parse),
25    ScriptEvaluation(Box<rhai::EvalAltResult>),
26}
27
28impl From<transaction::Error> for Error {
29    fn from(value: transaction::Error) -> Self {
30        Self::Operation(value)
31    }
32}
33
34#[derive(Clone, serde::Deserialize)]
35pub struct ScriptAccountBalance {
36    pub amount: rhai::FLOAT,
37    pub currency: String,
38}
39
40#[derive(Subcommand)]
41pub enum Commands {
42    /// Create a new account. Fails if the account already exists.
43    New {
44        /// Name of the new account.
45        #[arg(short, long)]
46        name: String,
47        /// Name of the new account.
48        #[arg(short, long)]
49        currency: String,
50    },
51    /// Add a spending transaction.
52    Spend {
53        /// The account to add the transaction to.
54        #[arg(long, value_name = "ACCOUNT-NAME")]
55        account: String,
56        /// amount of currency associated to the transaction.
57        #[arg(long, value_name = "amount")]
58        amount: f64,
59        /// Description of the transaction.
60        #[arg(short, long, value_name = "DESCRIPTION")]
61        description: String,
62        /// Tags to classify the transaction.
63        /// Example: --tags=house,family,expenses
64        #[arg(short, long, value_name = "TAGS", value_parser = Commands::parse_tags)]
65        tags: std::collections::HashSet<String>,
66    },
67    /// Add an income transaction.
68    Income {
69        /// The account to add the transaction to.
70        #[arg(long, value_name = "ACCOUNT-NAME")]
71        account: String,
72        /// amount of currency associated to the transaction.
73        #[arg(long, value_name = "amount")]
74        amount: f64,
75        /// Description of the transaction.
76        #[arg(short, long, value_name = "DESCRIPTION")]
77        description: String,
78        /// Tags to classify the transaction.
79        /// Example: --tags=house,family,expenses
80        #[arg(short, long, value_name = "TAGS", value_parser = Commands::parse_tags)]
81        tags: std::collections::HashSet<String>,
82    },
83    /// Display the total balance of accounts.
84    Balance {
85        /// Name of the account to display the balance from. If not specified, will aggregate all
86        /// balances from the accounts in the `--accounts` directory.
87        #[arg(short, long, value_name = "ACCOUNT-NAME")]
88        account: Option<String>,
89        /// Sums balances starting from this date.
90        #[arg(short, long, value_name = "START-DATE", value_parser = Commands::parse_date)]
91        from: Option<time::Date>,
92        /// Sums balances to this date.
93        #[arg(short, long, value_name = "END-DATE", value_parser = Commands::parse_date)]
94        to: Option<time::Date>,
95        /// Write the transactions to a svg chart.
96        #[arg(short, long)]
97        chart: bool,
98        /// Use a rhai script to format the output.
99        #[arg(short, long)]
100        script: Option<std::path::PathBuf>,
101    },
102}
103
104impl Commands {
105    fn parse_tags(
106        s: &str,
107    ) -> Result<std::collections::HashSet<String>, Box<dyn std::error::Error + Send + Sync + 'static>>
108    {
109        Ok(s.split(',').map(|s| s.to_string()).collect())
110    }
111
112    fn parse_date(
113        s: &str,
114    ) -> Result<time::Date, Box<dyn std::error::Error + Send + Sync + 'static>> {
115        time::Date::parse(s, TIME_FORMAT).map_err(|error| error.into())
116    }
117
118    pub fn run(self, accounts_path: &str) -> Result<(), Error> {
119        match self {
120            Commands::New { name, currency } => {
121                Commands::new_account(accounts_path, &name, &currency)
122            }
123            Commands::Income {
124                account,
125                amount,
126                description,
127                tags,
128            } => Commands::write_transaction(
129                accounts_path,
130                &account,
131                Transaction::Income(Item {
132                    date: time::OffsetDateTime::now_utc().date(),
133                    amount,
134                    description: description.to_string(),
135                    tags: tags.clone(),
136                }),
137            ),
138            Commands::Spend {
139                account,
140                amount,
141                description,
142                tags,
143            } => Commands::write_transaction(
144                accounts_path,
145                &account,
146                Transaction::Spending(Item {
147                    date: time::OffsetDateTime::now_utc().date(),
148                    amount,
149                    description,
150                    tags,
151                }),
152            ),
153            Commands::Balance {
154                account,
155                from,
156                to,
157                chart,
158                script,
159            } => Commands::balance(
160                accounts_path,
161                account.as_ref(),
162                from.as_ref(),
163                to.as_ref(),
164                chart,
165                script.as_ref(),
166            ),
167        }
168    }
169
170    fn list_accounts_paths(accounts_path: &str) -> Vec<std::path::PathBuf> {
171        std::fs::read_dir(accounts_path)
172            .map(|dir| {
173                dir.filter_map(|entry| {
174                    let account = entry.expect("entry must be valid").path();
175                    if account.is_file() {
176                        Some(account)
177                    } else {
178                        None
179                    }
180                })
181                .collect()
182            })
183            .unwrap_or_default()
184    }
185
186    fn new_account(accounts_path: &str, name: &str, currency: &str) -> Result<(), Error> {
187        let path = std::path::PathBuf::from_iter([accounts_path, name]);
188
189        if path.exists() {
190            return Err(Error::Account(
191                name.to_string(),
192                account::Error::AlreadyExists,
193            ));
194        }
195
196        Account::open(path, currency).map_err(|error| Error::Account(name.to_string(), error))
197    }
198
199    fn write_transaction(
200        accounts_path: &str,
201        name: &str,
202        transaction: Transaction,
203    ) -> Result<(), Error> {
204        Account::from_file(std::path::PathBuf::from_iter([accounts_path, name]))
205            .map_err(|error| Error::Account(name.to_string(), error))?
206            .push_transaction(transaction)
207            .write()
208            .map_err(|error| Error::Account(name.to_string(), error))
209            .map(|_| ())
210    }
211
212    fn balance(
213        accounts_path: &str,
214        account: Option<&String>,
215        from: Option<&time::Date>,
216        to: Option<&time::Date>,
217        chart: bool,
218        script: Option<&std::path::PathBuf>,
219    ) -> Result<(), Error> {
220        let totals = if let Some(script) = script {
221            let engine = script::build_engine(script);
222            let accounts = Self::get_accounts(accounts_path, account);
223
224            let ast = engine
225                .compile_file(script.into())
226                .map_err(Error::ScriptEvaluation)?;
227            let mut totals = std::collections::HashMap::<String, f64>::new();
228
229            for account in accounts {
230                let fn_name = format!("on_{}", account.name());
231                if ast.iter_functions().any(|func| func.name == fn_name) {
232                    let transactions = account
233                        .transactions_between(from, to)
234                        .map_err(|error| Error::Account(account.name().to_string(), error))?;
235
236                    let parameters: rhai::Dynamic = transactions
237                        .iter()
238                        .map(TransactionRhai::from)
239                        .collect::<Vec<TransactionRhai>>()
240                        .into();
241
242                    let account_balance = engine
243                        .call_fn::<rhai::Dynamic>(
244                            &mut rhai::Scope::new(),
245                            &ast,
246                            fn_name,
247                            (parameters,),
248                        )
249                        .map_err(Error::ScriptEvaluation)?;
250
251                    let (balance, currency) = if account_balance.is_map() {
252                        let balance: ScriptAccountBalance =
253                            rhai::serde::from_dynamic(&account_balance)
254                                .map_err(Error::ScriptEvaluation)?;
255
256                        totals
257                            .entry(balance.currency.clone())
258                            .and_modify(|entry| *entry += balance.amount)
259                            .or_insert(balance.amount);
260
261                        (balance.amount, balance.currency)
262                    } else if account_balance.is_float() {
263                        let balance = account_balance.cast::<rhai::FLOAT>();
264                        totals
265                            .entry(account.currency().to_string())
266                            .and_modify(|entry| *entry += balance)
267                            .or_insert(balance);
268
269                        (balance, account.currency().to_string())
270                    } else {
271                        // FIXME: better error.
272                        return Err(Error::ScriptEvaluation(Box::new(
273                            rhai::EvalAltResult::ErrorRuntime(
274                                rhai::Dynamic::from("return value must be a map or float"),
275                                rhai::Position::NONE,
276                            ),
277                        )));
278                    };
279
280                    match (transactions.first(), transactions.last()) {
281                        (Some(from), Some(to)) => {
282                            println!(
283                                "[{}/{}] balance for '{}': {:.2} {}",
284                                from.date(),
285                                to.date(),
286                                account.name(),
287                                balance,
288                                currency
289                            );
290
291                            if chart {
292                                charts::build(transactions);
293                            }
294                        }
295                        _ => {
296                            println!(
297                                "balance for '{}': 0.00 {}",
298                                account.name(),
299                                account.currency()
300                            );
301                        }
302                    }
303                } else {
304                    let account_balance = Self::list_between(&account, from, to, chart)?;
305
306                    totals
307                        .entry(account.currency().to_string())
308                        .and_modify(|entry| *entry += account_balance)
309                        .or_insert(account_balance);
310                }
311            }
312
313            totals
314        } else {
315            let mut totals = std::collections::HashMap::<String, f64>::new();
316            let accounts = Self::get_accounts(accounts_path, account);
317
318            for account in accounts {
319                let account_balance = Self::list_between(&account, from, to, chart)?;
320
321                totals
322                    .entry(account.currency().to_string())
323                    .and_modify(|entry| *entry += account_balance)
324                    .or_insert(account_balance);
325            }
326
327            totals
328        };
329
330        let mut totals: Vec<(String, f64)> = totals
331            .into_iter()
332            .map(|(currency, total)| (currency.to_string(), total))
333            .collect();
334        totals.sort_by(|(c1, _), (c2, _)| c1.cmp(c2));
335
336        println!("\nTotals:");
337
338        for (currency, total) in totals {
339            println!("  {total:.2} {currency}");
340        }
341
342        Ok(())
343    }
344
345    fn get_accounts(accounts_path: &str, account: Option<&String>) -> Vec<Account> {
346        let mut accounts = if let Some(account) = account {
347            match Account::from_file(std::path::PathBuf::from_iter([accounts_path, account])) {
348                Ok(account) => vec![account],
349                Err(error) => {
350                    println!("failed to open {account:?}: {error:?}");
351                    vec![]
352                }
353            }
354        } else {
355            Self::list_accounts_paths(accounts_path)
356                .into_iter()
357                .filter_map(|path| match Account::from_file(&path) {
358                    Ok(account) => Some(account),
359                    Err(error) => {
360                        println!("failed to open {path:?}: {error:?}");
361                        None
362                    }
363                })
364                .collect::<Vec<_>>()
365        };
366        accounts.sort_by(|a, b| a.name().cmp(b.name()));
367        accounts
368    }
369
370    fn list_between(
371        account: &Account,
372        from: Option<&time::Date>,
373        to: Option<&time::Date>,
374        chart: bool,
375    ) -> Result<f64, Error> {
376        let transactions = account
377            .transactions_between(from, to)
378            .map_err(|error| Error::Account(account.name().to_string(), error))?;
379
380        match (transactions.first(), transactions.last()) {
381            (Some(from), Some(to)) => {
382                let balance: f64 = transactions.iter().map(|op| op.amount()).sum();
383
384                println!(
385                    "[{}/{}] balance for '{}': {:.2} {}",
386                    from.date(),
387                    to.date(),
388                    account.name(),
389                    balance,
390                    account.currency()
391                );
392
393                if chart {
394                    charts::build(transactions);
395                }
396
397                Ok(balance)
398            }
399            _ => {
400                println!(
401                    "balance for '{}': 0.00 {}",
402                    account.name(),
403                    account.currency()
404                );
405                Ok(0.0)
406            }
407        }
408    }
409}