aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYaroslav <contact@yaroslavps.com>2020-10-05 21:20:59 +0300
committerYaroslav <contact@yaroslavps.com>2020-10-05 21:20:59 +0300
commit408b0ac993496b108ec1e479151d549e9535051a (patch)
tree6f19aa190a7f5707cc037f3f9590a89187582254
downloadfinbudg-408b0ac993496b108ec1e479151d549e9535051a.tar.gz
finbudg-408b0ac993496b108ec1e479151d549e9535051a.zip
initial commitv0.1.0
-rw-r--r--.gitignore1
-rw-r--r--Cargo.lock261
-rw-r--r--Cargo.toml24
-rw-r--r--LICENSE21
-rw-r--r--README.md119
-rw-r--r--budget/Cargo.toml12
-rw-r--r--budget/src/lib.rs162
-rw-r--r--budget/tests/budget.rs149
-rw-r--r--budget/tests/test.toml53
-rw-r--r--src/main.rs244
10 files changed, 1046 insertions, 0 deletions
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 <contact@yaroslavps.com>"]
+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 <contact@yaroslavps.com>"]
+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<String>,
+ pub days: Vec<Day>,
+}
+
+#[derive(Deserialize, PartialEq, Debug)]
+pub struct Day {
+ #[serde(deserialize_with = "deserialize_date")]
+ pub date: NaiveDate,
+ pub expenses: Vec<Expense>,
+}
+
+#[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<String>,
+}
+
+#[derive(PartialEq, Debug)]
+pub struct Calculated {
+ pub all_day_average: f64,
+ pub essential_day_average: f64,
+ pub categories_day_average: HashMap<String, f64>,
+ pub essential_subtotal: f64,
+ pub categories_subtotal: HashMap<String, f64>,
+ 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<NaiveDate, D::Error>
+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<Account, ParseError> {
+ let contents = match fs::read_to_string(path) {
+ Ok(data) => data,
+ Err(error) => {
+ return Err(ParseError::IOError(error.kind()));
+ },
+ };
+
+ match toml::from_str::<Account>(&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::<String, f64>::new(),
+ essential_subtotal: 0.0,
+ categories_subtotal: HashMap::<String, f64>::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<Calculated, ParseError> {
+ 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::<String, f64>::new(),
+ essential_subtotal: 18.9,
+ categories_subtotal: HashMap::<String, f64>::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(),
+ );
+ }
+ }
+}