diff options
| author | Yaroslav <contact@yaroslavps.com> | 2020-10-05 21:20:59 +0300 | 
|---|---|---|
| committer | Yaroslav <contact@yaroslavps.com> | 2020-10-05 21:20:59 +0300 | 
| commit | 408b0ac993496b108ec1e479151d549e9535051a (patch) | |
| tree | 6f19aa190a7f5707cc037f3f9590a89187582254 | |
| download | finbudg-0.1.0.tar.gz finbudg-0.1.0.zip | |
initial commitv0.1.0
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | Cargo.lock | 261 | ||||
| -rw-r--r-- | Cargo.toml | 24 | ||||
| -rw-r--r-- | LICENSE | 21 | ||||
| -rw-r--r-- | README.md | 119 | ||||
| -rw-r--r-- | budget/Cargo.toml | 12 | ||||
| -rw-r--r-- | budget/src/lib.rs | 162 | ||||
| -rw-r--r-- | budget/tests/budget.rs | 149 | ||||
| -rw-r--r-- | budget/tests/test.toml | 53 | ||||
| -rw-r--r-- | src/main.rs | 244 | 
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 @@ -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(), +            ); +        } +    } +} | 
