From 408b0ac993496b108ec1e479151d549e9535051a Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Mon, 5 Oct 2020 21:20:59 +0300 Subject: initial commit --- .gitignore | 1 + Cargo.lock | 261 +++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 24 +++++ LICENSE | 21 ++++ README.md | 119 ++++++++++++++++++++++ budget/Cargo.toml | 12 +++ budget/src/lib.rs | 162 ++++++++++++++++++++++++++++++ budget/tests/budget.rs | 149 ++++++++++++++++++++++++++++ budget/tests/test.toml | 53 ++++++++++ src/main.rs | 244 +++++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 1046 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 budget/Cargo.toml create mode 100644 budget/src/lib.rs create mode 100644 budget/tests/budget.rs create mode 100644 budget/tests/test.toml create mode 100644 src/main.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb5a316 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..70f1fc3 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,261 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +[[package]] +name = "ansi_term" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b" +dependencies = [ + "winapi", +] + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" + +[[package]] +name = "bitflags" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" + +[[package]] +name = "budget" +version = "0.1.0" +dependencies = [ + "chrono", + "serde", + "toml", +] + +[[package]] +name = "chrono" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" +dependencies = [ + "libc", + "num-integer", + "num-traits", + "time", + "winapi", +] + +[[package]] +name = "clap" +version = "2.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37e58ac78573c40708d45522f0d80fa2f01cc4f9b4e2bf749807255454312002" +dependencies = [ + "ansi_term", + "atty", + "bitflags", + "strsim", + "textwrap", + "unicode-width", + "vec_map", +] + +[[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 = "finbudg" +version = "0.1.0" +dependencies = [ + "budget", + "chrono", + "clap", + "colored", +] + +[[package]] +name = "hermit-abi" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c30f6d0bc6b00693347368a67d41b58f2fb851215ff1da49e90fe2c5c667151" +dependencies = [ + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa7087f49d294270db4e1928fc110c976cd4b9e5a16348e0a1df09afa99e6c98" + +[[package]] +name = "num-integer" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d59457e662d541ba17869cf51cf177c0b5f0cbf476c66bdc90bf1edac4f875b" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac267bcc07f48ee5f8935ab0d24f316fb722d7a1292e2913f0cc196b29ffd611" +dependencies = [ + "autocfg", +] + +[[package]] +name = "proc-macro2" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0704ee1a7e00d7bb417d0770ea303c1bccbabf0ef1667dae92b5967f5f8a71" +dependencies = [ + "unicode-xid", +] + +[[package]] +name = "quote" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa563d17ecb180e500da1cfd2b028310ac758de548efdd203e18f283af693f37" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96fe57af81d28386a513cbc6858332abc6117cfdb5999647c6444b8f43a370a5" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f630a6370fd8e457873b4bd2ffdae75408bc291ba72be773772a4c2a065d9ae8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "strsim" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" + +[[package]] +name = "syn" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c51d92969d209b54a98397e1b91c8ae82d8c87a7bb87df0b29aa2ad81454228" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "time" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255" +dependencies = [ + "libc", + "wasi", + "winapi", +] + +[[package]] +name = "toml" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc92d160b1eef40665be3a05630d003936a3bc7da7421277846c2613e92c71a" +dependencies = [ + "serde", +] + +[[package]] +name = "unicode-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3" + +[[package]] +name = "unicode-xid" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564" + +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + +[[package]] +name = "wasi" +version = "0.10.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..776e878 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "finbudg" +version = "0.1.0" +edition = "2018" +description = "Quick cli tool to calculate your expenses and balance for a set period of time." +license = "MIT" +readme = "README.md" +authors = ["Yaroslav de la Peña Smirnov "] +homepage = "https://www.yaroslavps.com/" +repository = "https://github.com/Yaroslav-95/finbudg" + +[dependencies] +clap = "2.33" +colored = "2.0" +chrono = "0.4" +budget = { path = "budget" } + +[workspace] +members = [ + "budget" +] + +[profile.release] +lto = true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1412902 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Yaroslav de la Peña Smirnov + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..303d8cb --- /dev/null +++ b/README.md @@ -0,0 +1,119 @@ +# finbudg + +Quick cli tool to calculate your expenses and balance for a set period of time. + +## TO-DO + +* Take into account shared expenses +* Make AUR package +* Make error messages more useful +* Show what is being spent most money on +* (Maybe) a way to interactively edit an input file + +## How to install + +For now the only way to install this, is by cloning or downloading the repo, and +building it from source with cargo: + +``` +cargo build --release +``` + +From there, if you would like to have this program on your path, you can copy +it -- for example on Arch Linux -- to `/usr/bin/`. + +## Example + +``` +finbudg input.toml +``` + +### Input: + +```toml +start_date = 2020-10-01 +end_date = 2020-10-31 +budget = 420.0 +essential_categories = [ + "products", + "transport", + "utilities", +] + +[[days]] +date = 2020-10-01 + + [[days.expenses]] + name = "Potato masher" + price = 3.81 + category = "supplies" + + [[days.expenses]] + name = "Bacon" + price = 3.33 + category = "products" + shared = 2 + + [[days.expenses]] + name = "Yoghurt" + price = 1.24 + category = "products" + qty = 2 + + [[days.expenses]] + name = "Onion" + price = 0.15 + category = "products" + + [[days.expenses]] + name = "Chicken" + price = 2.28 + category = "products" + shared = 2 + +[[days]] +date = 2020-10-02 + + [[days.expenses]] + name = "VPS" + price = 5.0 + category = "utilities" + recurring = true + + [[days.expenses]] + name = "Transport card" + price = 6.9 + category = "transport" +``` + +### Output: + +``` +Your expenses for the period of 2020-10-01 - 2020-10-31 +Last day on entry: 2020-10-02 +Days until period end: 29 +Budget: 420.00 + +Average per day in utilities: 2.50 +Average per day in supplies: 1.91 +Average per day in transport: 3.45 +Average per day in products: 3.50 +Average per day in essential expenses: 9.45 +Average per day: 11.36 + +Total in products: 7.00 +Total in transport: 6.90 +Total in supplies: 3.81 +Total in utilities: 5.00 +Total in essential expenses: 18.90 +Total: 22.71 + +Left on balance: 397.29 + +Days until balance runs out: +..taking into account all expenses: 34.99 +..taking into account only essential expenses: 42.04 + +Your expenses are healthy, they should last you from your last day on entry +through your last day of the period. +``` diff --git a/budget/Cargo.toml b/budget/Cargo.toml new file mode 100644 index 0000000..4c1c63e --- /dev/null +++ b/budget/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "budget" +version = "0.1.0" +authors = ["Yaroslav "] +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +toml = "0.5" +chrono = "0.4" diff --git a/budget/src/lib.rs b/budget/src/lib.rs new file mode 100644 index 0000000..4dab46a --- /dev/null +++ b/budget/src/lib.rs @@ -0,0 +1,162 @@ +use std::collections::HashMap; +use std::io::ErrorKind; +use std::fs; + +use toml::de::Error as DeserializerError; +use serde::{Deserialize, Deserializer}; +use chrono::NaiveDate; + +#[derive(Deserialize, PartialEq, Debug)] +pub struct Account { + #[serde(deserialize_with = "deserialize_date")] + pub start_date: NaiveDate, + #[serde(deserialize_with = "deserialize_date")] + pub end_date: NaiveDate, + pub budget: f64, + #[serde(default)] + pub essential_categories: Vec, + pub days: Vec, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct Day { + #[serde(deserialize_with = "deserialize_date")] + pub date: NaiveDate, + pub expenses: Vec, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct Expense { + pub name: String, + pub price: f64, + #[serde(default = "shared_qty_default")] + pub qty: u32, // unused for now, might use it the future or remove it + #[serde(default = "shared_qty_default")] + pub shared: u32, + #[serde(default = "recurring_default")] + pub recurring: bool, + #[serde(default)] + pub category: Option, +} + +#[derive(PartialEq, Debug)] +pub struct Calculated { + pub all_day_average: f64, + pub essential_day_average: f64, + pub categories_day_average: HashMap, + pub essential_subtotal: f64, + pub categories_subtotal: HashMap, + pub total: f64, + pub balance: f64, + pub days_left: f64, + pub days_left_essential: f64, +} + +#[derive(PartialEq, Eq, Debug)] +pub enum ParseError { + IOError(ErrorKind), + DeserializerError(DeserializerError), +} + +fn shared_qty_default() -> u32 { + 1 +} + +fn recurring_default() -> bool { + false +} + +// Parse the dates from toml's Datetime to Chrono's NaiveDate +// Probably unnecessary for now, but since I am planning on using the dates in +// the future to more easily count the days, it would be better to have them in +// a proper format +fn deserialize_date<'de, D>(deserializer: D) -> Result +where D: Deserializer<'de> { + toml::value::Datetime::deserialize(deserializer) + .map(|v| { + let s = v.to_string(); + + NaiveDate::parse_from_str(&s, "%Y-%m-%d") + })? + .map_err(serde::de::Error::custom) +} + +pub fn parse_account(path: &str) -> Result { + let contents = match fs::read_to_string(path) { + Ok(data) => data, + Err(error) => { + return Err(ParseError::IOError(error.kind())); + }, + }; + + match toml::from_str::(&contents) { + Ok(budget) => Ok(budget), + Err(error) => Err(ParseError::DeserializerError(error)), + } +} + +pub fn calculate(account: &Account) -> Calculated { + let mut calculated = Calculated { + all_day_average: 0.0, + essential_day_average: 0.0, + categories_day_average: HashMap::::new(), + essential_subtotal: 0.0, + categories_subtotal: HashMap::::new(), + total: 0.0, + balance: 0.0, + days_left: 0.0, + days_left_essential: 0.0, + }; + + let mut days_calculated = 0; + + for day in account.days.iter() { + days_calculated += 1; + + for expense in day.expenses.iter() { + calculated.total += expense.price; + + if let Some(category) = &expense.category { + if let Some(category_subtotal) = + calculated.categories_subtotal.get_mut(category) { + *category_subtotal += expense.price; + } else { + calculated.categories_subtotal.insert( + category.to_string(), + expense.price, + ); + } + + if account.essential_categories.contains(category) { + calculated.essential_subtotal += expense.price; + } + } + } + } + + calculated.all_day_average = calculated.total / days_calculated as f64; + calculated.essential_day_average = + calculated.essential_subtotal / days_calculated as f64; + + for (category, subtotal) in calculated.categories_subtotal.iter() { + calculated.categories_day_average + .insert( + category.clone(), + subtotal / days_calculated as f64, + ); + } + + calculated.balance = account.budget - calculated.total; + + calculated.days_left = calculated.balance / calculated.all_day_average; + calculated.days_left_essential = + calculated.balance / calculated.essential_day_average; + + calculated +} + +pub fn get_calculated(path: &str) -> Result { + let account = parse_account(path)?; + + Ok(calculate(&account)) +} diff --git a/budget/tests/budget.rs b/budget/tests/budget.rs new file mode 100644 index 0000000..32828f8 --- /dev/null +++ b/budget/tests/budget.rs @@ -0,0 +1,149 @@ +use std::collections::HashMap; + +use chrono::prelude::*; + +use budget::*; + +#[test] +fn can_parse_account() -> Result<(), ParseError>{ + let should_be = Account { + start_date: NaiveDate::from_ymd(2020, 10, 1), + end_date: NaiveDate::from_ymd(2020, 10, 31), + budget: 420.0, + essential_categories: vec![ + String::from("products"), + String::from("transport"), + String::from("utilities"), + ], + days: vec![ + Day { + date: NaiveDate::from_ymd(2020, 10, 1), + expenses: vec![ + Expense { + name: String::from("Potato masher"), + price: 3.81, + qty: 1, + shared: 1, + recurring: false, + category: Some(String::from("supplies")), + }, + Expense { + name: String::from("Bacon"), + price: 3.33, + qty: 1, + shared: 2, + recurring: false, + category: Some(String::from("products")), + }, + Expense { + name: String::from("Yoghurt"), + price: 1.24, + qty: 2, + shared: 1, + recurring: false, + category: Some(String::from("products")), + }, + Expense { + name: String::from("Onion"), + price: 0.15, + qty: 1, + shared: 1, + recurring: false, + category: Some(String::from("products")), + }, + Expense { + name: String::from("Chicken"), + price: 2.28, + qty: 1, + shared: 2, + recurring: false, + category: Some(String::from("products")), + }, + ], + }, + Day { + date: NaiveDate::from_ymd(2020, 10, 2), + expenses: vec![ + Expense { + name: String::from("VPS"), + price: 5.0, + qty: 1, + shared: 1, + recurring: true, + category: Some(String::from("utilities")), + }, + Expense { + name: String::from("Transport card"), + price: 6.9, + qty: 1, + shared: 1, + recurring: false, + category: Some(String::from("transport")), + }, + ], + }, + ], + }; + + let actually_is = budget::parse_account("tests/test.toml")?; + + assert_eq!(actually_is, should_be); + + Ok(()) +} + +#[test] +fn can_calculate() -> Result<(), ParseError> { + let mut should_be = Calculated { + all_day_average: 11.355, + essential_day_average: 9.45, + categories_day_average: HashMap::::new(), + essential_subtotal: 18.9, + categories_subtotal: HashMap::::new(), + total: 22.71, + balance: 397.29, + days_left: 34.9881109643329, + days_left_essential: 42.041269841269845, + }; + + should_be.categories_day_average.insert( + "supplies".to_string(), + 1.905, + ); + should_be.categories_day_average.insert( + "products".to_string(), + 3.5, + ); + should_be.categories_day_average.insert( + "transport".to_string(), + 3.45, + ); + should_be.categories_day_average.insert( + "utilities".to_string(), + 2.5, + ); + + should_be.categories_subtotal.insert( + "supplies".to_string(), + 3.81, + ); + should_be.categories_subtotal.insert( + "products".to_string(), + 7.0, + ); + should_be.categories_subtotal.insert( + "transport".to_string(), + 6.9, + ); + should_be.categories_subtotal.insert( + "utilities".to_string(), + 5.0, + ); + + let account = budget::parse_account("tests/test.toml")?; + let actually_is = budget::calculate(&account); + + assert_eq!(actually_is, should_be); + + Ok(()) +} diff --git a/budget/tests/test.toml b/budget/tests/test.toml new file mode 100644 index 0000000..7dd5774 --- /dev/null +++ b/budget/tests/test.toml @@ -0,0 +1,53 @@ +start_date = 2020-10-01 +end_date = 2020-10-31 +budget = 420.0 +essential_categories = [ + "products", + "transport", + "utilities", +] + +[[days]] +date = 2020-10-01 + + [[days.expenses]] + name = "Potato masher" + price = 3.81 + category = "supplies" + + [[days.expenses]] + name = "Bacon" + price = 3.33 + category = "products" + shared = 2 + + [[days.expenses]] + name = "Yoghurt" + price = 1.24 + category = "products" + qty = 2 + + [[days.expenses]] + name = "Onion" + price = 0.15 + category = "products" + + [[days.expenses]] + name = "Chicken" + price = 2.28 + category = "products" + shared = 2 + +[[days]] +date = 2020-10-02 + + [[days.expenses]] + name = "VPS" + price = 5.0 + category = "utilities" + recurring = true + + [[days.expenses]] + name = "Transport card" + price = 6.9 + category = "transport" diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..d091965 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,244 @@ +use clap::{ + Arg, + App, + ArgMatches, + crate_version, + crate_authors, + crate_description +}; +use chrono::Duration; +use colored::*; + +use budget::*; + +fn main() { + let matches = get_cli_matches(); + + let no_color = matches.occurrences_of("plain") > 0; + let force_color = matches.occurrences_of("force-color") > 0; + let input = matches.value_of("INPUT").unwrap(); + + let account = match budget::parse_account(input) { + Ok(data) => data, + Err(error) => { + match error { + ParseError::IOError(kind) => { + println!("IO error while parsing: {:?}", kind); + }, + ParseError::DeserializerError(_) => { + println!("Can't parse the file, invalid syntax"); + }, + } + + ::std::process::exit(1); + } + }; + let calculated = budget::calculate(&account); + + if no_color && !force_color { + colored::control::set_override(false); + } else if force_color { + colored::control::set_override(true); + } + + output(account, calculated); +} + +fn get_cli_matches() -> ArgMatches<'static> { + App::new("finbudg") + .version(crate_version!()) + .author(crate_authors!()) + .about(crate_description!()) + .arg(Arg::with_name("plain") + .short("p") + .long("plain") + .help("Don't colorize the output. Can also be set \ + with the NO_COLOR environment variable.") + .takes_value(false)) + .arg(Arg::with_name("force-color") + .long("force-color") + .help("Forces colorized output even when piping. Takes \ + precedence over --plain flag and NO_COLOR environment \ + variable") + .takes_value(false)) + .arg(Arg::with_name("INPUT") + .help("Expenses file in toml format to calculate from.") + .required(true) + .index(1)) + .get_matches() +} + +fn output(account: Account, calculated: Calculated) { + println!( + "{}", + format!( + "Your expenses for the period of {} - {}", + account.start_date.format("%Y-%m-%d"), + account.end_date.format("%Y-%m-%d"), + ).cyan(), + ); + + let last_day = match account.days.last() { + Some(day) => day, + None => { + println!("{}", "Your expenses are empty...".italic()); + + ::std::process::exit(0); + } + }; + + let days_until_end = account.end_date - last_day.date; + + println!( + "{}", + format!( + "Last day on entry: {}", + last_day.date.format("%Y-%m-%d"), + ).cyan(), + ); + + println!( + "{}", + format!( + "Days until period end: {}", + days_until_end.num_days(), + ).cyan(), + ); + + if days_until_end < Duration::zero() { + println!(); + println!( + "{}", + "Your last day on entry is set after the last date of the period!" + .yellow(), + ); + println!(); + } + + println!( + "{}", + format!( + "Budget: {:.2}", + account.budget, + ).cyan(), + ); + + println!(); + + for (category, expenses) in calculated.categories_day_average.iter() { + println!( + "Average per day in {}: {:.2}", + category, + expenses, + ); + } + + println!( + "Average per day in essential expenses: {:.2}", + calculated.essential_day_average, + ); + + println!( + "Average per day: {:.2}", + calculated.all_day_average, + ); + + println!(); + + for (category, expenses) in calculated.categories_subtotal.iter() { + println!( + "Total in {}: {:.2}", + category, + expenses, + ); + } + + println!( + "Total in essential expenses: {:.2}", + calculated.essential_subtotal, + ); + + println!( + "Total: {:.2}", + calculated.total, + ); + + println!(); + + let balance_output = format!("{:.2}", calculated.balance); + let balance_output = if calculated.balance > 0.0 { + if account.budget / calculated.balance < 10.0 { + balance_output.green() + } else { + balance_output.yellow() + } + } else { + balance_output.red() + }; + + println!("Left on balance: {}", balance_output); + + println!(); + + println!("Days until balance runs out:"); + + let days_left_output = format!( + "{:.2}", + calculated.days_left, + ); + let days_left_essential_output = format!( + "{:.2}", + calculated.days_left_essential, + ); + + let mut all_are_healthy = true; + let mut essential_are_healthy = true; + + let days_left_output = + if days_until_end.num_days() as f64 <= calculated.days_left { + days_left_output.green() + } else { + all_are_healthy = false; + + days_left_output.red() + }; + let days_left_essential_output = + if days_until_end.num_days() as f64 <= calculated.days_left_essential { + days_left_essential_output.green() + } else { + essential_are_healthy = false; + + days_left_essential_output.red() + }; + + println!( + "..taking into account all expenses: {}", + days_left_output, + ); + println!( + "..taking into account only essential expenses: {}", + days_left_essential_output, + ); + println!(); + + if all_are_healthy { + println!( + "{}", + "Your expenses are healthy, they should last you from your last \ + day on entry through your last day of the period.".green(), + ); + } else { + println!( + "{}", + "You are spending more than you can afford with your current \ + budget. Try minimizing your expenses".red(), + ); + if essential_are_healthy { + println!( + "{}", + "On the other hand, if you only spend money on essentials, \ + you should be able keep within your budget.".yellow(), + ); + } + } +} -- cgit v1.2.3